commit 975385da5db727f010ff030916020e5bbe6b4b22 Author: glances-rs Date: Fri Oct 9 23:25:35 2026 +0000 M1: core engine, config, plugin model and core plugins - Cargo workspace (core, plugins, tui, cli) with drop-in compatible CLI - Python-compatible config file handling, limits, views, rates, MMM, history - process collection via sysinfo + procfs, filters, thresholds, events, actions - 20 core plugins (cpu, percpu, mem, memswap, load, fs, network, diskio, ...) - stdout, stdout-csv, stdout-json, --issue, --modules-list output modes - differential test harness against the Python implementation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d12cc94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0920fd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +**/*.rs.bk +*.log +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dae6963 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2329 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glances-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "ctrlc", + "glances-core", + "glances-plugins", + "glances-tui", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "glances-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "indexmap", + "libc", + "regex", + "serde", + "serde_json", + "shlex", + "sysinfo", + "thiserror 2.0.21", + "tracing", + "unicode-width", +] + +[[package]] +name = "glances-plugins" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "glances-core", + "indexmap", + "libc", + "regex", + "serde", + "serde_json", + "sysinfo", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "glances-tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "glances-core", + "glances-plugins", + "indexmap", + "ratatui", + "serde_json", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a" +dependencies = [ + "nix 0.30.1", + "windows-sys", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b568374ba38b33a6c627141f891faf16902b08d2db26b8ede1bcb0a15b1919fa" +dependencies = [ + "memchr", + "psm", + "stacker", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66e184b924cebaaff20ab2256ca52f12332d528a39aa76553b5d96f92aacf7f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87478d267e4de54a626af9754f2f0f58e927aac6ed0575fe89bc05ad6851694" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "pest_meta" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f986f248b4241ac359b831f6139aaa34e03b08a37b6caf7e201a33f95c869e1" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "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 = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ba64d67 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] +resolver = "2" +members = [ + "crates/glances-core", + "crates/glances-plugins", + "crates/glances-tui", + "crates/glances-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +repository = "https://example.invalid/glances-rs" +rust-version = "1.85" + +[workspace.dependencies] +glances-core = { path = "crates/glances-core" } +glances-plugins = { path = "crates/glances-plugins" } +glances-tui = { path = "crates/glances-tui" } + +anyhow = "1" +clap = { version = "4", features = ["derive", "wrap_help"] } +clap_complete = "4" +indexmap = { version = "2", features = ["serde"] } +libc = "0.2" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +thiserror = "2" +unicode-width = "0.2" +tracing = "0.1" + +[profile.release] +lto = "thin" +strip = true diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..235d67c --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Glances-rs contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1792ac6 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# glances-rs + +A Rust rewrite of [Glances](https://github.com/nicolargo/glances), the system +monitoring tool, targeting Linux. + +This project is a drop-in replacement for the **standalone** Glances modes: + +- curses-like terminal UI (`glances`) +- stdout modes: `--stdout`, `--stdout-csv`, `--stdout-json` +- `--fetch`, `--issue`, `--modules-list` +- all plugins, including extended ones (containers, GPUs, sensors, ...) +- the same `glances.conf` configuration file + +The following Python Glances features are intentionally **not** provided: +REST API, WebUI, MCP, client/server mode, discovery/browser, SNMP and exporters. + +## Status + +Work in progress. See the milestones in the project plan; the workspace is +organized as: + +| Crate | Purpose | +| --- | --- | +| `glances-core` | plugin model, config, limits, history, events, filters, output | +| `glances-plugins` | collectors and plugin registry/engine | +| `glances-tui` | terminal UI (ratatui) | +| `glances-cli` | the `glances` binary | + +## License + +Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or +[MIT license](LICENSE-MIT) at your option. diff --git a/conf/fetch-templates/short.jinja b/conf/fetch-templates/short.jinja new file mode 100644 index 0000000..a58df8d --- /dev/null +++ b/conf/fetch-templates/short.jinja @@ -0,0 +1,9 @@ +✨ {{ gl.system['hostname'] }}{{ ' - ' + gl.ip['address'] if gl.ip['address'] else '' }} +⚙️ {{ gl.system['hr_name'] }} | Uptime: {{ gl.uptime }} + +💡 LOAD {{ '%0.2f'| format(gl.load['min1']) }} {{ '%0.2f'| format(gl.load['min5']) }} {{ '%0.2f'| format(gl.load['min15']) }} +⚡ CPU {{ gl.bar(gl.cpu['total']) }} {{ gl.cpu['total'] }}% of {{ gl.core['log'] }} cores +🧠 MEM {{ gl.bar(gl.mem['percent']) }} {{ gl.mem['percent'] }}% ({{ gl.auto_unit(gl.mem['used']) }} {{ gl.auto_unit(gl.mem['total']) }}) +{% for fs in gl.fs.keys() %}💾 {% if loop.index == 1 %}DISK{% else %} {% endif %} {{ gl.bar(gl.fs[fs]['percent']) }} {{ gl.fs[fs]['percent'] }}% ({{ gl.auto_unit(gl.fs[fs]['used']) }} {{ gl.auto_unit(gl.fs[fs]['size']) }}) for {{ fs }} +{% endfor %}{% for net in gl.network.keys() %}📡 {% if loop.index == 1 %}NET{% else %} {% endif %} ↓ {{ gl.auto_unit(gl.network[net]['bytes_recv_rate_per_sec']) }}b/s ↑ {{ gl.auto_unit(gl.network[net]['bytes_sent_rate_per_sec']) }}b/s for {{ net }} +{% endfor %} diff --git a/conf/fetch-templates/with-logo.jinja b/conf/fetch-templates/with-logo.jinja new file mode 100644 index 0000000..d58083a --- /dev/null +++ b/conf/fetch-templates/with-logo.jinja @@ -0,0 +1,23 @@ + _____ _ + / ____| | + | | __| | __ _ _ __ ___ ___ ___ + | | |_ | |/ _` | '_ \ / __/ _ \/ __| + | |__| | | (_| | | | | (_| __/\__ + \_____|_|\__,_|_| |_|\___\___||___/ + + +✨ {{ gl.system['hostname'] }}{{ ' - ' + gl.ip['address'] if gl.ip['address'] else '' }} +⚙️ {{ gl.system['hr_name'] }} | Uptime: {{ gl.uptime }} + +💡 LOAD {{ '%0.2f'| format(gl.load['min1']) }} {{ '%0.2f'| format(gl.load['min5']) }} {{ '%0.2f'| format(gl.load['min15']) }} +⚡ CPU {{ gl.bar(gl.cpu['total']) }} {{ gl.cpu['total'] }}% of {{ gl.core['log'] }} cores +🧠 MEM {{ gl.bar(gl.mem['percent']) }} {{ gl.mem['percent'] }}% ({{ gl.auto_unit(gl.mem['used']) }} {{ gl.auto_unit(gl.mem['total']) }}) +{% for fs in gl.fs.keys() %}💾 {% if loop.index == 1 %}DISK{% else %} {% endif %} {{ gl.bar(gl.fs[fs]['percent']) }} {{ gl.fs[fs]['percent'] }}% ({{ gl.auto_unit(gl.fs[fs]['used']) }} {{ gl.auto_unit(gl.fs[fs]['size']) }}) for {{ fs }} +{% endfor %}{% for net in gl.network.keys() %}📡 {% if loop.index == 1 %}NET{% else %} {% endif %} ↓ {{ gl.auto_unit(gl.network[net]['bytes_recv_rate_per_sec']) }}b/s ↑ {{ gl.auto_unit(gl.network[net]['bytes_sent_rate_per_sec']) }}b/s for {{ net }} +{% endfor %} +🔥 TOP PROCESS by CPU +{% for process in gl.top_process() %}{{ loop.index }}️⃣ {{ process['name'][:20] }}{{ ' ' * (20 - process['name'][:20] | length) }} ⚡ {{ process['cpu_percent'] }}% CPU{{ ' ' * (8 - (gl.auto_unit(process['cpu_percent']) | length)) }} 🧠 {{ gl.auto_unit(process['memory_info']['rss']) }}B MEM +{% endfor %} +🔥 TOP PROCESS by MEM +{% for process in gl.top_process(sorted_by='memory_percent', sorted_by_secondary='cpu_percent') %}{{ loop.index }}️⃣ {{ process['name'][:20] }}{{ ' ' * (20 - process['name'][:20] | length) }} 🧠 {{ gl.auto_unit(process['memory_info']['rss']) }}B MEM{{ ' ' * (7 - (gl.auto_unit(process['memory_info']['rss']) | length)) }} ⚡ {{ process['cpu_percent'] }}% CPU +{% endfor %} \ No newline at end of file diff --git a/conf/glances.conf b/conf/glances.conf new file mode 100644 index 0000000..f15a795 --- /dev/null +++ b/conf/glances.conf @@ -0,0 +1,1086 @@ +############################################################################## +# Globals Glances parameters +############################################################################## + +[global] +# Stats refresh rate (default is a minimum of 2 seconds) +# Can be overwrite by the -t option +# It is also possible to overwrite it in each plugin sections +refresh=2 +# Does Glances should check if a newer version is available on PyPI ? +check_update=true +# History size (maximum number of values) +# Default is 1200 values (~1h with the default refresh rate) +history_size=1200 +# Set the way Glances should display the date (default is %Y-%m-%d %H:%M:%S %Z) +#strftime_format=%Y-%m-%d %H:%M:%S %Z +# Define external directory for loading additional plugins +# The layout follows the glances standard for plugin definitions +#plugin_dir=/home/user/dev/plugins + +############################################################################## +# User interface +############################################################################## + +[outputs] +# Options for all UIs +#-------------------- +# Separator in the Curses and WebUI interface (between top and others plugins) +#separator=True +# Set the the Curses and WebUI interface left menu plugin list (comma-separated) +#left_menu=network,wifi,connections,ports,diskio,fs,irq,folders,raid,smart,sensors,now +# Limit the number of processes to display (in the WebUI) +#max_processes_display=25 +# +# Specifics options for TUI +#-------------------------- +# Disable background color +#disable_bg=True +# +# Specifics options for Glances server +#------------------------------------- +# Set URL prefix for the WebUI and the API +# Example: url_prefix=/glances/ => http://localhost/glances/ +# Note: The final / is mandatory +# Default is no prefix (/) +#url_prefix=/glances/ +# Set root path for WebUI statics files +# Why ? On Debian system, WebUI statics files are not provided. +# You can download it in a specific folder +# thanks to https://github.com/nicolargo/glances/issues/2021 +# then configure this folder with the webui_root_path key +# Default is folder where glances_restful_api.py is hosted +#webui_root_path= +# +# CORS options +# Comma separated list of origins that should be permitted to make cross-origin requests. +# This option is available for both Restful and XML-RPC server. +# When multiple origins are listed, each request's Origin header is matched +# against the list and reflected only if it is an exact match. +# Default is * (any origin) — set explicit origins to restrict browser access. +#cors_origins=* +# Indicate that cookies should be supported for cross-origin requests. +# Default is False. +# Set to True only when cors_origins is explicitly configured with specific origins. +#cors_credentials=False +# Comma separated list of HTTP methods that should be allowed for cross-origin requests. +# Default is * +#cors_methods=* +# Comma separated list of HTTP request headers that should be supported for cross-origin requests. +# Default is * +#cors_headers=* +# +# Define SSL files (keyfile_password is optional) +#ssl_keyfile_password=kfp +#ssl_keyfile=./glances.local+3-key.pem +#ssl_certfile=./glances.local+3.pem +# +# JWT Authentication settings +# Secret key for signing JWT tokens (generate with: openssl rand -hex 32) +# If not set, a random key is generated per server instance (tokens won't survive restart) +#jwt_secret_key=your-secure-secret-key-here +# Token expiration time in minutes (default: 60) +#jwt_expire_minutes=60 +# +# DNS rebinding protection for the REST API / WebUI +# Restrict the HTTP Host header accepted by the web server. +# Comma-separated list of hostnames or IPs. Wildcards are supported (e.g. *.example.com). +# When this key is absent or commented out, no host filtering is applied (default behaviour). +# Recommended for any internet-facing or multi-tenant deployment. +#webui_allowed_hosts=localhost,127.0.0.1,myserver.example.com +# +# MCP +# Overwrite the default MCP path +#mcp_path=/mcp +# Allowed Host headers for the MCP SSE endpoint (DNS rebinding protection). +# Comma-separated list. Defaults to localhost,127.0.0.1 when not set. +# Set to * to allow any host - use only behind a trusted reverse proxy. +#mcp_allowed_hosts=localhost,127.0.0.1,myserver.example.com +# +# DNS rebinding protection for the XML-RPC server (glances -s) +# Restrict the HTTP Host header accepted by the XML-RPC server. +# Comma-separated list of hostnames or IPs. Wildcards supported (e.g. *.example.com). +# When this key is absent or commented out, no host filtering is applied (default behaviour). +# Recommended for any internet-facing or multi-tenant deployment. +#xmlrpc_allowed_hosts=localhost,127.0.0.1,myserver.example.com + +############################################################################## +# Plugins +############################################################################## + +[quicklook] +# Set to true to disable a plugin +# Note: you can also disable it from the command line (see --disable-plugin ) +disable=False +# Stats list (default is cpu,mem,load) +# Available stats are: cpu,mem,load,swap, gpu_mem, gpu_proc +list=cpu,mem,load +# Graphical bar char used in the terminal user interface (default is |) +bar_char=▪ +# Define CPU, MEM and SWAP thresholds in % +cpu_careful=50 +cpu_warning=70 +cpu_critical=90 +mem_careful=50 +mem_warning=70 +mem_critical=90 +swap_careful=50 +swap_warning=70 +swap_critical=90 +# Source: http://blog.scoutapp.com/articles/2009/07/31/understanding-load-averages +# With 1 CPU core, the load should be lower than 1.00 ~ 100% +load_careful=70 +load_warning=100 +load_critical=500 + +# Taken from gpu plugin config +# Default GPU load thresholds in % +gpu_proc_careful=50 +gpu_proc_warning=70 +gpu_proc_critical=90 +# Default GPU memory thresholds in % +gpu_mem_careful=50 +gpu_mem_warning=70 +gpu_mem_critical=90 + + +[system] +# This plugin display the first line in the Glances UI with: +# Hostname / Operating system name / Architecture information +# Set to true to disable a plugin +disable=False +# Default refresh rate is 60 seconds +#refresh=60 +# System information to display (a string where {key} will be replaced by the value) +# Available information are: hostname, os_name, os_version, os_arch, linux_distro, platform +#system_info_msg= | My {os_name} system | + +[cpu] +disable=False +# See https://scoutapm.com/blog/slow_server_flow_chart +# +# I/O wait percentage should be lower than 1/# (# = Logical CPU cores) +# Leave commented to just use the default config: 50/70/90 +#iowait_careful=30 +#iowait_warning=40 +#iowait_critical=50 +# +# Total % is 100 - idle +total_careful=65 +total_warning=75 +total_critical=85 +total_log=True +# +# Default values if not defined: 50/70/90 +user_careful=50 +user_warning=70 +user_critical=90 +user_log=False +#user_critical_action=echo "{{time}} User CPU {{user}} higher than {{critical}}" > /tmp/cpu.alert +# +system_careful=50 +system_warning=70 +system_critical=90 +system_log=False +# +steal_careful=50 +steal_warning=70 +steal_critical=90 +#steal_log=True +# +# Context switch limit (core / second) +# Leave commented to just use the default config critical is 50000*(Logical CPU cores) +#ctx_switches_careful=10000 +#ctx_switches_warning=12000 +#ctx_switches_critical=14000 + +[percpu] +disable=False +# Define the maximum number of CPU displayed at a time +# If the number of CPU is higher than the one configured in max_cpu_display then: +# - display top 'max_cpu_display' (sorted by CPU consumption) +# - a last line will be added with the mean of all other CPUs +max_cpu_display=4 +# Define CPU thresholds in % +# Default values if not defined: 50/70/90 +user_careful=50 +user_warning=70 +user_critical=90 +iowait_careful=50 +iowait_warning=70 +iowait_critical=90 +system_careful=50 +system_warning=70 +system_critical=90 + +[gpu] +disable=False +# Default GPU load thresholds in % +proc_careful=50 +proc_warning=70 +proc_critical=90 +# Default GPU memory thresholds in % +mem_careful=50 +mem_warning=70 +mem_critical=90 +# Default GPU temperature thresholds in degrees Celsus +temperature_careful=60 +temperature_warning=70 +temperature_critical=80 + +[npu] +disable=True +# Default NPU load thresholds in % +load_careful=50 +load_warning=70 +load_critical=90 +# Default NPU frequency thresholds in % +freq_careful=50 +freq_warning=70 +freq_critical=90 +# Default NPU temperature thresholds in degrees Celsius +temperature_careful=60 +temperature_warning=70 +temperature_critical=80 + +[mpp] +disable=True +# Default MPP engine load thresholds in % +load_careful=50 +load_warning=70 +load_critical=90 + +[mem] +disable=False +# Display available memory instead of used memory +#available=True +# Define RAM thresholds in % +# Default values if not defined: 50/70/90 +careful=50 +warning=70 +critical=90 +#critical_action_repeat=echo "{{time}} {{percent}} higher than {{critical}}"" >> /tmp/memory.alert + +[memswap] +disable=False +# Define SWAP thresholds in % +# Default values if not defined: 50/70/90 +careful=50 +warning=70 +critical=90 +#warning_action=echo "{{time}} {{percent}} higher than {{warning}}"" > /tmp/memory.alert + +[load] +disable=False +# Define LOAD thresholds +# Value * number of cores +# Default values if not defined: 0.7/1.0/5.0 per number of cores +# Source: http://blog.scoutapp.com/articles/2009/07/31/understanding-load-averages +# http://www.linuxjournal.com/article/9001 +careful=0.7 +warning=1.0 +critical=5.0 +#log=False + +[network] +disable=False +# Default bitrate thresholds in % of the network interface speed +# Default values if not defined: 70/80/90 +rx_careful=70 +rx_warning=80 +rx_critical=90 +tx_careful=70 +tx_warning=80 +tx_critical=90 +# Define the list of hidden network interfaces (comma-separated regexp) +hide=docker.*,lo +# Define the list of wireless network interfaces to be show (comma-separated) +#show=docker.* +# Automatically hide interface not up (default is False) +hide_no_up=True +# Automatically hide interface with no IP address (default is False) +hide_no_ip=True +# Set hide_zero to True to automatically hide interface with no traffic +hide_zero=False +# Set hide_threshold_bytes to an integer value to automatically hide +# interface with traffic less or equal than this value +#hide_threshold_bytes=0 +# It is possible to overwrite the bitrate thresholds per interface +# WLAN 0 Default limits (in bits per second aka bps) for interface bitrate +#wlan0_rx_careful=4000000 +#wlan0_rx_warning=5000000 +#wlan0_rx_critical=6000000 +#wlan0_rx_log=True +#wlan0_tx_careful=700000 +#wlan0_tx_warning=900000 +#wlan0_tx_critical=1000000 +#wlan0_tx_log=True +#wlan0_rx_critical_action=echo "{{time}} {{interface_name}} RX {{bytes_recv_rate_per_sec}}Bps" > /tmp/network.alert +# Alias for network interface name +#alias=wlp0s20f3:WIFI + +[ip] +# Disable display of private IP address +disable=False +# Refresh rate for this plugin (in seconds). Default is the global refresh rate. +# IP address changes rarely. +refresh=60 +# Configure the online service where public IP address information will be downloaded +# - public_disabled: Disable public IP address information (set to True for offline platform) +# - public_refresh_interval: Refresh interval between to calls to the online service +# - public_api: URL of the API (the API should return an JSON object) +# - public_username: Login for the online service (if needed) +# - public_password: Password for the online service (if needed) +# - public_field: Field name of the public IP address in onlibe service JSON message +# - public_template: Template to build the public message +# +# Example for IPLeak service: +# public_api=https://ipv4.ipleak.net/json/ +# public_field=ip +# public_template={ip} {continent_name}/{country_name}/{city_name} +# +public_disabled=True +public_refresh_interval=300 +public_api=https://ipv4.ipleak.net/json/ +#public_username= +#public_password= +public_field=ip +public_template={continent_name}/{country_name}/{city_name} + +[connections] +# Display additional information about TCP connections +# This plugin is disabled by default because it consumes lots of CPU +disable=True +# nf_conntrack thresholds in % +nf_conntrack_percent_careful=70 +nf_conntrack_percent_warning=80 +nf_conntrack_percent_critical=90 + +[wifi] +disable=False +# Define SIGNAL thresholds in dBm (lower is better...) +# Based on: http://serverfault.com/questions/501025/industry-standard-for-minimum-wifi-signal-strength +careful=-65 +warning=-75 +critical=-85 + +[diskio] +disable=False +# Define the list of hidden disks (comma-separated regexp) +#hide=sda2,sda5,loop.* +hide=loop.*,/dev/loop.* +# Set hide_zero to True to automatically hide disk with no read/write +hide_zero=False +# Set hide_threshold_bytes to an integer value to automatically hide +# interface with traffic less or equal than this value +#hide_threshold_bytes=0 +# Define the list of disks to be show (comma-separated) +#show=sda.* +# Alias for sda1 and sdb1 +#alias=sda1:SystemDisk,sdb1:DataDisk +# Default latency thresholds (in ms) (rx = read / tx = write) +rx_latency_careful=10 +rx_latency_warning=20 +rx_latency_critical=50 +tx_latency_careful=10 +tx_latency_warning=20 +tx_latency_critical=50 +# Set latency thresholds (latency in ms) for a given disk name (rx = read / tx = write) +# dm-0_rx_latency_careful=10 +# dm-0_rx_latency_warning=20 +# dm-0_rx_latency_critical=50 +# dm-0_rx_latency_log=False +# dm-0_tx_latency_careful=10 +# dm-0_tx_latency_warning=20 +# dm-0_tx_latency_critical=50 +# dm-0_tx_latency_log=False +# There is no default bitrate thresholds for disk (because it is not possible to know the disk speed) +# Set bitrate thresholds (in bytes per second) for a given disk name (rx = read / tx = write) +#dm-0_rx_careful=4000000000 +#dm-0_rx_warning=5000000000 +#dm-0_rx_critical=6000000000 +#dm-0_rx_log=False +#dm-0_tx_careful=700000000 +#dm-0_tx_warning=900000000 +#dm-0_tx_critical=1000000000 +#dm-0_tx_log=False + +[fs] +disable=False +# Display free space instead of used space (same as --fs-free-space) +#free_space=False +# Refresh rate for this plugin (in seconds). Default is the global refresh rate. +# Filesystem usage (statvfs) changes slowly. +refresh=60 +# Define the list of file system to hide (comma-separated regexp) +hide=/boot.*,.*/snap.* +# Define the list of file system to show (comma-separated regexp) +#show=/,/srv +# Define filesystem space thresholds in % +# Default values if not defined: 50/70/90 +careful=50 +warning=70 +critical=90 +# It is also possible to define per mount point value +# Example: /_careful=40 +#/_careful=1 +#/_warning=5 +#/_critical=10 +#/_critical_action=echo "{{time}} {{mnt_point}} filesystem space {{percent}}% higher than {{critical}}%" > /tmp/fs.alert +# Allow additional file system types (comma-separated FS type) +#allow=shm +# Alias for root file system +#alias=/:Root,/zfspool:ZFS + +[irq] +# Documentation: https://glances.readthedocs.io/en/latest/aoa/irq.html +# This plugin is disabled by default +disable=True + +[folders] +# Documentation: https://glances.readthedocs.io/en/latest/aoa/folders.html +disable=False +# Refresh rate for this plugin (in seconds). Default is the global refresh rate. +# folder_size() walks the entire directory tree — use a long interval. +# Can be overridden per folder with folder__refresh (see below). +refresh=60 +# Define a folder list to monitor +# The list is composed of items (list_#nb <= 10) +# An item is defined by: +# * path: absolute path +# * careful: optional careful threshold (in MB) +# * warning: optional warning threshold (in MB) +# * critical: optional critical threshold (in MB) +# * refresh: interval in second between two refreshes +#folder_1_path=/tmp +#folder_1_careful=2500 +#folder_1_warning=3000 +#folder_1_critical=3500 +#folder_1_refresh=60 +#folder_2_path=/home/nicolargo/Videos +#folder_2_warning=17000 +#folder_2_critical=20000 +#folder_3_path=/nonexisting +#folder_4_path=/root + +[cloud] +# Documentation: https://glances.readthedocs.io/en/latest/aoa/cloud.html +# This plugin is disabled by default +disable=True +# Refresh rate for this plugin (in seconds). Default is the global refresh rate. +# Cloud metadata API calls are slow. +refresh=120 + +[raid] +# Documentation: https://glances.readthedocs.io/en/latest/aoa/raid.html +# This plugin is disabled by default +disable=True + +[smart] +# Documentation: https://glances.readthedocs.io/en/latest/aoa/smart.html +# This plugin is disabled by default +disable=True +# Define the list of sensors to hide (comma-separated regexp) +#hide=.*Hide_this_driver.* +# Define the list of sensors to show (comma-separated regexp) +#show=.*Drive_Temperature.* +# List of attributes to hide (comma separated) +#hide_attributes=Self-tests,Errors + +[hddtemp] +disable=False +# Define hddtemp server IP and port (default is 127.0.0.1 and 7634 (TCP)) +host=127.0.0.1 +port=7634 + +[sensors] +# Documentation: https://glances.readthedocs.io/en/latest/aoa/sensors.html +disable=False +# Refresh rate for this plugin (in seconds). Default is the global refresh rate. +# Sensors (I2C/ACPI) are slow to read and change infrequently. +refresh=10 +# Hide some sensors (comma separated list of regexp) +hide=unknown.* +# Show only the following sensors (comma separated list of regexp) +#show=CPU.* +# Sensors core thresholds (in Celsius...) +# Note: By default values are grabbed from the system (if values are available) +# Core temperature thresholds in °C +# Default values if not defined: 45/52/60 +temperature_core_careful=45 +temperature_core_warning=65 +temperature_core_critical=80 +# Temperatures threshold in °C for hddtemp +# Default values if not defined: 45/52/60 +#temperature_hdd_careful=45 +#temperature_hdd_warning=52 +#temperature_hdd_critical=60 +# Battery threshold in % +# Default values if not defined: 70/80/90 +#battery_careful=70 +#battery_warning=80 +#battery_critical=90 +# Fan speed threshold in RPM +#fan_speed_careful=100 +# Overwrite thresholds for a specific sensor +#temperature_core_Ambient_careful=40 +#temperature_core_Ambient_warning=60 +#temperature_core_Ambient_critical=85 +#temperature_core_Ambient_log=True +#temperature_core_Ambient_critical_action=echo "{{time}} {{label}} temperature {{value}}{{unit}} higher than {{critical}}{{unit}}" > /tmp/temperature.alert +# Sensors alias +#alias=core 0:CPU Core 0,core 1:CPU Core 1 + +[processcount] +disable=False +# If you want to change the refresh rate of the processing list, please uncomment: +#refresh=10 + +[processlist] +disable=False +# Sort key: if not defined, the sort is automatically done by Glances (recommended) +# Should be one of the following: +# cpu_percent, memory_percent, io_counters, name, cpu_times, username +#sort_key=memory_percent +# List of stats to disable (not grabed and not display) +# Stats that can be disabled: cpu_percent,memory_info,memory_percent,username,cpu_times,num_threads,nice,status,io_counters,cmdline,cpu_num +# Stats that can not be disable: pid,name +disable_stats=cpu_num +# Disable display of virtual memory +#disable_virtual_memory=True +# Define CPU/MEM (per process) thresholds in % +# Default values if not defined: 50/70/90 +cpu_careful=50 +cpu_warning=70 +cpu_critical=90 +mem_careful=50 +mem_warning=70 +mem_critical=90 +# +# Nice priorities range from -20 to 19. +# Configure nice levels using a comma-separated list. +# +# Nice: Example 1, non-zero is warning (default behavior) +nice_warning=-20,-19,-18,-17,-16,-15,-14,-13,-12,-11,-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 +# +# Nice: Example 2, low priority processes escalate from careful to critical +#nice_ok=O +#nice_careful=1,2,3,4,5,6,7,8,9 +#nice_warning=10,11,12,13,14 +#nice_critical=15,16,17,18,19 +# +# Status: define threshold regarding the process status (first letter of process status) +# R: Running, S: Sleeping, Z: Zombie (complete list here https://psutil.readthedocs.io/en/latest/#process-status-constants) +status_ok=R,W,P,I +status_critical=Z,D +# Define the list of processes to export using: +# a comma-separated list of Glances filter +#export=.*firefox.*,pid:1234 +# Define a list of process to focus on (comma-separated list of Glances filter) +#focus=.*firefox.*,.*python.* + +[ports] +disable=False +# Interval in second between two scans +# Ports scanner plugin configuration +refresh=30 +# Set the default timeout (in second) for a scan (can be overwritten in the scan list) +timeout=3 +# If port_default_gateway is True, add the default gateway on top of the scan list +port_default_gateway=True +# +# Define the scan list (1 < x < 255) +# port_x_host (name or IP) is mandatory +# port_x_port (TCP port number) is optional (if not set, use ICMP) +# port_x_description is optional (if not set, define to host:port) +# port_x_timeout is optional and overwrite the default timeout value +# port_x_rtt_warning is optional and defines the warning threshold in ms +# +#port_1_host=192.168.0.1 +#port_1_port=80 +#port_1_description=Home Box +#port_1_timeout=1 +#port_2_host=www.free.fr +#port_2_description=My ISP +#port_3_host=www.google.com +#port_3_description=Internet ICMP +#port_3_rtt_warning=1000 +#port_4_description=Internet Web +#port_4_host=www.google.com +#port_4_port=80 +#port_4_rtt_warning=1000 +# +# Define Web (URL) monitoring list (1 < x < 255) +# web_x_url is the URL to monitor (example: http://my.site.com/folder) +# web_x_description is optional (if not set, define to URL) +# web_x_timeout is optional and overwrite the default timeout value +# web_x_rtt_warning is optional and defines the warning respond time in ms (approximately) +# web_x_ssl_verify is optional (default is True): False to skip the certificate check, +# or the path to a CA bundle file +# +#web_1_url=https://blog.nicolargo.com +#web_1_description=My Blog +#web_1_rtt_warning=3000 +#web_2_url=https://github.com +#web_3_url=http://www.google.fr +#web_3_description=Google Fr +#web_4_url=https://blog.nicolargo.com/nonexist +#web_4_description=Intranet + +[vms] +disable=True +# Thresholds, same shape as [containers]. Leave commented to keep the VM table +# uncoloured, which is what it was before these existed. +# CPU is the per-second rate of the VM cpu_time; MEM is against that VM own +# memory_total; LOAD is the 1 min load the engine reports, if it reports one. +#cpu_careful=50 +#cpu_warning=70 +#cpu_critical=90 +#mem_careful=20 +#mem_warning=50 +#mem_critical=70 +#load_careful=70 +#load_warning=100 +#load_critical=500 +# Per-VM override, like containername_cpu_careful: +#vmname_mem_careful=10 +# Define the maximum VMs size name (default is 20 chars) +max_name_size=20 +# By default, Glances only display running VMs with states: +# 'Running', 'Paused', 'Starting' or 'Restarting' +# Set the following key to True to display all VMs regarding their states +all=False + +[containers] +disable=False +# Only show specific containers (comma-separated list of container name or regular expression) +# Comment this line to display all containers (default configuration) +; show=telegraf +# Hide some containers (comma-separated list of container name or regular expression) +# Comment this line to display all containers (default configuration) +; hide=telegraf +# Define the maximum docker size name (default is 20 chars) +max_name_size=20 +# List of stats to disable (not display) +# Following stats can be disabled: name,status,uptime,cpu,mem,diskio,networkio,ports,command +disable_stats=command +# Thresholds for CPU and MEM (in %) +; cpu_careful=50 +; cpu_warning=70 +; cpu_critical=90 +; mem_careful=20 +; mem_warning=50 +; mem_critical=70 +# +# Per container thresholds +; containername_cpu_careful=10 +; containername_cpu_warning=20 +; containername_cpu_critical=30 +# +# By default, Glances only display running containers +# Set the following key to True to display all containers +all=False +# Define Podman sock +; podman_sock=unix:///run/user/1000/podman/podman.sock + +[amps] +# AMPs configuration are defined in the bottom of this file +disable=False + +[alert] +disable=False +# Maximum number of events to display (default is 10 events) +;max_events=10 +# Minimum duration for an event to be taken into account (default is 6 seconds) +;min_duration=6 +# Minimum time between two events of the same type (default is 6 seconds) +# This is used to avoid too many alerts for the same event +# Events will be merged +;min_interval=6 + +############################################################################## +# Browser mode - Static servers definition +############################################################################## + +[serverlist] +# Define columns (comma separated list of ::()) to grab/display +# Default is: system:hr_name,load:min5,cpu:total,mem:percent +# You can also add stats with key, like sensors:value:Ambient (key is case sensitive) +#columns=system:hr_name,load:min5,cpu:total,mem:percent,memswap:percent,sensors:value:Ambient,sensors:value:Composite +# Define the static servers list +# _protocol can be: rpc (default if not defined) or rest +# List is limited to 256 servers max (1 to 256) +#server_1_name=localhost +#server_1_alias=Local WebUI +#server_1_port=61266 +#server_1_protocol=rest +#server_2_name=localhost +#server_2_alias=My local PC +#server_2_port=61209 +#server_2_protocol=rpc +#server_3_name=192.168.0.17 +#server_3_alias=Another PC on my network +#server_3_port=61209 +#server_3_protocol=rpc +#server_4_name=notagooddefinition +#server_4_port=61237 + +[passwords] +# Define the passwords list related to the [serverlist] section +# Syntax: host=password +# Where: host is the hostname +# password is the clear password +# Additionally (and optionally) a default password could be defined +#localhost=abc +#default=defaultpassword +# +# Define the path of the local '.pwd' file (default is system one) +#local_password_path=~/.config/glances + +############################################################################## +# Exports +############################################################################## + +[export] +# Common section for all exporters +# Do not export following fields (comma separated list of regex) +#exclude_fields=.*_critical,.*_careful,.*_warning,.*\.key$ + +[graph] +# Configuration for the --export graph option +# Set the path where the graph (.svg files) will be created +# Can be overwrite by the --graph-path command line option +path=/tmp/glances +# It is possible to generate the graphs automatically by setting the +# generate_every to a non zero value corresponding to the seconds between +# two generation. Set it to 0 to disable graph auto generation. +generate_every=0 +# See following configuration keys definitions in the Pygal lib documentation +# http://pygal.org/en/stable/documentation/index.html +width=800 +height=600 +style=DarkStyle + +[influxdb] +# !!! +# Will be DEPRECATED in future release. +# Please have a look on the new influxdb3 export module +# !!! +# Configuration for the --export influxdb option +# https://influxdb.com/ +host=localhost +port=8086 +protocol=http +user=root +password=root +db=glances +# Prefix will be added for all measurement name +# Ex: prefix=foo +# => foo.cpu +# => foo.mem +# You can also use dynamic values +#prefix=foo +# Following tags will be added for all measurements +# You can also use dynamic values. +# Note: hostname and name (for process) are always added as a tag +#tags=foo:bar,spam:eggs,domain:`domainname` + +[influxdb2] +# Configuration for the --export influxdb2 option +# https://influxdb.com/ +host=localhost +port=8086 +protocol=http +org=nicolargo +bucket=glances +token=PUT_YOUR_INFLUXDB2_TOKEN_HERE +# Set the interval between two exports (in seconds) +# If the interval is set to 0, the Glances refresh time is used (default behavor) +#interval=0 +# Prefix will be added for all measurement name +# Ex: prefix=foo +# => foo.cpu +# => foo.mem +# You can also use dynamic values +#prefix=foo +# Following tags will be added for all measurements +# You can also use dynamic values. +# Note: hostname and name (for process) are always added as a tag +#tags=foo:bar,spam:eggs,domain:`domainname` + +[influxdb3] +# Configuration for the --export influxdb3 option +# https://influxdb.com/ +host=http://localhost:8181 +org=nicolargo +database=glances +token=PUT_YOUR_INFLUXDB3_TOKEN_HERE +# Set the interval between two exports (in seconds) +# If the interval is set to 0, the Glances refresh time is used (default behavor) +#interval=0 +# Prefix will be added for all measurement name +# Ex: prefix=foo +# => foo.cpu +# => foo.mem +# You can also use dynamic values +#prefix=foo +# Following tags will be added for all measurements +# You can also use dynamic values. +# Note: hostname and name (for process) are always added as a tag +#tags=foo:bar,spam:eggs,domain:`domainname` + +[cassandra] +# Configuration for the --export cassandra option +# Also works for the ScyllaDB +# https://influxdb.com/ or http://www.scylladb.com/ +host=localhost +port=9042 +protocol_version=3 +keyspace=glances +replication_factor=2 +# If not define, table name is set to host key +table=localhost +# If not define, username and password will not be used +#username=cassandra +#password=password + +[opentsdb] +# Configuration for the --export opentsdb option +# http://opentsdb.net/ +host=localhost +port=4242 +#prefix=glances +#tags=foo:bar,spam:eggs + +[statsd] +# Configuration for the --export statsd option +# https://github.com/etsy/statsd +host=localhost +port=8125 +#prefix=glances + +[elasticsearch] +# Configuration for the --export elasticsearch option +# Data are available via the ES RESTful API. ex: URL//cpu +# https://www.elastic.co +scheme=http +host=localhost +port=9200 +index=glances + +[riemann] +# Configuration for the --export riemann option +# http://riemann.io +host=localhost +port=5555 + +[rabbitmq] +# Configuration for the --export rabbitmq option +host=localhost +port=5672 +user=guest +password=guest +queue=glances_queue +#protocol=amqps + +[mqtt] +# Configuration for the --export mqtt option +host=localhost +# Overwrite device name in the topic +#devicename=localhost +port=8883 +tls=false +user=guest +password=guest +topic=glances +topic_structure=per-metric +callback_api_version=2 + +[couchdb] +# Configuration for the --export couchdb option +# https://www.couchdb.org +host=localhost +port=5984 +db=glances +user=admin +password=admin + +[mongodb] +# Configuration for the --export mongodb option +# https://www.mongodb.com +host=localhost +port=27017 +db=glances +user=root +password=example + +[kafka] +# Configuration for the --export kafka option +# http://kafka.apache.org/ +host=localhost +port=9092 +topic=glances +#compression=gzip +# Tags will be added for all events +#tags=foo:bar,spam:eggs +# You can also use dynamic values +#tags=hostname:`hostname -f` + +[zeromq] +# Configuration for the --export zeromq option +# http://www.zeromq.org +# Use * to bind on all interfaces +host=* +port=5678 +# Glances envelopes the stats in a publish message with two frames: +# - First frame containing the following prefix (STRING) +# - Second frame with the Glances plugin name (STRING) +# - Third frame with the Glances plugin stats (JSON) +prefix=G + +[prometheus] +# Configuration for the --export prometheus option +# https://prometheus.io +# Create a Prometheus exporter listening on localhost:9091 (default configuration) +# Metric are exporter using the following name: +# __{labelkey:labelvalue} +# Note: You should add this exporter to your Prometheus server configuration: +# scrape_configs: +# - job_name: 'glances_exporter' +# scrape_interval: 5s +# static_configs: +# - targets: ['localhost:9091'] +# +# Labels will be added for all measurements (default is src:glances) +# labels=foo:bar,spam:eggs +# You can also use dynamic values +# labels=system:`uname -s` +# +host=localhost +port=9091 +#prefix=glances +labels=src:glances + +[restful] +# Configuration for the --export restful option +# Example, export to http://localhost:6789/ +host=localhost +port=6789 +protocol=http +path=/ + +[graphite] +# Configuration for the --export graphite option +# https://graphiteapp.org/ +host=localhost +port=2003 +# Prefix will be added for all measurement name +prefix=glances +# System name added between the prefix and the stats +# By default, system_name = FQDN +#system_name=mycomputer + +[timescaledb] +# Configuration for the --export timescaledb option +# https://www.timescale.com/ +host=localhost +port=5432 +db=glances +user=postgres +password=password +# Overwrite device name (default is the FQDN) +# Most of the time, you should not overwrite this value +#hostname=mycomputer + +[nats] +# Configuration for the --export nats option +# https://nats.io/ +# Host is a separated list of NATS nodes +host=nats://localhost:4222 +# Prefix for the subjects (default is 'glances') +prefix=glances + +[duckdb] +# database defines where data are stored, can be one of: +# :memory: (see https://duckdb.org/docs/stable/clients/python/dbapi#in-memory-connection) +# :memory:glances (see https://duckdb.org/docs/stable/clients/python/dbapi#in-memory-connection) +# /path/to/glances.db (see https://duckdb.org/docs/stable/clients/python/dbapi#file-based-connection) +# Or anyone else supported by the API (see https://duckdb.org/docs/stable/clients/python/dbapi) +database=:memory: + +[clickhouse] +# Configuration for the --export clickhouse option +# https://clickhouse.com +host=localhost +port=8123 +db=glances +user=default +password=password + +############################################################################## +# AMPS +# * enable: Enable (true) or disable (false) the AMP +# * regex: Regular expression to filter the process(es) +# * refresh: The AMP is executed every refresh seconds +# * one_line: (optional) Force (if true) the AMP to be displayed in one line +# * command: (optional) command to execute when the process is detected (thk to the regex) +# * countmin: (optional) minimal number of processes +# A warning will be displayed if number of process < count +# * countmax: (optional) maximum number of processes +# A warning will be displayed if number of process > count +# * : Others variables can be defined and used in the AMP script +############################################################################## + +[amp_dropbox] +# Use the default AMP (no dedicated AMP Python script) +# Check if the Dropbox daemon is running +# Every 3 seconds, display the 'dropbox status' command line +enable=false +regex=.*dropbox.* +refresh=3 +one_line=false +command=dropbox status +countmin=1 + +[amp_python] +# Use the default AMP (no dedicated AMP Python script) +# Monitor all the Python scripts +# Alert if more than 20 Python scripts are running +enable=false +regex=.*python.* +refresh=3 +countmax=20 + +[amp_conntrack] +# Use && separator for multiple commands +# If the regex key is not defined, the AMP will be executed every refresh second +# and the process count will not be displayed (countmin and countmax will be ignore) +enable=false +refresh=30 +one_line=false +command=sysctl net.netfilter.nf_conntrack_count && sysctl net.netfilter.nf_conntrack_max + +[amp_nginx] +# Use the NGinx AMP +# Nginx status page should be enable (https://easyengine.io/tutorials/nginx/status-page/) +enable=false +regex=\/usr\/sbin\/nginx +refresh=60 +one_line=false +status_url=http://localhost/nginx_status + +[amp_systemd] +# Use the Systemd AMP +enable=false +regex=\/lib\/systemd\/systemd +refresh=30 +one_line=true +systemctl_cmd=/bin/systemctl --plain + +[amp_systemv] +# Use the Systemv AMP +enable=false +regex=\/sbin\/init +refresh=30 +one_line=true +service_cmd=/usr/bin/service --status-all diff --git a/crates/glances-cli/Cargo.toml b/crates/glances-cli/Cargo.toml new file mode 100644 index 0000000..86d6b81 --- /dev/null +++ b/crates/glances-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "glances-cli" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "glances" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap = { workspace = true, features = ["derive", "wrap_help"] } +clap_complete.workspace = true +ctrlc = "3.5.2" +glances-core = { version = "0.1.0", path = "../glances-core" } +glances-plugins = { version = "0.1.0", path = "../glances-plugins" } +glances-tui = { version = "0.1.0", path = "../glances-tui" } +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/crates/glances-cli/src/logging.rs b/crates/glances-cli/src/logging.rs new file mode 100644 index 0000000..d79bd8a --- /dev/null +++ b/crates/glances-cli/src/logging.rs @@ -0,0 +1,43 @@ +//! Logging setup (port of `glances.logger`). + +use std::path::PathBuf; +use tracing_subscriber::EnvFilter; + +/// Return the Glances log file path. +pub fn log_filename() -> PathBuf { + let base = std::env::var("XDG_CACHE_HOME") + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".local").join("share"))); + if let Some(base) = base { + if base.is_dir() { + let dir = base.join("glances"); + let _ = std::fs::create_dir_all(&dir); + return dir.join("glances.log"); + } + } + std::env::temp_dir().join(format!("glances-{}.log", std::env::var("USER").unwrap_or_else(|_| "user".into()))) +} + +/// Initialise the logger: everything goes to the log file, warnings and +/// errors also go to stderr. +pub fn init(debug: bool) { + let log_file = log_filename(); + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_file) + .ok(); + let level = if debug { "debug" } else { "info" }; + let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info")); + if let Some(file) = file { + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(file) + .with_ansi(false) + .with_target(true) + .try_init() + .ok(); + } +} diff --git a/crates/glances-cli/src/main.rs b/crates/glances-cli/src/main.rs new file mode 100644 index 0000000..0daf86d --- /dev/null +++ b/crates/glances-cli/src/main.rs @@ -0,0 +1,191 @@ +//! The `glances` binary. + +mod logging; +mod outputs; +mod tui_placeholder; + +use anyhow::Result; +use clap::{CommandFactory, Parser}; +use glances_core::config::Config; +use glances_core::Args; +use glances_plugins::engine::Engine; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +static RUNNING: AtomicBool = AtomicBool::new(true); + +fn main() { + let args = Args::parse(); + + if args.version { + println!("{}", args.version_message(&logging::log_filename().display().to_string())); + return; + } + if let Some(shell) = &args.print_completion { + print_completion(shell); + return; + } + if let Some(feature) = args.unsupported() { + eprintln!("glances: error: {feature} is not supported by this Rust implementation"); + std::process::exit(2); + } + + logging::init(args.debug); + + if let Err(err) = run(args) { + tracing::error!("{err}"); + eprintln!("glances: {err}"); + std::process::exit(1); + } +} + +fn run(mut args: Args) -> Result<()> { + install_signal_handler(); + + let config = Config::new(args.conf_file.as_deref(), args.disable_config_exec); + + // The configuration key can be overwritten from the command line (-t). + if args.time.is_none() { + let refresh = if config.has_section("global") { + config.get_float_value("global", "refresh", 2.0) + } else { + 2.0 + }; + args.time = Some(refresh); + } + + // --memory-leak: quiet mode, 60 seconds, 1 second refresh. + if args.memory_leak { + args.quiet = true; + if args.stop_after.is_none() { + args.stop_after = Some(60); + } + args.time = Some(1.0); + args.disable_history = true; + } + + let mut engine = Engine::new(args.clone(), config); + + if args.modules_list { + display_modules_list(&engine); + return Ok(()); + } + + if args.stdout_issue { + engine.update(); + print!("{}", outputs::render_issue(&engine)); + return Ok(()); + } + + if args.stdout.is_some() || args.stdout_json.is_some() || args.stdout_csv.is_some() { + return run_stdout(&mut engine, &args); + } + + if args.stdout_fetch { + return tui_placeholder::run_fetch(&mut engine, &args); + } + + if args.quiet { + return run_quiet(&mut engine, &args); + } + + tui_placeholder::run(&mut engine, &args) +} + +fn display_modules_list(engine: &Engine) { + let mut plugins = engine.get_plugins_list(false); + plugins.sort(); + println!("Plugins list: {}", plugins.join(", ")); + println!("Exporters list: "); +} + +fn print_completion(shell: &str) { + let mut command = Args::command(); + match shell { + "bash" => clap_complete::generate(clap_complete::Shell::Bash, &mut command, "glances", &mut std::io::stdout()), + "zsh" => clap_complete::generate(clap_complete::Shell::Zsh, &mut command, "glances", &mut std::io::stdout()), + "fish" => clap_complete::generate(clap_complete::Shell::Fish, &mut command, "glances", &mut std::io::stdout()), + other => { + eprintln!("glances: error: unsupported shell for completion: {other} (use bash, zsh or fish)"); + std::process::exit(2); + } + } +} + +fn install_signal_handler() { + let _ = ctrlc::set_handler(|| { + RUNNING.store(false, Ordering::SeqCst); + }); +} + +fn sleep_with_signal(duration: f64) { + let deadline = Instant::now() + Duration::from_secs_f64(duration.max(0.0)); + while RUNNING.load(Ordering::SeqCst) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn run_stdout(engine: &mut Engine, args: &Args) -> Result<()> { + let refresh = args.time.unwrap_or(2.0); + let iterations = args.stop_after; + let mut csv = outputs::CsvRenderer::new(); + let stdout_items = args.stdout.as_deref().map(outputs::build_stdout_list); + let json_plugins = args.stdout_json.as_deref().map(|value| { + value.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::>() + }); + let csv_plugins = args.stdout_csv.as_deref().map(|value| { + value + .split(',') + .map(|p| p.split('.').next().unwrap_or(p).trim().to_string()) + .filter(|p| !p.is_empty()) + .collect::>() + }); + + let mut count: i64 = 0; + while RUNNING.load(Ordering::SeqCst) { + let start = Instant::now(); + engine.update(); + let output = if let Some(items) = &stdout_items { + outputs::render_stdout(engine, items) + } else if let Some(plugins) = &json_plugins { + format!("{}\n", outputs::render_stdout_json(engine, plugins)) + } else if let Some(plugins) = &csv_plugins { + format!("{}\n", csv.render(engine, plugins)) + } else { + String::new() + }; + print!("{output}"); + std::io::stdout().flush().ok(); + + count += 1; + if let Some(max) = iterations { + if count >= max { + break; + } + } + let elapsed = start.elapsed().as_secs_f64(); + sleep_with_signal(refresh - elapsed); + } + Ok(()) +} + +fn run_quiet(engine: &mut Engine, args: &Args) -> Result<()> { + let refresh = args.time.unwrap_or(2.0); + let mut count: i64 = 0; + while RUNNING.load(Ordering::SeqCst) { + let start = Instant::now(); + engine.update(); + count += 1; + if let Some(max) = args.stop_after { + if count >= max { + break; + } + } + let elapsed = start.elapsed().as_secs_f64(); + sleep_with_signal(refresh - elapsed); + } + Ok(()) +} diff --git a/crates/glances-cli/src/outputs.rs b/crates/glances-cli/src/outputs.rs new file mode 100644 index 0000000..0f1fc8a --- /dev/null +++ b/crates/glances-cli/src/outputs.rs @@ -0,0 +1,236 @@ +//! Terminal output modes: `--stdout`, `--stdout-csv`, `--stdout-json`. + +use glances_plugins::engine::Engine; +use serde_json::{Map, Value, json}; + +/// One `[.].` item of the stdout modes. +#[derive(Debug, Clone, PartialEq)] +pub struct StdoutItem { + pub plugin: String, + pub key: Option, + pub attribute: Option, +} + +pub fn build_stdout_list(value: &str) -> Vec { + value + .split(',') + .map(|item| { + let parts: Vec<&str> = item.split('.').collect(); + match parts.len() { + 1 => StdoutItem { plugin: parts[0].to_string(), key: None, attribute: None }, + 2 => StdoutItem { + plugin: parts[0].to_string(), + key: None, + attribute: Some(parts[1].to_string()), + }, + _ => StdoutItem { + plugin: parts[0].to_string(), + key: Some(parts[1].to_string()), + attribute: Some(parts[2].to_string()), + }, + } + }) + .collect() +} + +/// `--stdout`: one stat per line. +pub fn render_stdout(engine: &Engine, items: &[StdoutItem]) -> String { + let mut out = String::new(); + for item in items { + let Some(plugin) = engine.get_plugin(&item.plugin) else { + continue; + }; + if !plugin.is_enabled() { + continue; + } + let stat = plugin.export(); + match &item.attribute { + Some(attribute) => match &stat { + Value::Object(map) => { + if let Some(value) = map.get(attribute) { + out.push_str(&format!("{}.{}: {}\n", item.plugin, attribute, glances_core::py_str(value))); + } else { + tracing::error!("Can not display stat {}.{} (KeyError)", item.plugin, attribute); + } + } + Value::Array(list) => { + for entry in list { + let Some(entry) = entry.as_object() else { + continue; + }; + let entry_key = entry + .get("key") + .and_then(Value::as_str) + .and_then(|key_field| entry.get(key_field)) + .map(glances_core::py_str); + let Some(entry_key) = entry_key else { + continue; + }; + if let Some(wanted) = &item.key { + if wanted != &entry_key { + continue; + } + } + if let Some(value) = entry.get(attribute) { + out.push_str(&format!( + "{}.{}.{}: {}\n", + item.plugin, + entry_key, + attribute, + glances_core::py_str(value) + )); + } + } + } + _ => {} + }, + None => { + out.push_str(&format!("{}: {}\n", item.plugin, glances_core::py_repr(&stat))); + } + } + } + out +} + +/// `--stdout-json`: one JSON object per line. +pub fn render_stdout_json(engine: &Engine, plugins: &[String]) -> String { + let mut result = Map::new(); + for name in plugins { + let Some(plugin) = engine.get_plugin(name) else { + continue; + }; + if plugin.is_enabled() { + result.insert(name.clone(), plugin.export()); + } + } + serde_json::to_string(&Value::Object(result)).unwrap_or_else(|_| "{}".to_string()) +} + +/// `--stdout-csv`: all the stats in one line, with a header. +#[derive(Debug, Default)] +pub struct CsvRenderer { + header: bool, + list_keys: std::collections::HashMap>, + header_field_names: std::collections::HashMap>, +} + +impl CsvRenderer { + pub fn new() -> Self { + Self { header: true, ..Default::default() } + } + + pub fn render(&mut self, engine: &Engine, plugins: &[String]) -> String { + let mut line = String::new(); + for name in plugins { + let Some(plugin) = engine.get_plugin(name) else { + continue; + }; + if !plugin.is_enabled() { + continue; + } + let stat = plugin.export(); + if self.header { + line.push_str(&self.build_header(name, &stat)); + } else { + line.push_str(&self.build_data(name, &stat)); + } + } + self.header = false; + line.pop(); + line + } + + fn build_header(&mut self, plugin: &str, stat: &Value) -> String { + let mut line = String::new(); + match stat { + Value::Object(map) => { + for key in map.keys() { + line.push_str(&format!("{plugin}.{key},")); + } + } + Value::Array(list) => { + let mut keys_order = Vec::new(); + for entry in list { + let Some(entry) = entry.as_object() else { + continue; + }; + let Some(identity_field) = entry.get("key").and_then(Value::as_str) else { + continue; + }; + let Some(identity) = entry.get(identity_field) else { + continue; + }; + let identity = glances_core::py_str(identity); + keys_order.push(identity.clone()); + for key in entry.keys() { + line.push_str(&format!("{plugin}.{identity}.{key},")); + } + } + self.list_keys.insert(plugin.to_string(), keys_order); + if let Some(first) = list.iter().find_map(|entry| entry.as_object()) { + self.header_field_names.insert(plugin.to_string(), first.keys().cloned().collect()); + } + } + _ => { + line.push_str(&format!("{plugin},")); + } + } + line + } + + fn build_data(&self, plugin: &str, stat: &Value) -> String { + let mut line = String::new(); + match stat { + Value::Object(map) => { + for value in map.values() { + line.push_str(&format!("{},", glances_core::py_str(value))); + } + } + Value::Array(list) => { + let mut current = std::collections::HashMap::new(); + for entry in list { + let Some(entry) = entry.as_object() else { + continue; + }; + let Some(identity_field) = entry.get("key").and_then(Value::as_str) else { + continue; + }; + let Some(identity) = entry.get(identity_field) else { + continue; + }; + current.insert(glances_core::py_str(identity), entry.clone()); + } + let empty = Map::new(); + for identity in self.list_keys.get(plugin).map(|k| k.as_slice()).unwrap_or(&[]) { + let entry = current.get(identity).unwrap_or(&empty); + for field in self.header_field_names.get(plugin).map(|f| f.as_slice()).unwrap_or(&[]) { + let value = entry.get(field.as_str()); + let text = match value { + Some(value) => glances_core::py_str(value), + None => "N/A".to_string(), + }; + line.push_str(&format!("{text},")); + } + } + } + _ => { + line.push_str(&format!("{},", glances_core::py_str(stat))); + } + } + line + } +} + +/// `--issue`: dump every plugin stats (used for bug reports). +pub fn render_issue(engine: &Engine) -> String { + let mut out = String::new(); + out.push_str(&format!("Glances version: {}\n", crate::VERSION)); + for name in engine.get_plugins_list(false) { + let Some(plugin) = engine.get_plugin(&name) else { + continue; + }; + let enabled = if plugin.is_enabled() { "enabled" } else { "disabled" }; + out.push_str(&format!("{name} ({enabled}): {}\n", glances_core::py_repr(plugin.stats()))); + } + out +} diff --git a/crates/glances-cli/src/tui_placeholder.rs b/crates/glances-cli/src/tui_placeholder.rs new file mode 100644 index 0000000..8e55dfc --- /dev/null +++ b/crates/glances-cli/src/tui_placeholder.rs @@ -0,0 +1,14 @@ +//! Temporary placeholder for the terminal UI and the fetch mode. +//! The real TUI lives in the `glances-tui` crate (next milestone). + +use anyhow::Result; +use glances_core::Args; +use glances_plugins::engine::Engine; + +pub fn run(_engine: &mut Engine, _args: &Args) -> Result<()> { + anyhow::bail!("terminal UI is not implemented yet (use --stdout, --stdout-csv or --stdout-json)") +} + +pub fn run_fetch(_engine: &mut Engine, _args: &Args) -> Result<()> { + anyhow::bail!("fetch mode is not implemented yet") +} diff --git a/crates/glances-core/Cargo.toml b/crates/glances-core/Cargo.toml new file mode 100644 index 0000000..fd45ae6 --- /dev/null +++ b/crates/glances-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "glances-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow.workspace = true +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +clap = { workspace = true, features = ["derive", "wrap_help"] } +indexmap.workspace = true +libc.workspace = true +regex.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +shlex = "2.0.1" +sysinfo = "0.39.6" +thiserror.workspace = true +tracing.workspace = true +unicode-width.workspace = true diff --git a/crates/glances-core/src/actions.rs b/crates/glances-core/src/actions.rs new file mode 100644 index 0000000..7c4a6f4 --- /dev/null +++ b/crates/glances-core/src/actions.rs @@ -0,0 +1,280 @@ +//! On-alert actions (port of `glances.actions`). + +use crate::secure::{sanitize_value, secure_popen}; +use crate::timer::Timer; +use serde_json::Value; +use std::collections::HashMap; + +/// Render a (small) Mustache template with JSON data. +/// +/// Supported tags: `{{name}}` (HTML escaped), `{{{name}}}` and `{{&name}}` +/// (raw), dotted names, and `{{#name}}...{{/name}}` / `{{^name}}...{{/name}}` +/// sections. Anything else is refused so a malformed template can never be +/// executed half-rendered. +pub fn render_template(template: &str, data: &Value) -> Result { + render_range(template, data, &mut 0) +} + +fn render_range(template: &str, data: &Value, pos: &mut usize) -> Result { + let mut out = String::new(); + while *pos < template.len() { + let rest = &template[*pos..]; + let Some(open) = rest.find("{{") else { + out.push_str(rest); + *pos = template.len(); + break; + }; + out.push_str(&rest[..open]); + *pos += open; + + // Standalone-ish: read the tag. + let tag_start = *pos + 2; + let after = &template[tag_start..]; + let (tag, raw, tag_end) = if let Some(stripped) = after.strip_prefix('{') { + // Triple mustache + let Some(close) = stripped.find("}}}") else { + return Err("unclosed mustache tag".to_string()); + }; + (stripped[..close].trim().to_string(), true, tag_start + 1 + close + 3) + } else { + let Some(close) = after.find("}}") else { + return Err("unclosed mustache tag".to_string()); + }; + (after[..close].trim().to_string(), false, tag_start + close + 2) + }; + + if let Some(name) = tag.strip_prefix('#') { + // Section: find the matching close tag. + let body_start = tag_end; + let (body, body_end) = find_section(&template[body_start..], name.trim())?; + *pos = body_start + body_end; + let value = lookup(data, name.trim()); + match value { + Some(Value::Array(items)) => { + for item in &items { + let mut inner_pos = 0; + out.push_str(&render_range(body, item, &mut inner_pos)?); + } + } + Some(Value::Bool(true)) => { + let mut inner_pos = 0; + out.push_str(&render_range(body, data, &mut inner_pos)?); + } + Some(Value::Object(map)) => { + let merged = merge_context(data, &Value::Object(map.clone())); + let mut inner_pos = 0; + out.push_str(&render_range(body, &merged, &mut inner_pos)?); + } + Some(Value::String(s)) if !s.is_empty() => { + let mut inner_pos = 0; + out.push_str(&render_range(body, data, &mut inner_pos)?); + } + _ => {} + } + } else if let Some(name) = tag.strip_prefix('^') { + let body_start = tag_end; + let (body, body_end) = find_section(&template[body_start..], name.trim())?; + *pos = body_start + body_end; + let value = lookup(data, name.trim()); + let falsy = match value { + None | Some(Value::Null) | Some(Value::Bool(false)) => true, + Some(Value::Array(items)) => items.is_empty(), + Some(Value::String(s)) => s.is_empty(), + _ => false, + }; + if falsy { + let mut inner_pos = 0; + out.push_str(&render_range(body, data, &mut inner_pos)?); + } + } else if let Some(name) = tag.strip_prefix('/') { + return Err(format!("unexpected closing tag {{{{/{}}}}}", name.trim())); + } else { + *pos = tag_end; + let name = tag.strip_prefix('&').unwrap_or(&tag); + let value = lookup(data, name.trim()); + let text = value_to_text(value); + if raw || tag.starts_with('&') { + out.push_str(&text); + } else { + out.push_str(&html_escape(&text)); + } + } + } + Ok(out) +} + +/// Find the body and length of a section, starting after its opening tag. +fn find_section<'a>(template: &'a str, name: &str) -> Result<(&'a str, usize), String> { + let open = format!("{{{{#{name}}}}}"); + let close = format!("{{{{/{name}}}}}"); + let mut depth = 0usize; + let mut cursor = 0usize; + while cursor < template.len() { + let next_open = template[cursor..].find(&open).map(|i| cursor + i); + let next_close = template[cursor..].find(&close).map(|i| cursor + i); + match (next_open, next_close) { + (Some(o), Some(c)) if c < o => { + if depth == 0 { + return Ok((&template[..c], c + close.len())); + } + depth -= 1; + cursor = c + close.len(); + } + (Some(o), _) => { + depth += 1; + cursor = o + open.len(); + } + (None, Some(c)) => { + if depth == 0 { + return Ok((&template[..c], c + close.len())); + } + depth -= 1; + cursor = c + close.len(); + } + (None, None) => return Err(format!("unclosed mustache section {name}")), + } + } + Err(format!("unclosed mustache section {name}")) +} + +fn merge_context(data: &Value, extra: &Value) -> Value { + match (data, extra) { + (Value::Object(base), Value::Object(extra)) => { + let mut merged = base.clone(); + for (k, v) in extra { + merged.insert(k.clone(), v.clone()); + } + Value::Object(merged) + } + _ => extra.clone(), + } +} + +fn lookup(data: &Value, name: &str) -> Option { + if name == "." { + return Some(data.clone()); + } + let mut current = data; + for part in name.split('.') { + current = current.get(part)?; + } + Some(current.clone()) +} + +fn value_to_text(value: Option) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s, + Some(Value::Bool(b)) => if b { "True" } else { "False" }.to_string(), + Some(Value::Number(n)) => n.to_string(), + Some(other) => crate::units::py_str(&other), + } +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// This class manages actions when an alert is reached. +#[derive(Debug)] +pub struct Actions { + disable_config_exec: bool, + status: HashMap, + start_timer: Timer, +} + +impl Actions { + pub fn new(disable_config_exec: bool, refresh_time: f64) -> Self { + Self { + disable_config_exec, + status: HashMap::new(), + // Avoid any trigger when Glances is started (issue #732). + start_timer: Timer::new(refresh_time * 2.0), + } + } + + pub fn set(&mut self, stat_name: &str, criticality: &str) { + self.status.insert(stat_name.to_string(), criticality.to_string()); + } + + pub fn get(&self, stat_name: &str) -> Option<&str> { + self.status.get(stat_name).map(|s| s.as_str()) + } + + /// Run the commands (in background in Python; synchronously here). + pub fn run( + &mut self, + stat_name: &str, + criticality: &str, + commands: &[String], + repeat: bool, + mustache_dict: Option<&Value>, + ) -> bool { + if (self.get(stat_name) == Some(criticality) && !repeat) || !self.start_timer.finished() { + return false; + } + tracing::debug!( + "{} action {:?} for {} ({}) with stats {:?}", + if repeat { "Repeat" } else { "Run" }, + commands, + stat_name, + criticality, + mustache_dict + ); + + let safe = mustache_dict.map(sanitize_value); + let render = |arg: &str| -> Result { + if let Some(safe) = &safe { + render_template(arg, safe) + } else { + Ok(arg.to_string()) + } + }; + + for cmd in commands { + tracing::info!("Action triggered for {stat_name} ({criticality}): {cmd}"); + let ret = secure_popen(cmd, !self.disable_config_exec, Some(&render)); + tracing::debug!("Action result for {stat_name} ({criticality}): {ret}"); + } + self.set(stat_name, criticality); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_render_simple() { + let data = json!({"name": "nginx", "pid": 42}); + assert_eq!(render_template("kill {{pid}} {{name}}", &data).unwrap(), "kill 42 nginx"); + assert_eq!(render_template("raw {{{name}}}", &data).unwrap(), "raw nginx"); + } + + #[test] + fn test_render_escape() { + let data = json!({"name": "a'b"}); + assert_eq!(render_template("{{name}}", &data).unwrap(), "a'b"); + assert_eq!(render_template("{{{name}}}", &data).unwrap(), "a'b"); + } + + #[test] + fn test_render_section() { + let data = json!({"items": [1, 2]}); + assert_eq!(render_template("{{#items}}x{{/items}}", &data).unwrap(), "xx"); + assert_eq!(render_template("{{^missing}}y{{/missing}}", &data).unwrap(), "y"); + } + + #[test] + fn test_actions_no_trigger_before_start_timer() { + let mut actions = Actions::new(false, 100.0); + assert!(!actions.run("cpu_user", "critical", &["/bin/true".to_string()], false, None)); + } +} diff --git a/crates/glances-core/src/args.rs b/crates/glances-core/src/args.rs new file mode 100644 index 0000000..3d50fd4 --- /dev/null +++ b/crates/glances-core/src/args.rs @@ -0,0 +1,503 @@ +//! Command line arguments (drop-in compatible with Glances standalone mode). +//! +//! Flags belonging to intentionally unsupported features (web server, client/ +//! server, exporters...) are still accepted so that the CLI can fail with a +//! clear message instead of an "unknown argument" error. + +use clap::{ArgAction, Parser, ValueEnum}; + +pub const GLANCES_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const GLANCES_API_VERSION: &str = "4"; + +/// Available process sort keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SortProcesses { + #[value(name = "cpu_percent")] + CpuPercent, + #[value(name = "memory_percent")] + MemoryPercent, + #[value(name = "username")] + Username, + #[value(name = "cpu_times")] + CpuTimes, + #[value(name = "io_counters")] + IoCounters, + #[value(name = "name")] + Name, + #[value(name = "cpu_num")] + CpuNum, +} + +impl SortProcesses { + pub fn as_str(self) -> &'static str { + match self { + SortProcesses::CpuPercent => "cpu_percent", + SortProcesses::MemoryPercent => "memory_percent", + SortProcesses::Username => "username", + SortProcesses::CpuTimes => "cpu_times", + SortProcesses::IoCounters => "io_counters", + SortProcesses::Name => "name", + SortProcesses::CpuNum => "cpu_num", + } + } +} + +#[derive(Parser, Debug, Clone)] +#[command( + name = "glances", + disable_version_flag = true, + after_help = EXAMPLES +)] +pub struct Args { + /// enable debug mode + #[arg(short = 'd', long = "debug", action = ArgAction::SetTrue)] + pub debug: bool, + + /// display the version and exit + #[arg(short = 'V', long = "version", action = ArgAction::SetTrue)] + pub version: bool, + + /// path to the configuration file + #[arg(short = 'C', long = "config", value_name = "FILE")] + pub conf_file: Option, + + /// path to additional plugin directory (Rust plugins only; Python plugins are not supported) + #[arg(short = 'P', long = "plugins", value_name = "DIR")] + pub plugin_dir: Option, + + /// display modules (plugins & exports) list and exit + #[arg(long = "modules-list", visible_alias = "module-list", action = ArgAction::SetTrue)] + pub modules_list: bool, + + /// disable plugin (comma-separated list or all) + #[arg( + long = "disable-plugin", + visible_aliases = ["disable-plugins", "disable"], + value_name = "PLUGINS" + )] + pub disable_plugin: Option, + + /// enable plugin (comma-separated list) + #[arg( + long = "enable-plugin", + visible_aliases = ["enable-plugins", "enable"], + value_name = "PLUGINS" + )] + pub enable_plugin: Option, + + /// disable process module + #[arg(long = "disable-process", action = ArgAction::SetTrue)] + pub disable_process: bool, + + /// light mode for Curses UI (disable all but the top menu) + #[arg(long = "light", visible_alias = "enable-light", action = ArgAction::SetTrue)] + pub enable_light: bool, + + /// task's cpu usage will be divided by the total number of CPUs + #[arg(short = '0', long = "disable-irix", action = ArgAction::SetTrue)] + pub disable_irix: bool, + + /// start Glances in per CPU mode + #[arg(short = '1', long = "percpu", visible_alias = "per-cpu", action = ArgAction::SetTrue)] + pub percpu: bool, + + /// disable network, disk I/O, FS and sensors modules + #[arg(short = '2', long = "disable-left-sidebar", action = ArgAction::SetTrue)] + pub disable_left_sidebar: bool, + + /// disable quick look module + #[arg(short = '3', long = "disable-quicklook", action = ArgAction::SetTrue)] + pub disable_quicklook: bool, + + /// disable all but quick look and load + #[arg(short = '4', long = "full-quicklook", action = ArgAction::SetTrue)] + pub full_quicklook: bool, + + /// disable top menu (QL, CPU, MEM, SWAP and LOAD) + #[arg(short = '5', long = "disable-top", action = ArgAction::SetTrue)] + pub disable_top: bool, + + /// start Glances in mean GPU mode + #[arg(short = '6', long = "meangpu", action = ArgAction::SetTrue)] + pub meangpu: bool, + + /// disable stats history + #[arg(long = "disable-history", action = ArgAction::SetTrue)] + pub disable_history: bool, + + /// disable bold mode in the terminal + #[arg(long = "disable-bold", action = ArgAction::SetTrue)] + pub disable_bold: bool, + + /// disable background colors in the terminal + #[arg(long = "disable-bg", action = ArgAction::SetTrue)] + pub disable_bg: bool, + + /// enable IRQ module + #[arg(long = "enable-irq", action = ArgAction::SetTrue)] + pub enable_irq: bool, + + /// enable extended stats on top process + #[arg(long = "enable-process-extended", action = ArgAction::SetTrue)] + pub enable_process_extended: bool, + + /// disable separator in the UI (between top and others modules) + #[arg(long = "disable-separator", action = ArgAction::SetTrue)] + pub disable_separator: bool, + + /// disable cursor (process selection) in the UI + #[arg(long = "disable-cursor", action = ArgAction::SetTrue)] + pub disable_cursor: bool, + + /// Use arrow keys to sort the process list instead of the SHIFT+key combinations + #[arg(long = "arrow-keys-sort", action = ArgAction::SetTrue)] + pub arrow_keys_sort: bool, + + /// Sort processes by: cpu_percent, memory_percent, username, cpu_times, io_counters, name, cpu_num + #[arg(long = "sort-processes", value_name = "KEY")] + pub sort_processes_key: Option, + + /// Accumulate processes by program + #[arg(long = "programs", visible_alias = "program", action = ArgAction::SetTrue)] + pub programs: bool, + + /// stop Glances after n refresh + #[arg(long = "stop-after", value_name = "N")] + pub stop_after: Option, + + /// do not display the curses interface + #[arg(short = 'q', long = "quiet", action = ArgAction::SetTrue)] + pub quiet: bool, + + /// set the process filter pattern (regular expression) + #[arg(short = 'f', long = "process-filter", value_name = "PATTERN")] + pub process_filter: Option, + + /// set a process list to focus on (comma-separated list of Glances filter) + #[arg(long = "process-focus", value_name = "FILTER")] + pub process_focus: Option, + + /// force short name for processes name + #[arg(long = "process-short-name", overrides_with = "process_long_name", action = ArgAction::SetTrue)] + pub process_short_name_flag: bool, + + /// force long name for processes name + #[arg(long = "process-long-name", overrides_with = "process_short_name_flag", action = ArgAction::SetTrue)] + pub process_long_name: bool, + + /// display stats to stdout, one stat per line (comma-separated list of plugins/plugins.attribute) + #[arg(long = "stdout", value_name = "PLUGINS")] + pub stdout: Option, + + /// display stats to stdout, JSON format (comma-separated list of plugins/plugins.attribute) + #[arg(long = "stdout-json", value_name = "PLUGINS")] + pub stdout_json: Option, + + /// display stats to stdout, CSV format (comma-separated list of plugins/plugins.attribute) + #[arg(long = "stdout-csv", value_name = "PLUGINS")] + pub stdout_csv: Option, + + /// test all plugins and exit (please copy/paste the output if you open an issue) + #[arg(long = "issue", action = ArgAction::SetTrue)] + pub stdout_issue: bool, + + /// test memory leak (quiet mode during 60 seconds) + #[arg(long = "memory-leak", action = ArgAction::SetTrue)] + pub memory_leak: bool, + + /// hide kernel threads in the process list (not available on Windows) + #[arg(long = "hide-kernel-threads", action = ArgAction::SetTrue)] + pub no_kernel_threads: bool, + + /// display network rate in bytes per second + #[arg(short = 'b', long = "byte", action = ArgAction::SetTrue)] + pub byte: bool, + + /// show RAM Fs in the DiskIO plugin + #[arg(long = "diskio-show-ramfs", action = ArgAction::SetTrue)] + pub diskio_show_ramfs: bool, + + /// show IO per second in the DiskIO plugin + #[arg(long = "diskio-iops", action = ArgAction::SetTrue)] + pub diskio_iops: bool, + + /// show IO latency in the DiskIO plugin + #[arg(long = "diskio-latency", action = ArgAction::SetTrue)] + pub diskio_latency: bool, + + /// display temperature in Fahrenheit (default is Celsius) + #[arg(long = "fahrenheit", action = ArgAction::SetTrue)] + pub fahrenheit: bool, + + /// display FS free space instead of used + #[arg(long = "fs-free-space", action = ArgAction::SetTrue)] + pub fs_free_space: bool, + + /// display sparklines instead of bar in the curses interface + #[arg(long = "sparkline", action = ArgAction::SetTrue)] + pub sparkline: bool, + + /// disable unicode characters in the curses interface + #[arg(long = "disable-unicode", action = ArgAction::SetTrue)] + pub disable_unicode: bool, + + /// hide public information (like public IP) + #[arg(long = "hide-public-info", action = ArgAction::SetTrue)] + pub hide_public_info: bool, + + /// disable backtick command execution in configuration values and shell operator interpretation (&&, |, >) in AMP commands + #[arg(long = "disable-config-exec", action = ArgAction::SetTrue)] + pub disable_config_exec: bool, + + /// disable online Glances version check + #[arg(long = "disable-check-update", action = ArgAction::SetTrue)] + pub disable_check_update: bool, + + /// strftime format string for displaying current date in standalone mode + #[arg(long = "strftime", value_name = "FORMAT", default_value = "")] + pub strftime_format: String, + + /// set minimum refresh rate in seconds [default: 2 sec] + #[arg(short = 't', long = "time", value_name = "SECOND")] + pub time: Option, + + /// display a (neo)fetch like summary and exit + #[arg(long = "fetch", visible_alias = "stdout-fetch", action = ArgAction::SetTrue)] + pub stdout_fetch: bool, + + /// overwrite default fetch template file + #[arg( + long = "fetch-template", + visible_alias = "stdout-fetch-template", + value_name = "FILE", + default_value = "" + )] + pub fetch_template: String, + + /// print shell completion and exit (bash, zsh or fish) + #[arg(long = "print-completion", value_name = "SHELL")] + pub print_completion: Option, + + // ------------------------------------------------------------------ + // Unsupported features: accepted for a clear error message. + // ------------------------------------------------------------------ + #[arg(short = 'w', long = "webserver", action = ArgAction::SetTrue, hide = true)] + pub webserver: bool, + #[arg(long = "disable-webui", action = ArgAction::SetTrue, hide = true)] + pub disable_webui: bool, + #[arg(long = "enable-mcp", action = ArgAction::SetTrue, hide = true)] + pub enable_mcp: bool, + #[arg(long = "mcp-path", value_name = "PATH", hide = true)] + pub mcp_path: Option, + #[arg(long = "open-web-browser", action = ArgAction::SetTrue, hide = true)] + pub open_web_browser: bool, + #[arg(short = 'c', long = "client", value_name = "SERVER", hide = true)] + pub client: Option, + #[arg(short = 's', long = "server", action = ArgAction::SetTrue, hide = true)] + pub server: bool, + #[arg(long = "browser", action = ArgAction::SetTrue, hide = true)] + pub browser: bool, + #[arg(long = "disable-autodiscover", action = ArgAction::SetTrue, hide = true)] + pub disable_autodiscover: bool, + #[arg(short = 'p', long = "port", value_name = "PORT", hide = true)] + pub port: Option, + #[arg(short = 'B', long = "bind", value_name = "ADDRESS", hide = true)] + pub bind_address: Option, + #[arg(short = 'u', value_name = "USERNAME", hide = true)] + pub username_used: Option, + #[arg(long = "username", action = ArgAction::SetTrue, hide = true)] + pub username_prompt: bool, + #[arg(long = "password", action = ArgAction::SetTrue, hide = true)] + pub password_prompt: bool, + #[arg(long = "snmp-community", value_name = "COMMUNITY", hide = true)] + pub snmp_community: Option, + #[arg(long = "snmp-port", value_name = "PORT", hide = true)] + pub snmp_port: Option, + #[arg(long = "snmp-version", value_name = "VERSION", hide = true)] + pub snmp_version: Option, + #[arg(long = "snmp-user", value_name = "USER", hide = true)] + pub snmp_user: Option, + #[arg(long = "snmp-auth", value_name = "AUTH", hide = true)] + pub snmp_auth: Option, + #[arg(long = "snmp-force", action = ArgAction::SetTrue, hide = true)] + pub snmp_force: bool, + #[arg(long = "cached-time", value_name = "SEC", hide = true)] + pub cached_time: Option, + #[arg(long = "export", value_name = "EXPORTERS", hide = true)] + pub export: Option, + #[arg(long = "export-csv-file", value_name = "FILE", hide = true)] + pub export_csv_file: Option, + #[arg(long = "export-csv-overwrite", action = ArgAction::SetTrue, hide = true)] + pub export_csv_overwrite: bool, + #[arg(long = "export-json-file", value_name = "FILE", hide = true)] + pub export_json_file: Option, + #[arg(long = "export-graph-path", value_name = "DIR", hide = true)] + pub export_graph_path: Option, + #[arg(long = "export-process-filter", value_name = "FILTER", hide = true)] + pub export_process_filter: Option, + #[arg(long = "api-doc", action = ArgAction::SetTrue, hide = true)] + pub stdout_api_doc: bool, + #[arg(long = "api-restful-doc", action = ArgAction::SetTrue, hide = true)] + pub stdout_api_restful_doc: bool, + #[arg(long = "trace-malloc", action = ArgAction::SetTrue, hide = true)] + pub trace_malloc: bool, +} + +const EXAMPLES: &str = "\ +Examples of use: + Monitor local machine (standalone mode): + $ glances + + Display all Glances modules (plugins) and exit: + $ glances --module-list + + Display stats to stdout (one stat per line, possible to go inside stats using plugin.attribute): + $ glances --stdout now,cpu.user,mem.used,load + + Display JSON stats to stdout (one stats per line): + $ glances --stdout-json now,cpu,mem,load + + Display CSV stats to stdout (all stats in one line): + $ glances --stdout-csv now,cpu.user,mem.used,load + + Enable some plugins disabled by default (comma-separated list): + $ glances --enable-plugin sensors + + Disable some plugins (comma-separated list): + $ glances --disable-plugin network,ports + + Disable all plugins except some (comma-separated list): + $ glances --disable-plugin all --enable-plugin cpu,mem,load"; + +impl Args { + /// Return the reason why this command line can not be handled, if any. + pub fn unsupported(&self) -> Option<&'static str> { + let checks: &[(bool, &'static str)] = &[ + (self.webserver, "web server mode (-w/--webserver)"), + (self.enable_mcp, "the MCP server (--enable-mcp)"), + (self.client.is_some(), "client mode (-c/--client)"), + (self.server, "server mode (-s/--server)"), + (self.browser, "the server browser (--browser)"), + (self.export.is_some(), "export modules (--export)"), + (self.stdout_api_doc, "the Python API documentation (--api-doc)"), + (self.stdout_api_restful_doc, "the RESTful API documentation (--api-restful-doc)"), + (self.trace_malloc, "Python memory tracing (--trace-malloc)"), + ]; + checks.iter().find(|(used, _)| *used).map(|(_, name)| *name) + } + + pub fn process_short_name(&self) -> bool { + !self.process_long_name + } + + pub fn enable_separator(&self) -> bool { + !self.disable_separator + } + + /// Python-compatible version message. + pub fn version_message(&self, log_file: &str) -> String { + format!( + "Glances version:\t{}\nGlances API version:\t{}\nGlances-rs version:\t{}\nLog file:\t\t{}", + "4.5.7", + GLANCES_API_VERSION, + GLANCES_VERSION, + log_file + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(argv: &[&str]) -> Args { + Args::try_parse_from(argv).expect("args should parse") + } + + #[test] + fn test_kept_flags_parse() { + let args = parse(&[ + "glances", + "-d", + "-C", + "/tmp/glances.conf", + "--disable-plugin", + "all", + "--enable-plugin", + "cpu,mem", + "--light", + "-0", + "-1", + "-2", + "-3", + "-4", + "-5", + "-6", + "--disable-history", + "--disable-bold", + "--disable-bg", + "--enable-irq", + "--enable-process-extended", + "--disable-separator", + "--disable-cursor", + "--arrow-keys-sort", + "--sort-processes", + "memory_percent", + "--programs", + "--stop-after", + "3", + "-q", + "-f", + "python", + "--process-focus", + "user:root", + "--process-long-name", + "--stdout", + "cpu,mem", + "--stdout-json", + "cpu", + "--stdout-csv", + "cpu", + "--issue", + "--hide-kernel-threads", + "-b", + "--diskio-show-ramfs", + "--diskio-iops", + "--diskio-latency", + "--fahrenheit", + "--fs-free-space", + "--sparkline", + "--disable-unicode", + "--hide-public-info", + "--disable-config-exec", + "--disable-check-update", + "--strftime", + "%Y", + "-t", + "5", + "--fetch", + "--fetch-template", + "/tmp/tpl", + "--print-completion", + "bash", + ]); + assert!(args.export.is_none()); + assert!(!args.process_short_name()); + assert_eq!(args.sort_processes_key, Some(SortProcesses::MemoryPercent)); + assert_eq!(args.time, Some(5.0)); + } + + #[test] + fn test_unsupported_flags_detected() { + assert!(parse(&["glances", "-w"]).unsupported().is_some()); + assert!(parse(&["glances", "-s"]).unsupported().is_some()); + assert!(parse(&["glances", "-c", "host"]).unsupported().is_some()); + assert!(parse(&["glances", "--export", "csv"]).unsupported().is_some()); + assert!(parse(&["glances"]).unsupported().is_none()); + } + + #[test] + fn test_version_flag_parses() { + assert!(parse(&["glances", "-V"]).version); + } +} diff --git a/crates/glances-core/src/config.rs b/crates/glances-core/src/config.rs new file mode 100644 index 0000000..22c1ae2 --- /dev/null +++ b/crates/glances-core/src/config.rs @@ -0,0 +1,392 @@ +//! Configuration file handling (drop-in compatible with `glances.conf`). +//! +//! The file format is the Python `ConfigParser` INI dialect: sections in +//! brackets, `key = value` or `key: value` options, `#`/`;` comments and +//! indented continuation lines. Option names are lower-cased; section names +//! are kept as is. + +use indexmap::IndexMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const CONFIG_FILENAME: &str = "glances.conf"; + +#[derive(Debug, Clone, Default)] +pub struct Config { + sections: IndexMap>, + loaded_file: Option, + disable_config_exec: bool, +} + +impl Config { + /// Build the configuration by searching the standard paths (unless a + /// custom `-C` path is given) and reading the first existing file. + pub fn new(config_dir: Option<&str>, disable_config_exec: bool) -> Self { + let mut config = Config { + sections: IndexMap::new(), + loaded_file: None, + disable_config_exec, + }; + config.read(config_dir); + config + } + + /// Build an empty configuration (used by tests and by callers that + /// already read a file). + pub fn empty(disable_config_exec: bool) -> Self { + let mut config = Config::default(); + config.disable_config_exec = disable_config_exec; + config.set_defaults(); + config + } + + /// Parse a configuration from a string (used by tests). + pub fn from_str(content: &str, disable_config_exec: bool) -> Self { + let mut config = Config { + sections: parse_ini(content), + loaded_file: None, + disable_config_exec, + }; + config.set_defaults(); + config + } + + fn config_file_paths(config_dir: Option<&str>) -> Vec { + let mut paths = Vec::new(); + + // Custom path given with -C. + if let Some(dir) = config_dir { + paths.push(PathBuf::from(dir)); + } + + // User config dir: ~/.config/glances + let user_base = std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config"))); + if let Some(base) = user_base { + paths.push(base.join("glances").join(CONFIG_FILENAME)); + } + + // System-wide config dir: /etc/glances + paths.push(PathBuf::from("/etc/glances").join(CONFIG_FILENAME)); + + // Default location: /share/doc/glances (and the checkout + // layout next to the executable, useful while developing). + if let Ok(exe) = std::env::current_exe() { + if let Some(bin_dir) = exe.parent() { + for prefix in [bin_dir.join(".."), bin_dir.join("../..")] { + paths.push(prefix.join("share/doc/glances").join(CONFIG_FILENAME)); + paths.push(prefix.join("conf").join(CONFIG_FILENAME)); + } + } + } + + paths + } + + fn read(&mut self, config_dir: Option<&str>) { + for config_file in Self::config_file_paths(config_dir) { + if config_file.is_file() { + match std::fs::read_to_string(&config_file) { + Ok(content) => { + self.sections = parse_ini(&content); + self.loaded_file = Some(config_file.clone()); + tracing::info!("Read configuration file '{}'", config_file.display()); + } + Err(err) => { + tracing::error!("Can not read configuration file '{}': {err}", config_file.display()); + std::process::exit(1); + } + } + break; + } + } + self.set_defaults(); + } + + /// Fill in the default values exactly like the Python implementation. + fn set_defaults(&mut self) { + // Globals + self.set_default("global", "strftime_format", ""); + self.set_default("global", "check_update", "true"); + + // Quicklook + self.set_default_cwc("quicklook", Some("cpu"), ["50", "70", "90"]); + self.set_default_cwc("quicklook", Some("mem"), ["50", "70", "90"]); + self.set_default_cwc("quicklook", Some("swap"), ["50", "70", "90"]); + + // CPU + self.set_default_cwc("cpu", Some("user"), ["50", "70", "90"]); + self.set_default_cwc("cpu", Some("system"), ["50", "70", "90"]); + self.set_default_cwc("cpu", Some("steal"), ["50", "70", "90"]); + self.set_default_cwc("cpu", Some("iowait"), ["50", "70", "90"]); + // Context switches bottleneck identification #1212 + let ctx_switches_bottleneck = + 500000.0 * 0.10 * std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) as f64; + let careful = format!("{}", ctx_switches_bottleneck - (ctx_switches_bottleneck * 0.20)); + let warning = format!("{}", ctx_switches_bottleneck - (ctx_switches_bottleneck * 0.10)); + let critical = format!("{ctx_switches_bottleneck}"); + self.set_default_cwc("cpu", Some("ctx_switches"), [&careful, &warning, &critical]); + + // Per-CPU + self.set_default_cwc("percpu", Some("user"), ["50", "70", "90"]); + self.set_default_cwc("percpu", Some("system"), ["50", "70", "90"]); + + // Load + self.set_default_cwc("load", None, ["0.7", "1.0", "5.0"]); + + // Mem + self.set_default_cwc("mem", None, ["50", "70", "90"]); + + // Swap + self.set_default_cwc("memswap", None, ["50", "70", "90"]); + + // Network + self.set_default_cwc("network", Some("rx"), ["50", "70", "90"]); + self.set_default_cwc("network", Some("tx"), ["50", "70", "90"]); + + // FS + self.set_default_cwc("fs", None, ["50", "70", "90"]); + + // Sensors + self.set_default_cwc("sensors", Some("temperature_hdd"), ["45", "52", "60"]); + self.set_default_cwc("sensors", Some("battery"), ["70", "80", "90"]); + + // Process list + self.set_default_cwc("processlist", Some("cpu"), ["50", "70", "90"]); + self.set_default_cwc("processlist", Some("mem"), ["50", "70", "90"]); + } + + fn set_default_cwc(&mut self, section: &str, option_header: Option<&str>, cwc: [&str; N]) { + let header = match option_header { + Some(header) => format!("{header}_"), + None => String::new(), + }; + self.set_default(section, &format!("{header}careful"), cwc[0]); + self.set_default(section, &format!("{header}warning"), cwc[1]); + self.set_default(section, &format!("{header}critical"), cwc[2]); + } + + fn set_default(&mut self, section: &str, option: &str, default: &str) { + let entry = self.sections.entry(section.to_string()).or_default(); + entry.entry(option.to_string()).or_insert_with(|| default.to_string()); + } + + pub fn loaded_config_file(&self) -> Option<&Path> { + self.loaded_file.as_deref() + } + + /// List all section names. + pub fn sections(&self) -> Vec<&str> { + self.sections.keys().map(|s| s.as_str()).collect() + } + + pub fn has_section(&self, section: &str) -> bool { + self.sections.contains_key(section) + } + + pub fn has_option(&self, section: &str, option: &str) -> bool { + self.sections + .get(section) + .map(|s| s.contains_key(&option.to_lowercase())) + .unwrap_or(false) + } + + /// List `(key, value)` pairs of a section. + pub fn items(&self, section: &str) -> Vec<(String, String)> { + self.sections + .get(section) + .map(|s| s.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default() + } + + /// Get a raw value. Dynamic values between backticks are executed unless + /// `--disable-config-exec` is set. + pub fn get_value(&self, section: &str, option: &str, default: Option<&str>) -> Option { + let mut ret = self + .sections + .get(section) + .and_then(|s| s.get(&option.to_lowercase())) + .cloned() + .or_else(|| default.map(|d| d.to_string())); + + if let Some(value) = ret.as_mut() { + for command in find_backticks(value) { + if self.disable_config_exec { + tracing::warn!("Config exec disabled: skipping command `{command}` in [{section}] {option}"); + } else { + tracing::warn!("Executing config command `{command}` for [{section}] {option}"); + let output = system_exec(&command); + *value = value.replace(&format!("`{command}`"), &output); + } + } + } + ret + } + + pub fn get_list_value( + &self, + section: &str, + option: &str, + default: Option>, + separator: char, + ) -> Option> { + self.get_value(section, option, None) + .map(|v| v.split(separator).map(|s| s.to_string()).collect()) + .or(default) + } + + pub fn get_int_value(&self, section: &str, option: &str, default: i64) -> i64 { + self.get_value(section, option, None) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(default) + } + + pub fn get_float_value(&self, section: &str, option: &str, default: f64) -> f64 { + self.get_value(section, option, None) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(default) + } + + pub fn get_bool_value(&self, section: &str, option: &str, default: bool) -> bool { + match self.get_value(section, option, None) { + Some(v) => parse_bool(&v).unwrap_or(default), + None => default, + } + } +} + +/// Parse the true/false flavours accepted by Python's `ConfigParser`. +pub fn parse_bool(value: &str) -> Option { + match value.trim().to_lowercase().as_str() { + "1" | "yes" | "true" | "on" => Some(true), + "0" | "no" | "false" | "off" => Some(false), + _ => None, + } +} + +fn parse_ini(content: &str) -> IndexMap> { + let mut sections: IndexMap> = IndexMap::new(); + let mut current_section: Option = None; + let mut last_key: Option = None; + + for raw_line in content.lines() { + let line = raw_line.trim_end(); + let trimmed = line.trim_start(); + + // Blank line: keep the last key for continuation values. + if trimmed.is_empty() { + continue; + } + // Comment. + if trimmed.starts_with('#') || trimmed.starts_with(';') { + continue; + } + // Section header. + if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("[[") { + let name = trimmed[1..trimmed.len() - 1].trim().to_string(); + sections.entry(name.clone()).or_default(); + current_section = Some(name); + last_key = None; + continue; + } + // Continuation line (indented). + if line.starts_with(' ') || line.starts_with('\t') { + if let (Some(section), Some(key)) = (current_section.as_ref(), last_key.as_ref()) { + if let Some(value) = sections.get_mut(section).and_then(|s| s.get_mut(key)) { + value.push('\n'); + value.push_str(trimmed); + } + } + continue; + } + // Key/value. + if let Some(pos) = trimmed.find(['=', ':']) { + let key = trimmed[..pos].trim().to_lowercase(); + let value = trimmed[pos + 1..].trim(); + if let Some(section) = current_section.as_ref() { + sections + .entry(section.clone()) + .or_default() + .insert(key.clone(), value.to_string()); + last_key = Some(key); + } + } + } + sections +} + +fn find_backticks(value: &str) -> Vec { + let mut result = Vec::new(); + let mut chars = value.char_indices().peekable(); + while let Some((start, c)) = chars.next() { + if c == '`' { + for (end, c2) in chars.by_ref() { + if c2 == '`' { + result.push(value[start + 1..end].to_string()); + break; + } + } + } + } + result +} + +/// Execute a system command without a shell and return its trimmed stdout. +fn system_exec(command: &str) -> String { + let parts: Vec<&str> = command.split(' ').collect(); + let Some((program, args)) = parts.split_first() else { + return String::new(); + }; + match Command::new(program).args(args).output() { + Ok(output) => String::from_utf8_lossy(&output.stdout).trim_end().to_string(), + Err(e) => format!("ERROR: {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ini() { + let config = Config::from_str( + "# comment\n[global]\nrefresh = 5\nhistory_size: 100\n\n[cpu]\nuser_careful=10\n", + false, + ); + assert!(config.has_section("global")); + assert_eq!(config.get_int_value("global", "refresh", 2), 5); + assert_eq!(config.get_int_value("global", "history_size", 0), 100); + assert_eq!(config.get_float_value("cpu", "user_careful", 0.0), 10.0); + } + + #[test] + fn test_defaults() { + let config = Config::empty(false); + assert_eq!(config.get_float_value("cpu", "user_careful", 0.0), 50.0); + assert_eq!(config.get_float_value("cpu", "user_warning", 0.0), 70.0); + assert_eq!(config.get_float_value("cpu", "user_critical", 0.0), 90.0); + assert_eq!(config.get_float_value("load", "careful", 0.0), 0.7); + assert_eq!(config.get_float_value("load", "critical", 0.0), 5.0); + assert!(config.has_section("quicklook")); + assert!(config.has_section("processlist")); + } + + #[test] + fn test_bool_values() { + let config = Config::from_str("[global]\ncheck_update=off\n", false); + assert!(!config.get_bool_value("global", "check_update", true)); + let config = Config::from_str("[x]\ndisable=True\n", false); + assert!(config.get_bool_value("x", "disable", false)); + } + + #[test] + fn test_backtick_exec() { + let config = Config::from_str("[x]\nprefix=`echo hello`\n", false); + assert_eq!(config.get_value("x", "prefix", None).unwrap(), "hello"); + let config = Config::from_str("[x]\nprefix=`echo hello`\n", true); + assert_eq!(config.get_value("x", "prefix", None).unwrap(), "`echo hello`"); + } +} diff --git a/crates/glances-core/src/events.rs b/crates/glances-core/src/events.rs new file mode 100644 index 0000000..aedf8f4 --- /dev/null +++ b/crates/glances-core/src/events.rs @@ -0,0 +1,328 @@ +//! Events list (port of `glances.events_list` and `glances.event`). + +use crate::processes::{processes, sort_stats}; +use crate::thresholds::thresholds; +use indexmap::IndexMap; +use serde_json::Value; +use std::sync::Mutex; +use std::sync::OnceLock; + +/// Static decision tree for the global alert message. +const TREE: [(&str, &[&str], u32); 9] = [ + ("EVENTS history", &[], 0), + ("High CPU user mode", &["cpu_user"], 2), + ("High CPU kernel usage", &["cpu_system"], 2), + ("High CPU I/O waiting", &["cpu_iowait"], 2), + ("Large CPU stolen time. System running the hypervisor is too busy.", &["cpu_steal"], 2), + ("High CPU niced value", &["cpu_niced"], 2), + ("System overloaded in the last 5 minutes", &["load"], 2), + ("High swap (paging) usage", &["memswap"], 2), + ("High memory consumption", &["mem"], 2), +]; + +/// Parse the decision tree and return the message for the current thresholds. +pub fn build_global_message() -> String { + let guard = thresholds().lock().unwrap(); + let current = guard.all(); + let mut best_weight = 0u32; + let mut best_min = 0u32; + let mut best_msg = TREE[0].0; + for (msg, names, min) in TREE { + let weight: u32 = names.iter().filter_map(|n| current.get(*n)).map(|t| t.value() as u32).sum(); + if weight > best_weight { + best_weight = weight; + best_min = min; + best_msg = msg; + } + } + if best_weight >= best_min { best_msg.to_string() } else { TREE[0].0.to_string() } +} + +#[derive(Debug, Clone)] +pub struct Event { + pub begin: f64, + pub end: f64, + pub state: String, + pub r#type: String, + pub min: f64, + pub max: f64, + pub sum: f64, + pub count: i64, + pub avg: f64, + pub top: Vec, + pub desc: String, + pub sort: String, + pub global_msg: String, + top_dict: IndexMap, +} + +impl Event { + pub fn is_ongoing(&self) -> bool { + self.end == -1.0 + } + + pub fn is_finished(&self) -> bool { + self.end != -1.0 + } + + pub fn to_json(&self) -> Value { + serde_json::json!({ + "begin": self.begin, + "end": self.end, + "state": self.state, + "type": self.r#type, + "min": self.min, + "max": self.max, + "sum": self.sum, + "count": self.count, + "avg": self.avg, + "top": self.top, + "desc": self.desc, + "sort": self.sort, + "global": self.global_msg, + }) + } + + fn update( + &mut self, + state: &str, + value: f64, + sort_key: &str, + proc_list: &[Value], + proc_desc: &str, + global_msg: &str, + ) { + self.end = -1.0; + self.min = self.min.min(value); + self.max = self.max.max(value); + self.sum += value; + self.count += 1; + self.avg = self.sum / self.count as f64; + + if state == "CRITICAL" { + // Avoid changing from CRITICAL to WARNING. + self.state = state.to_string(); + self.sort = sort_key.to_string(); + let mut sorted: Vec = proc_list.to_vec(); + sort_stats(&mut sorted, sort_key, "memory_percent", true); + for process in sorted.iter().take(6) { + let name = process.get("name").and_then(Value::as_str).unwrap_or("?").to_string(); + *self.top_dict.entry(name).or_insert(0) += 1; + } + let mut counts: Vec<(String, i64)> = self.top_dict.iter().map(|(k, v)| (k.clone(), *v)).collect(); + counts.sort_by(|a, b| b.1.cmp(&a.1)); + self.top = counts.into_iter().take(3).map(|(name, _)| name).collect(); + } else { + self.top_dict.clear(); + } + self.desc = proc_desc.to_string(); + self.global_msg = global_msg.to_string(); + } +} + +#[derive(Debug)] +pub struct EventsList { + pub events: Vec, + pub max_events: usize, + pub min_duration: f64, + pub min_interval: f64, +} + +impl Default for EventsList { + fn default() -> Self { + Self::new(10, 6.0, 6.0) + } +} + +impl EventsList { + pub fn new(max_events: usize, min_duration: f64, min_interval: f64) -> Self { + Self { events: Vec::new(), max_events, min_duration, min_interval } + } + + pub fn get(&self) -> Vec { + self.events.iter().map(Event::to_json).collect() + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + fn event_exist(&self, event_time: f64, event_type: &str) -> Option { + self.events.iter().position(|e| { + (e.is_ongoing() || (event_time - e.end) < self.min_interval) && e.r#type == event_type + }) + } + + pub fn get_event_sort_key(event_type: &str) -> &'static str { + if event_type.starts_with("MEM") { + "memory_percent" + } else if event_type.starts_with("CPU_IOWAIT") { + "io_counters" + } else { + "cpu_percent" + } + } + + pub fn set_process_sort(event_type: &str) { + let mut procs = processes().lock().unwrap(); + if procs.auto_sort { + let key = Self::get_event_sort_key(event_type); + procs.set_sort_key(key, true); + procs.auto_sort = true; + } + } + + pub fn reset_process_sort() { + let mut procs = processes().lock().unwrap(); + if procs.auto_sort { + procs.set_sort_key("auto", true); + } + } + + /// Add a new item to the events list. + pub fn add(&mut self, event_state: &str, event_type: &str, event_value: f64, proc_desc: &str) -> usize { + let event_time = chrono::Utc::now().timestamp() as f64; + let global_message = build_global_message(); + let proc_list: Vec = processes().lock().unwrap().get_list(false).to_vec(); + + match self.event_exist(event_time, event_type) { + None => { + self.create_event(event_time, event_state, event_type, event_value, proc_desc, &global_message); + } + Some(index) => { + self.update_event( + event_time, + index, + event_state, + event_type, + event_value, + &proc_list, + proc_desc, + &global_message, + ); + } + } + self.len() + } + + fn create_event( + &mut self, + event_time: f64, + event_state: &str, + event_type: &str, + event_value: f64, + proc_desc: &str, + global_message: &str, + ) { + if event_state != "WARNING" && event_state != "CRITICAL" { + return; + } + Self::set_process_sort(event_type); + let sort = processes().lock().unwrap().sort_key.clone(); + let event = Event { + begin: event_time, + end: -1.0, + state: event_state.to_string(), + r#type: event_type.to_string(), + min: event_value, + max: event_value, + sum: event_value, + count: 1, + avg: event_value, + top: Vec::new(), + desc: proc_desc.to_string(), + sort, + global_msg: global_message.to_string(), + top_dict: IndexMap::new(), + }; + self.events.insert(0, event); + if self.len() > self.max_events { + self.events.pop(); + } + } + + #[allow(clippy::too_many_arguments)] + fn update_event( + &mut self, + event_time: f64, + event_index: usize, + event_state: &str, + event_type: &str, + event_value: f64, + proc_list: &[Value], + proc_desc: &str, + global_message: &str, + ) { + let is_ongoing = self.events[event_index].is_ongoing(); + if (event_state == "OK" || event_state == "CAREFUL") && is_ongoing { + self.close_event(event_time, event_index); + } else if event_state == "OK" || event_state == "CAREFUL" { + // Event is already closed, do nothing. + } else { + Self::set_process_sort(event_type); + let sort_key = Self::get_event_sort_key(event_type).to_string(); + let event = &mut self.events[event_index]; + event.update(event_state, event_value, &sort_key, proc_list, proc_desc, global_message); + } + } + + fn close_event(&mut self, event_time: f64, event_index: usize) { + Self::reset_process_sort(); + if event_time - self.events[event_index].begin >= self.min_duration { + self.events[event_index].end = event_time; + } else { + self.events.remove(event_index); + } + } + + /// Clean the events list by deleting finished items. + pub fn clean(&mut self, critical: bool) -> usize { + let mut clean_events = Vec::new(); + while let Some(event) = self.events.pop() { + if event.end < 0.0 || (!critical && event.state.starts_with("CRITICAL")) { + clean_events.insert(0, event); + } + } + self.events = clean_events; + self.len() + } +} + +/// Global events list shared by all plugins. +pub fn events() -> &'static Mutex { + static EVENTS: OnceLock> = OnceLock::new(); + EVENTS.get_or_init(|| Mutex::new(EventsList::default())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_events_add() { + let mut events = EventsList::new(10, 0.0, 6.0); + events.add("WARNING", "CPU", 42.0, ""); + assert_eq!(events.len(), 1); + let json = events.get(); + assert_eq!(json[0]["type"], "CPU"); + assert_eq!(json[0]["state"], "WARNING"); + } + + #[test] + fn test_clean() { + let mut events = EventsList::new(10, 0.0, 6.0); + events.add("WARNING", "CPU", 42.0, ""); + events.add("CRITICAL", "MEM", 80.0, ""); + // Close both events. + events.events[0].end = 100.0; + events.events[1].end = 100.0; + // clean(false) removes the finished WARNING but keeps CRITICAL. + assert_eq!(events.clean(false), 1); + assert_eq!(events.get()[0]["state"], "CRITICAL"); + assert_eq!(events.clean(true), 0); + } +} diff --git a/crates/glances-core/src/filter.rs b/crates/glances-core/src/filter.rs new file mode 100644 index 0000000..64ba171 --- /dev/null +++ b/crates/glances-core/src/filter.rs @@ -0,0 +1,154 @@ +//! Process filtering (port of `glances.filter`). + +use regex::Regex; +use serde_json::Value; + +#[derive(Debug, Clone, Default)] +pub struct Filter { + filter_input: Option, + filter: Option, + filter_re: Option, + filter_key: Option, +} + +impl Filter { + pub fn filter_input(&self) -> Option<&str> { + self.filter_input.as_deref() + } + + pub fn filter(&self) -> Option<&str> { + self.filter.as_deref() + } + + pub fn filter_key(&self) -> Option<&str> { + self.filter_key.as_deref() + } + + /// Set the filter (as a string) and compile the regular expression. + pub fn set_filter(&mut self, value: Option<&str>) { + self.filter_input = value.map(|v| v.to_string()); + match value { + None => { + self.filter = None; + self.filter_key = None; + } + Some(value) => { + // Split once: everything after the first colon is the regex. + match value.split_once(':') { + Some((key, rest)) => { + self.filter = Some(rest.to_string()); + self.filter_key = Some(key.to_string()); + } + None => { + self.filter = Some(value.to_string()); + self.filter_key = None; + } + } + } + } + self.filter_re = None; + if let Some(filter) = &self.filter { + tracing::debug!( + "Set filter to {} on {}", + filter, + self.filter_key.as_deref().unwrap_or("name or cmdline") + ); + match Regex::new(filter) { + Ok(re) => self.filter_re = Some(re), + Err(e) => { + tracing::error!("Cannot compile filter regex: {filter} ({e})"); + self.filter = None; + self.filter_re = None; + self.filter_key = None; + } + } + } + } + + /// Return True if the process item matches the current filter. + pub fn is_filtered(&self, process: &Value) -> bool { + let Some(filter_re) = self.filter_re.as_ref() else { + return false; + }; + if self.filter.is_none() { + return false; + } + if self.filter_key.is_none() { + return self.filter_matches(process, "name", filter_re) || self.filter_matches(process, "cmdline", filter_re); + } + self.filter_matches(process, self.filter_key.as_deref().unwrap_or("name"), filter_re) + } + + fn filter_matches(&self, process: &Value, key: &str, filter_re: &Regex) -> bool { + let Some(value) = process.get(key) else { + return false; + }; + let text = match value { + Value::String(s) => s.clone(), + Value::Array(items) => { + if key == "cmdline" && !items.is_empty() { + items[0].as_str().unwrap_or_default().to_string() + } else { + items.iter().map(|i| i.as_str().unwrap_or_default()).collect::>().join(" ") + } + } + Value::Null => return false, + other => crate::units::py_str(other), + }; + filter_re.is_match(&text) && filter_re.find(&text).map(|m| m.len() == text.len()).unwrap_or(false) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FilterList { + filters: Vec, +} + +impl FilterList { + pub fn is_empty(&self) -> bool { + self.filters.is_empty() + } + + pub fn filters(&self) -> &[Filter] { + &self.filters + } + + /// Set the filter list from a comma separated list of filters. + pub fn set_filter(&mut self, value: Option<&str>) { + self.filters.clear(); + if let Some(value) = value { + for f in value.split(',') { + let mut filter = Filter::default(); + filter.set_filter(Some(f)); + self.filters.push(filter); + } + } + } + + /// Return True if the process is filtered by at least one filter. + pub fn is_filtered(&self, process: &Value) -> bool { + self.filters.iter().any(|f| f.is_filtered(process)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_filter_by_name() { + let mut f = Filter::default(); + f.set_filter(Some(".*python.*")); + assert!(f.is_filtered(&json!({"name": "python3", "cmdline": ["python3"]}))); + assert!(!f.is_filtered(&json!({"name": "bash", "cmdline": ["bash"]}))); + } + + #[test] + fn test_filter_by_key() { + let mut f = Filter::default(); + f.set_filter(Some("username:root")); + assert!(f.is_filtered(&json!({"name": "x", "username": "root"}))); + assert!(!f.is_filtered(&json!({"name": "x", "username": "bob"}))); + } +} diff --git a/crates/glances-core/src/history.rs b/crates/glances-core/src/history.rs new file mode 100644 index 0000000..b8cf309 --- /dev/null +++ b/crates/glances-core/src/history.rs @@ -0,0 +1,110 @@ +//! Stats history (port of `glances.attribute` / `glances.history`). + +use chrono::{DateTime, Utc}; +use indexmap::IndexMap; +use std::collections::VecDeque; + +/// One historized attribute: a ring buffer of `(date, value)` tuples. +#[derive(Debug, Clone)] +pub struct Attribute { + pub name: String, + pub description: String, + pub value: Option, + pub history: VecDeque<(DateTime, f64)>, + pub history_max_size: Option, +} + +impl Attribute { + pub fn new(name: impl Into, description: impl Into, history_max_size: Option) -> Self { + Self { + name: name.into(), + description: description.into(), + value: None, + history: VecDeque::new(), + history_max_size, + } + } + + pub fn set_value(&mut self, value: f64) { + self.value = Some(value); + let now = Utc::now(); + if let Some(max) = self.history_max_size { + if self.history.len() >= max { + self.history.pop_front(); + } + self.history.push_back((now, value)); + } + } + + pub fn history_reset(&mut self) { + self.history.clear(); + } + + pub fn history_raw(&self, nb: usize) -> Vec<(DateTime, f64)> { + if nb == 0 { + self.history.iter().cloned().collect() + } else { + self.history.iter().rev().take(nb).collect::>().into_iter().rev().cloned().collect() + } + } + + pub fn history_json(&self, nb: usize) -> Vec<(String, f64)> { + self.history_raw(nb) + .into_iter() + .map(|(date, value)| (date.to_rfc3339(), value)) + .collect() + } +} + +/// A dict of historized attributes. +#[derive(Debug, Clone, Default)] +pub struct History { + pub attributes: IndexMap, +} + +impl History { + pub fn add(&mut self, key: &str, value: f64, description: &str, history_max_size: Option) { + let attribute = self + .attributes + .entry(key.to_string()) + .or_insert_with(|| Attribute::new(key, description, history_max_size)); + attribute.set_value(value); + } + + pub fn reset(&mut self) { + for attribute in self.attributes.values_mut() { + attribute.history_reset(); + } + } + + pub fn get(&self, nb: usize) -> IndexMap, f64)>> { + self.attributes.iter().map(|(key, attr)| (key.clone(), attr.history_raw(nb))).collect() + } + + pub fn get_json(&self, nb: usize) -> IndexMap> { + self.attributes.iter().map(|(key, attr)| (key.clone(), attr.history_json(nb))).collect() + } + + /// Return the values of one item (used for TUI trends). + pub fn item_values(&self, item: &str) -> Option> { + self.attributes.get(item).map(|a| a.history.iter().map(|(_, v)| *v).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_history_ring_buffer() { + let mut history = History::default(); + for i in 0..5 { + history.add("x", i as f64, "", Some(3)); + } + let attr = history.attributes.get("x").unwrap(); + assert_eq!(attr.history.len(), 3); + assert_eq!(attr.value, Some(4.0)); + let raw = history.get(0); + assert_eq!(raw["x"].len(), 3); + } +} diff --git a/crates/glances-core/src/lib.rs b/crates/glances-core/src/lib.rs new file mode 100644 index 0000000..ede763a --- /dev/null +++ b/crates/glances-core/src/lib.rs @@ -0,0 +1,29 @@ +//! glances-core: shared foundations of the glances-rs rewrite. +//! +//! This crate contains the pieces that are common to every plugin and output: +//! argument parsing, configuration file handling, the plugin model (limits, +//! views, history, rates...), formatting helpers, the event/threshold/action +//! machinery and filtering. + +pub mod actions; +pub mod args; +pub mod config; +pub mod events; +pub mod filter; +pub mod history; +pub mod plugin; +pub mod processes; +pub mod secure; +pub mod state; +pub mod thresholds; +pub mod timer; +pub mod units; + +pub use args::Args; +pub use config::Config; +pub use plugin::{ + Align, Decoration, Field, HistoryItem, Limit, Plugin, PluginModel, RenderContext, StatLine, + UpdateContext, +}; +pub use state::Runtime; +pub use units::{auto_unit, py_repr, py_str, to_fahrenheit}; diff --git a/crates/glances-core/src/plugin.rs b/crates/glances-core/src/plugin.rs new file mode 100644 index 0000000..d9df8c9 --- /dev/null +++ b/crates/glances-core/src/plugin.rs @@ -0,0 +1,1169 @@ +//! The plugin model shared by every Glances plugin (port of +//! `glances.plugins.plugin.model`). + +use crate::actions::Actions; +use crate::args::Args; +use crate::config::Config; +use crate::events::events; +use crate::history::History; +use crate::state::Runtime; +use crate::thresholds::thresholds; +use crate::timer::Timer; +use crate::units::as_f64; +use indexmap::IndexMap; +use serde_json::{Map, Value, json}; +use std::collections::VecDeque; + +pub const HISTORY_MAX_SIZE: usize = 28800; + +/// Decoration of a stats line (drives the colour style in the TUI). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decoration { + Default, + Underline, + Bold, + Sort, + Ok, + Max, + Filter, + Title, + Process, + ProcessSelected, + Status, + CpuTime, + Careful, + Warning, + Critical, + OkLog, + CarefulLog, + WarningLog, + CriticalLog, + Selected, + Info, + Error, + Separator, + Password, +} + +impl Decoration { + pub fn name(self) -> &'static str { + match self { + Decoration::Default => "DEFAULT", + Decoration::Underline => "UNDERLINE", + Decoration::Bold => "BOLD", + Decoration::Sort => "SORT", + Decoration::Ok => "OK", + Decoration::Max => "MAX", + Decoration::Filter => "FILTER", + Decoration::Title => "TITLE", + Decoration::Process => "PROCESS", + Decoration::ProcessSelected => "PROCESS_SELECTED", + Decoration::Status => "STATUS", + Decoration::CpuTime => "CPU_TIME", + Decoration::Careful => "CAREFUL", + Decoration::Warning => "WARNING", + Decoration::Critical => "CRITICAL", + Decoration::OkLog => "OK_LOG", + Decoration::CarefulLog => "CAREFUL_LOG", + Decoration::WarningLog => "WARNING_LOG", + Decoration::CriticalLog => "CRITICAL_LOG", + Decoration::Selected => "SELECTED", + Decoration::Info => "INFO", + Decoration::Error => "ERROR", + Decoration::Separator => "SEPARATOR", + Decoration::Password => "PASSWORD", + } + } + + pub fn from_name(name: &str) -> Self { + match name { + "UNDERLINE" => Decoration::Underline, + "BOLD" => Decoration::Bold, + "SORT" => Decoration::Sort, + "OK" => Decoration::Ok, + "MAX" => Decoration::Max, + "FILTER" => Decoration::Filter, + "TITLE" => Decoration::Title, + "PROCESS" => Decoration::Process, + "PROCESS_SELECTED" => Decoration::ProcessSelected, + "STATUS" => Decoration::Status, + "CPU_TIME" => Decoration::CpuTime, + "CAREFUL" => Decoration::Careful, + "WARNING" => Decoration::Warning, + "CRITICAL" => Decoration::Critical, + "OK_LOG" => Decoration::OkLog, + "CAREFUL_LOG" => Decoration::CarefulLog, + "WARNING_LOG" => Decoration::WarningLog, + "CRITICAL_LOG" => Decoration::CriticalLog, + "SELECTED" => Decoration::Selected, + "INFO" => Decoration::Info, + "ERROR" => Decoration::Error, + "SEPARATOR" => Decoration::Separator, + "PASSWORD" => Decoration::Password, + _ => Decoration::Default, + } + } +} + +/// Alignment of a plugin block in the TUI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + Left, + Right, + Bottom, +} + +/// A line of the TUI, as produced by a plugin. +#[derive(Debug, Clone)] +pub struct StatLine { + pub msg: String, + pub decoration: Decoration, + pub optional: bool, + pub additional: bool, + pub splittable: bool, +} + +impl StatLine { + pub fn new(msg: impl Into) -> Self { + Self { + msg: msg.into(), + decoration: Decoration::Default, + optional: false, + additional: false, + splittable: false, + } + } + + pub fn decoration(mut self, decoration: Decoration) -> Self { + self.decoration = decoration; + self + } + + pub fn optional(mut self, optional: bool) -> Self { + self.optional = optional; + self + } +} + +/// Field metadata (port of the `fields_description` dicts). +#[derive(Debug, Clone, Default)] +pub struct Field { + pub description: String, + pub unit: Option, + pub rate: bool, + pub log: bool, + pub alert: bool, + pub optional: bool, + pub mmm: bool, + pub min_symbol: Option, + pub short_name: Option, +} + +impl Field { + pub fn new(description: &str) -> Self { + Self { description: description.to_string(), ..Default::default() } + } + + pub fn unit(mut self, unit: &str) -> Self { + self.unit = Some(unit.to_string()); + self + } + + pub fn rate(mut self) -> Self { + self.rate = true; + self + } + + pub fn log(mut self) -> Self { + self.log = true; + self + } + + pub fn alert(mut self) -> Self { + self.alert = true; + self + } + + pub fn optional(mut self) -> Self { + self.optional = true; + self + } + + pub fn mmm(mut self) -> Self { + self.mmm = true; + self + } + + pub fn min_symbol(mut self, symbol: &str) -> Self { + self.min_symbol = Some(symbol.to_string()); + self + } + + pub fn short_name(mut self, name: &str) -> Self { + self.short_name = Some(name.to_string()); + self + } + + /// Unit type used to format the value (port of `fields_unit_type`). + pub fn unit_type(&self) -> &'static str { + match self.unit.as_deref() { + Some("percent" | "percents") => "float", + Some("number" | "numbers" | "int" | "ints" | "second" | "seconds" | "byte" | "bytes") => "int", + _ => "float", + } + } + + /// Short unit symbol (port of `fields_unit_short`). + pub fn short_unit(&self) -> &'static str { + match self.unit.as_deref() { + Some("percent") => "%", + _ => "", + } + } +} + +pub type Fields = IndexMap; + +/// A history item definition. +#[derive(Debug, Clone)] +pub struct HistoryItem { + pub name: String, + pub description: String, +} + +impl HistoryItem { + pub fn new(name: &str, description: &str) -> Self { + Self { name: name.to_string(), description: description.to_string() } + } +} + +/// A limit value from the configuration file. +#[derive(Debug, Clone)] +pub enum Limit { + Num(f64), + List(Vec), +} + +impl Limit { + pub fn as_num(&self) -> Option { + match self { + Limit::Num(n) => Some(*n), + Limit::List(_) => None, + } + } + + pub fn as_list(&self) -> Option<&[String]> { + match self { + Limit::List(items) => Some(items), + Limit::Num(_) => None, + } + } +} + +#[derive(Debug, Clone, Default)] +struct MmmState { + values: VecDeque, + min: Option, + max: Option, +} + +/// Context passed to `Plugin::collect`. +pub struct UpdateContext<'a> { + pub args: &'a Args, + pub runtime: &'a Runtime, + pub config: &'a Config, + pub cursor_position: i64, +} + +/// Context passed to `Plugin::render`. +pub struct RenderContext<'a> { + pub args: &'a Args, + pub runtime: &'a Runtime, + pub max_width: usize, +} + +/// The plugin model, shared base of all plugins. +pub struct PluginModel { + pub name: String, + pub stats: Value, + pub stats_previous: Option, + pub views: Map, + pub limits: IndexMap, + pub alias: IndexMap, + pub history: History, + pub history_enabled: bool, + pub items_history_list: Vec, + pub refresh_timer: Timer, + pub default_refresh: f64, + pub time_since_last_update: f64, + pub hide_zero: bool, + pub hide_threshold_bytes: f64, + pub hide_zero_fields: Vec, + pub enabled: bool, + pub fields: Fields, + pub item_key: Option<&'static str>, + /// Apply the MMM (min/max/mean) computation on update. Only the plugins + /// which explicitly enable it (like mem in the Python implementation). + pub mmm_tracked: bool, + mmm: IndexMap, + pub actions: Actions, +} + +impl PluginModel { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: &str, + fields: Fields, + items_history_list: Vec, + init_value: Value, + config: &Config, + default_refresh: f64, + disable_history: bool, + disable_config_exec: bool, + ) -> Self { + let mut model = Self { + name: name.to_string(), + stats: init_value, + stats_previous: None, + views: Map::new(), + limits: IndexMap::new(), + alias: IndexMap::new(), + history: History::default(), + history_enabled: !disable_history && !items_history_list.is_empty(), + items_history_list, + refresh_timer: Timer::new(0.0), + default_refresh, + time_since_last_update: 1.0, + hide_zero: false, + hide_threshold_bytes: 0.0, + hide_zero_fields: Vec::new(), + enabled: true, + fields, + item_key: None, + mmm_tracked: false, + mmm: IndexMap::new(), + actions: Actions::new(disable_config_exec, default_refresh), + }; + model.load_limits(config); + model.alias = model.read_alias(); + model.init_mmm_fields(); + model + } + + pub fn set_init_value(&mut self, value: Value) { + self.stats = value; + } + + /// Load limits from the configuration file. + pub fn load_limits(&mut self, config: &Config) { + self.limits.clear(); + self.limits.insert("history_size".to_string(), Limit::Num(HISTORY_MAX_SIZE as f64)); + + if config.has_section("global") { + let history_size = config.get_float_value("global", "history_size", HISTORY_MAX_SIZE as f64); + self.limits.insert("history_size".to_string(), Limit::Num(history_size)); + } + + if config.has_section(&self.name) { + for (level, value) in config.items(&self.name) { + let limit = format!("{}_{}", self.name, level); + match value.trim().parse::() { + Ok(number) => { + self.limits.insert(limit.clone(), Limit::Num(number)); + } + Err(_) => { + let items: Vec = value.split(',').map(|s| s.trim().to_string()).collect(); + self.limits.insert(limit.clone(), Limit::List(items)); + } + } + tracing::debug!("Load limit: {} = {:?}", limit, self.limits.get(&limit)); + } + } + } + + pub fn history_size(&self) -> usize { + match self.limits.get("history_size") { + Some(Limit::Num(n)) => *n as usize, + _ => HISTORY_MAX_SIZE, + } + } + + pub fn set_refresh(&mut self, value: f64) { + self.limits.insert(format!("{}_refresh", self.name), Limit::Num(value)); + } + + pub fn get_refresh(&self) -> f64 { + match self.limits.get(&format!("{}_refresh", self.name)) { + Some(Limit::Num(n)) => *n, + _ => self.default_refresh, + } + } + + /// Return the limit value for the given criticality. + pub fn get_limit(&self, criticality: &str, stat_name: &str) -> Option { + let stat_name = stat_name.to_lowercase(); + if let Some(Limit::Num(v)) = self.limits.get(&format!("{stat_name}_{criticality}")) { + return Some(*v); + } + if let Some(Limit::Num(v)) = self.limits.get(&format!("{}_{criticality}", self.name)) { + return Some(*v); + } + None + } + + pub fn is_limit(&self, criticality: &str, stat_name: &str) -> bool { + let key = format!("{}_{criticality}", self.get_stat_name(Some(stat_name), None).to_lowercase()); + self.limits.contains_key(&key) + } + + /// Return the (commands, repeat) action pair for the alert. + pub fn get_limit_action(&self, criticality: &str, stat_name: &str) -> (Option>, bool) { + let candidates = [ + (format!("{stat_name}_{criticality}_action"), false), + (format!("{stat_name}_{criticality}_action_repeat"), true), + (format!("{}_{criticality}_action", self.name), false), + (format!("{}_{criticality}_action_repeat", self.name), true), + ]; + for (key, repeat) in candidates { + if let Some(Limit::List(commands)) = self.limits.get(&key) { + return (Some(commands.clone()), repeat); + } + } + (None, false) + } + + pub fn get_limit_log(&self, stat_name: &str, default_action: bool) -> bool { + if let Some(limit) = self.limits.get(&format!("{stat_name}_log")) { + return limit.as_list().and_then(|l| l.first()).map(|v| v.to_lowercase() == "true").unwrap_or(false); + } + if let Some(limit) = self.limits.get(&format!("{}_log", self.name)) { + return limit.as_list().and_then(|l| l.first()).map(|v| v.to_lowercase() == "true").unwrap_or(false); + } + default_action + } + + /// Return a configuration (header_) value for the current plugin. + pub fn get_conf_value(&self, value: &str, header: &str) -> Vec { + self.get_conf_value_for(value, header, &self.name) + } + + pub fn get_conf_value_for(&self, value: &str, header: &str, plugin_name: &str) -> Vec { + let mut plugin_name = plugin_name.to_string(); + if !header.is_empty() { + plugin_name = format!("{plugin_name}_{header}"); + } + match self.limits.get(&format!("{plugin_name}_{value}")) { + Some(Limit::List(items)) => items.clone(), + Some(Limit::Num(_)) | None => Vec::new(), + } + } + + /// Bool flavour of `get_conf_value`. + pub fn get_conf_bool(&self, value: &str, header: &str, plugin_name: &str) -> bool { + let mut key = plugin_name.to_string(); + if !header.is_empty() { + key = format!("{key}_{header}"); + } + match self.limits.get(&format!("{key}_{value}")) { + Some(Limit::List(items)) => items.first().map(|v| v.to_lowercase() == "true").unwrap_or(false), + Some(Limit::Num(n)) => *n != 0.0, + None => false, + } + } + + /// Return True if the value matches the `show` configuration list. + pub fn is_show(&self, value: &str, header: &str) -> bool { + let alias = self.has_alias(value); + self.get_conf_value("show", header).iter().any(|pattern| { + fullmatch(pattern, value) || alias.as_deref().map(|a| fullmatch(pattern, a)).unwrap_or(false) + }) + } + + /// Return True if the value matches the `hide` configuration list. + pub fn is_hide(&self, value: &str, header: &str) -> bool { + let alias = self.has_alias(value); + self.get_conf_value("hide", header).iter().any(|pattern| { + fullmatch(pattern, value) || alias.as_deref().map(|a| fullmatch(pattern, a)).unwrap_or(false) + }) + } + + pub fn is_display(&self, value: &str, header: &str) -> bool { + if !self.get_conf_value("show", header).is_empty() { + return self.is_show(value, header); + } + !self.is_hide(value, header) + } + + /// Return True if any of the values should be displayed in the UI. + pub fn is_display_any(&self, values: &[&str], header: &str) -> bool { + if !self.get_conf_value("show", header).is_empty() { + return values.iter().any(|value| self.is_show(value, header)); + } + !values.iter().any(|value| self.is_hide(value, header)) + } + + pub fn read_alias(&self) -> IndexMap { + match self.limits.get(&format!("{}_alias", self.name)) { + Some(Limit::List(items)) => items + .iter() + .filter_map(|item| { + let parts = crate::units::split_esc(item, ':', '\\'); + if parts.len() >= 2 { + Some((parts[0].to_lowercase(), parts[1..].join(":"))) + } else { + None + } + }) + .collect(), + _ => IndexMap::new(), + } + } + + pub fn has_alias(&self, header: &str) -> Option { + self.alias.get(&header.to_lowercase()).cloned() + } + + pub fn get_stat_name(&self, header: Option<&str>, action_key: Option<&str>) -> String { + let mut ret = self.name.clone(); + if let Some(key) = action_key { + if !key.is_empty() { + ret.push('_'); + ret.push_str(key); + } + } + if let Some(header) = header { + if !header.is_empty() { + ret.push('_'); + ret.push_str(header); + } + } + ret + } + + fn init_mmm_fields(&mut self) { + let mmm_names: Vec = self + .fields + .iter() + .filter(|(_, f)| f.mmm) + .map(|(name, _)| name.clone()) + .collect(); + for field_name in mmm_names { + let unit = self.fields.get(&field_name).and_then(|f| f.unit.clone()).unwrap_or_default(); + self.mmm.insert( + field_name.clone(), + MmmState { values: VecDeque::new(), min: None, max: None }, + ); + for (suffix, description) in [ + ("_min", format!("Minimum {field_name} observed since Glances startup.")), + ("_max", format!("Maximum {field_name} observed since Glances startup.")), + ("_mean", format!("Mean (average) {field_name} computed from the history.")), + ] { + let generated = format!("{field_name}{suffix}"); + let unit = unit.clone(); + self.fields + .entry(generated) + .or_insert_with(|| Field::new(&description).unit(&unit)); + } + } + } + + /// Update the min/max/mean fields of the stats. + pub fn update_mmm_fields(&mut self, stats: &mut Value) { + if self.mmm.is_empty() || !self.mmm_tracked { + return; + } + let Value::Object(map) = stats else { + return; + }; + let names: Vec = self.mmm.keys().cloned().collect(); + for field_name in names { + let Some(current) = map.get(&field_name).and_then(as_f64) else { + continue; + }; + let state = self.mmm.get_mut(&field_name).unwrap(); + state.values.push_back(current); + if state.values.len() > HISTORY_MAX_SIZE { + state.values.pop_front(); + } + state.min = Some(state.min.map(|m| m.min(current)).unwrap_or(current)); + state.max = Some(state.max.map(|m| m.max(current)).unwrap_or(current)); + let min = state.min.unwrap(); + let max = state.max.unwrap(); + let mean = state.values.iter().sum::() / state.values.len() as f64; + map.insert(format!("{field_name}_min"), json!(min)); + map.insert(format!("{field_name}_max"), json!(max)); + map.insert(format!("{field_name}_mean"), json!((mean * 100.0).round() / 100.0)); + } + } + + /// Update the rate fields of the stats (port of the `_manage_rate` + /// decorator). `key` is the field used to identify list items. + pub fn compute_rate(&mut self, stats: &mut Value, key: Option<&str>) { + let first_iteration = self.stats_previous.is_none(); + if !first_iteration { + match stats { + Value::Object(_) => self.compute_rate_on_stat(stats, None), + Value::Array(items) => { + let previous = self.stats_previous.clone().unwrap_or(Value::Null); + let previous_by_key: IndexMap = match key { + Some(key) => previous + .as_array() + .map(|list| { + list.iter() + .filter_map(|item| { + item.get(key).map(|v| (crate::units::py_str(v), item.clone())) + }) + .collect() + }) + .unwrap_or_default(), + None => IndexMap::new(), + }; + for item in items.iter_mut() { + let previous_item = key + .and_then(|k| item.get(k)) + .map(|v| crate::units::py_str(v)) + .and_then(|k| previous_by_key.get(&k).cloned()) + .unwrap_or(Value::Object(Map::new())); + self.compute_rate_on_stat(item, Some(&previous_item)); + } + } + _ => {} + } + } + // Memorize the current stats for the next run (the gauges are kept). + self.stats_previous = Some(stats.clone()); + } + + fn compute_rate_on_stat(&mut self, stat: &mut Value, previous_override: Option<&Value>) { + let previous = match previous_override { + Some(p) => p.clone(), + None => self.stats_previous.clone().unwrap_or(Value::Null), + }; + // A null previous means no sample at all yet (first iteration): + // rate fields stay as they are. An empty object means the item is new + // and takes the first-sample path below (gauge recorded, rate 0). + if previous.is_null() { + return; + } + let Value::Object(map) = stat else { + return; + }; + let rate_fields: Vec = + self.fields.iter().filter(|(_, f)| f.rate).map(|(name, _)| name.clone()).collect(); + for field in rate_fields { + if !map.contains_key(&field) { + continue; + } + map.insert("time_since_update".to_string(), json!(self.time_since_last_update)); + let current_value = map.get(&field).cloned().unwrap_or(Value::Null); + map.insert(format!("{field}_gauge"), current_value.clone()); + let current = as_f64(¤t_value); + let previous_gauge = previous.get(format!("{field}_gauge")).and_then(as_f64); + match (current, previous_gauge) { + (Some(current), Some(previous)) if current != 0.0 && previous != 0.0 => { + let delta = current - previous; + map.insert(field.clone(), json!(delta)); + let rate = if self.time_since_last_update > 0.0 { + (delta / self.time_since_last_update).floor() + } else { + 0.0 + }; + map.insert(format!("{field}_rate_per_sec"), json!(rate)); + if self.time_since_last_update <= 0.0 { + map.insert(field.clone(), json!(0)); + } + } + _ => { + map.insert(field.clone(), json!(0)); + map.insert(format!("{field}_rate_per_sec"), json!(0)); + } + } + } + } + + /// Get a view option for a stats field. + /// + /// - For dict stats: `item` is None and `field` is the stats key. + /// - For list stats: `item` is the key value of the item and `field` is + /// the stats key inside that item. + pub fn get_view(&self, item: Option<&str>, field: &str, option: &str) -> Value { + let item_views: Option<&Value> = match item { + None => self.views.get(field), + Some(item) => self.views.get(item).and_then(|v| v.get(field)), + }; + item_views.and_then(|v| v.get(option)).cloned().unwrap_or_else(|| json!("DEFAULT")) + } + + pub fn view_decoration(&self, item: Option<&str>, field: &str) -> Decoration { + match self.get_view(item, field, "decoration") { + Value::String(s) => Decoration::from_name(&s), + _ => Decoration::Default, + } + } + + pub fn view_bool(&self, item: Option<&str>, field: &str, option: &str) -> bool { + self.get_view(item, field, option).as_bool().unwrap_or(false) + } + + /// Override the decoration of a view (used by plugins which add specific + /// alerts on top of the generic ones). + pub fn set_view_decoration(&mut self, item: Option<&str>, field: &str, decoration: Decoration) { + let view = match item { + None => self.views.get_mut(field), + Some(item) => self.views.get_mut(item).and_then(|v| v.get_mut(field)), + }; + if let Some(view) = view.and_then(Value::as_object_mut) { + view.insert("decoration".to_string(), json!(decoration.name())); + } + } + + fn build_field_decoration(&mut self, field: &str, value: &Value) -> Decoration { + let Some(definition) = self.fields.get(field).cloned() else { + return Decoration::Default; + }; + if definition.rate && self.stats.is_object() { + if self.stats.get("time_since_update").and_then(as_f64) == Some(0.0) { + return Decoration::Default; + } + } + if definition.log { + let (current, minimum, maximum) = numeric_alert_args(value); + return self.get_alert(current, minimum, maximum, true, false, Some(field), None, true); + } + if definition.alert { + let (current, minimum, maximum) = numeric_alert_args(value); + return self.get_alert(current, minimum, maximum, true, false, Some(field), None, false); + } + Decoration::Default + } + + fn build_view_for_field(&mut self, key: Option<&str>, field: &str, value: &Value) -> Value { + let decoration = self.build_field_decoration(field, value); + let optional = self.fields.get(field).map(|f| f.optional).unwrap_or(false); + + let hidden = if !self.hide_zero { + false + } else if let Some(key) = key { + if let Some(existing) = self.views.get(key).and_then(|v| v.get(field)).and_then(|v| v.get("hidden")) { + let mut hidden = existing.as_bool().unwrap_or(false); + if self.hide_zero_fields.iter().any(|f| f == field) { + if let Some(raw) = self.get_raw_stats_key(field, key).and_then(|v| as_f64(&v)) { + if raw > self.hide_threshold_bytes { + hidden = false; + } + } + } + hidden + } else { + self.hide_zero_fields.iter().any(|f| f == field) + } + } else { + self.hide_zero_fields.iter().any(|f| f == field) + }; + + json!({ + "decoration": decoration.name(), + "optional": optional, + "additional": false, + "splittable": false, + "hidden": hidden, + }) + } + + fn get_raw_stats_key(&self, item: &str, key: &str) -> Option { + match &self.stats { + Value::Array(items) => items + .iter() + .find(|i| { + let item_key_field = i.get("key").and_then(Value::as_str); + item_key_field + .and_then(|kf| i.get(kf)) + .map(crate::units::py_str) + .as_deref() + == Some(key) + }) + .and_then(|i| i.get(item)) + .cloned(), + Value::Object(map) => map.get(item).cloned(), + _ => None, + } + } + + /// Update the views (metadata used to render the stats). + pub fn update_views(&mut self, key: Option<&str>) -> Map { + let mut ret = Map::new(); + match self.stats.clone() { + Value::Array(items) if key.is_some() => { + let key = key.unwrap(); + for item in &items { + let Some(item_key) = item.get(key).map(crate::units::py_str) else { + continue; + }; + let mut item_views = Map::new(); + if let Value::Object(fields) = item { + for field in fields.keys() { + let value = fields.get(field).cloned().unwrap_or(Value::Null); + let view = self.build_view_for_field(Some(&item_key), field, &value); + item_views.insert(field.clone(), view); + } + } + ret.insert(item_key, Value::Object(item_views)); + } + } + Value::Object(fields) => { + for (field, value) in &fields { + let view = self.build_view_for_field(None, field, value); + ret.insert(field.clone(), view); + } + } + _ => {} + } + self.views = ret.clone(); + ret + } + + pub fn reset_views(&mut self) { + self.views = Map::new(); + } + + /// Return the alert status relative to a current value. + #[allow(clippy::too_many_arguments)] + pub fn get_alert( + &mut self, + current: f64, + minimum: f64, + maximum: f64, + highlight_zero: bool, + is_max: bool, + header: Option<&str>, + action_key: Option<&str>, + log: bool, + ) -> Decoration { + if !highlight_zero && current == 0.0 { + return Decoration::Default; + } + if maximum == 0.0 { + return Decoration::Default; + } + let value = current * 100.0 / maximum; + + let stat_name = self.get_stat_name(header, action_key).to_lowercase(); + let mut ret = if is_max { Decoration::Max } else { Decoration::Ok }; + + let critical = self.get_limit("critical", &stat_name); + let warning = self.get_limit("warning", &stat_name); + let careful = self.get_limit("careful", &stat_name); + if let Some(critical) = critical { + if value >= critical { + ret = Decoration::Critical; + } + } + if ret == Decoration::Ok { + if let Some(warning) = warning { + if value >= warning { + ret = Decoration::Warning; + } + } + } + if ret == Decoration::Ok { + if let Some(careful) = careful { + if value >= careful { + ret = Decoration::Careful; + } + } + } + if ret == Decoration::Ok && careful.is_none() && warning.is_none() && critical.is_none() { + ret = Decoration::Default; + } + + if current < minimum { + ret = Decoration::Careful; + } + + let mut log_str = false; + if self.get_limit_log(&stat_name, log) && ret != Decoration::Default { + log_str = true; + let log_ret = match ret { + Decoration::Ok => "OK", + Decoration::Careful => "CAREFUL", + Decoration::Warning => "WARNING", + Decoration::Critical => "CRITICAL", + Decoration::Max => "MAX", + _ => "OK", + }; + events().lock().unwrap().add(log_ret, &stat_name.to_uppercase(), value, ""); + } + + thresholds().lock().unwrap().add(&stat_name, ret.name()); + + self.manage_action(&stat_name, ret.name().to_lowercase().as_str(), header, action_key); + + match (ret, log_str) { + (Decoration::Ok, true) => Decoration::OkLog, + (Decoration::Careful, true) => Decoration::CarefulLog, + (Decoration::Warning, true) => Decoration::WarningLog, + (Decoration::Critical, true) => Decoration::CriticalLog, + (other, _) => other, + } + } + + pub fn get_alert_log(&mut self, current: f64, maximum: f64, header: Option<&str>) -> Decoration { + self.get_alert(current, 0.0, maximum, false, false, header, None, true) + } + + fn manage_action(&mut self, stat_name: &str, trigger: &str, header: Option<&str>, action_key: Option<&str>) { + let (command, repeat) = self.get_limit_action(trigger, stat_name); + match command { + None => { + self.actions.set(stat_name, trigger); + } + Some(commands) => { + let action_key = action_key.or(header); + let stats_action = self.get_stats_action(); + let mut mustache_dict = match &stats_action { + Value::Array(items) if action_key.is_some() => { + let action_key = action_key.unwrap(); + let key_field = self.item_key.clone(); + items + .iter() + .find(|item| { + key_field + .as_deref() + .and_then(|k| item.get(k)) + .map(crate::units::py_str) + .as_deref() + == Some(action_key) + }) + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())) + } + Value::Object(_) => stats_action.clone(), + _ => Value::Object(Map::new()), + }; + if let Value::Object(map) = &mut mustache_dict { + map.insert("critical".to_string(), json!(self.get_limit("critical", stat_name))); + map.insert("warning".to_string(), json!(self.get_limit("warning", stat_name))); + map.insert("careful".to_string(), json!(self.get_limit("careful", stat_name))); + map.insert( + "time".to_string(), + json!(chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.6f").to_string()), + ); + } + self.actions.run(stat_name, trigger, &commands, repeat, Some(&mustache_dict)); + } + } + } + + pub fn get_stats_action(&self) -> Value { + self.stats.clone() + } + + /// Update the history with the current stats. + pub fn update_stats_history(&mut self) { + if !self.history_enabled { + return; + } + let export = self.stats.clone(); + if export.is_null() { + return; + } + let history_max_size = self.history_size(); + let items = self.items_history_list.clone(); + let key = self.item_key.clone(); + for item in items { + match &export { + Value::Array(list) => { + for entry in list { + let Some(item_key) = key.as_deref().and_then(|k| entry.get(k)).map(crate::units::py_str) else { + continue; + }; + if let Some(value) = entry.get(&item.name).and_then(as_f64) { + self.history.add( + &format!("{item_key}_{}", item.name), + value, + &item.description, + Some(history_max_size), + ); + } + } + } + Value::Object(_) => { + if let Some(value) = export.get(&item.name).and_then(as_f64) { + self.history.add(&item.name, value, &item.description, Some(history_max_size)); + } + } + _ => {} + } + } + } + + pub fn reset_stats_history(&mut self) { + self.history.reset(); + } + + pub fn get_trend(&self, item: &str, nb: usize) -> Option { + let raw = self.history.item_values(item)?; + if raw.len() < nb || nb == 0 { + return None; + } + let last: Vec = raw.iter().rev().take(nb).copied().collect(); + let half = nb / 2; + let mean = |slice: &[f64]| slice.iter().sum::() / slice.len() as f64; + Some(mean(&last[..half]) - mean(&last[half..])) + } + + /// Return the field used to identify list items. + pub fn item_key(&self) -> Option<&'static str> { + self.item_key + } + + /// Set the item key for list stats. + pub fn set_item_key(&mut self, key: &'static str) { + self.item_key = Some(key); + } + + /// Sort list stats naturally (alias or key). + pub fn sorted_stats(&self) -> Value { + let Some(key) = self.item_key else { + return self.stats.clone(); + }; + let Value::Array(items) = &self.stats else { + return self.stats.clone(); + }; + let mut sorted = items.clone(); + sorted.sort_by(|a, b| { + let a_name = self + .has_alias(a.get(key).and_then(Value::as_str).unwrap_or("")) + .unwrap_or_else(|| a.get(key).and_then(Value::as_str).unwrap_or("").to_string()); + let b_name = self + .has_alias(b.get(key).and_then(Value::as_str).unwrap_or("")) + .unwrap_or_else(|| b.get(key).and_then(Value::as_str).unwrap_or("").to_string()); + natural_key(&a_name).cmp(&natural_key(&b_name)) + }); + Value::Array(sorted) + } +} + +fn numeric_alert_args(value: &Value) -> (f64, f64, f64) { + // `maximum` is 100 for percent-like values, 1 otherwise (Python default + // is maximum=100 and callers pass the raw stat as current). + (as_f64(value).unwrap_or(0.0), 0.0, 100.0) +} + +fn natural_key(text: &str) -> Vec { + let mut parts = Vec::new(); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.peek().copied() { + if c.is_ascii_digit() { + let mut num = String::new(); + while let Some(d) = chars.peek().copied() { + if d.is_ascii_digit() { + num.push(d); + chars.next(); + } else { + break; + } + } + parts.push(NaturalPart::Number(num.parse().unwrap_or(0))); + } else { + let mut text_part = String::new(); + while let Some(d) = chars.peek().copied() { + if !d.is_ascii_digit() { + text_part.push(d); + chars.next(); + } else { + break; + } + } + parts.push(NaturalPart::Text(text_part.to_lowercase())); + } + } + parts +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum NaturalPart { + Number(u64), + Text(String), +} + +fn fullmatch(pattern: &str, value: &str) -> bool { + match regex::Regex::new(&format!("^(?:{pattern})$")) { + Ok(re) => re.is_match(value), + Err(_) => false, + } +} + +/// The plugin trait. +pub trait Plugin: Send + Sync { + fn name(&self) -> &str; + fn model(&self) -> &PluginModel; + fn model_mut(&mut self) -> &mut PluginModel; + + fn fields(&self) -> &Fields { + &self.model().fields + } + + /// Collect the raw stats. Called only when the refresh timer is finished. + fn collect(&mut self, ctx: &mut UpdateContext) -> anyhow::Result; + + /// Hook called after the rate computation (e.g. diskio latency). + fn post_collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result<()> { + Ok(()) + } + + /// Update the views (metadata used to render the stats). Plugins with + /// specific alerts override this method. + fn update_views(&mut self) { + let key = self.get_key(); + self.model_mut().update_views(key); + } + + /// Field used to identify list items (None for dict stats). + fn get_key(&self) -> Option<&'static str> { + None + } + + /// Initial value of the stats (used to force a first update). + fn init_value(&self) -> Value { + json!({}) + } + + /// Return False if the plugin can not run on this system. + fn available(&mut self) -> bool { + true + } + + fn exit(&mut self) {} + + /// Render the plugin block in the TUI. + fn render(&self, ctx: &RenderContext) -> Vec { + let _ = ctx; + Vec::new() + } + + fn display_curse(&self) -> bool { + false + } + + fn align(&self) -> Align { + Align::Left + } + + /// Stats to export (overridable, e.g. processlist). + fn export(&self) -> Value { + self.model().stats.clone() + } + + fn stats(&self) -> &Value { + &self.model().stats + } + + fn is_enabled(&self) -> bool { + self.model().enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.model_mut().enabled = enabled; + } +} diff --git a/crates/glances-core/src/processes.rs b/crates/glances-core/src/processes.rs new file mode 100644 index 0000000..95c5b76 --- /dev/null +++ b/crates/glances-core/src/processes.rs @@ -0,0 +1,719 @@ +//! Process collection and management (port of `glances.processes`). +//! +//! The process list is shared between the processlist, processcount, +//! programlist, amps plugins and the event/action machinery, exactly like the +//! `glances_processes` global in the Python implementation. + +use crate::filter::{Filter, FilterList}; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::OnceLock; +use sysinfo::{ProcessesToUpdate, System, Users}; + +/// Mandatory process stats which can not be disabled by the user. +pub const MANDATORY_PROCESS_STATS: [&str; 2] = ["pid", "name"]; + +/// Available process sort keys. +pub const SORT_PROCESSES_STATS: [&str; 7] = + ["cpu_percent", "memory_percent", "username", "cpu_times", "io_counters", "name", "cpu_num"]; + +/// Sort keys as displayed to humans. +pub fn sort_for_human(key: &str) -> &str { + match key { + "io_counters" => "disk IO", + "cpu_percent" => "CPU consumption", + "memory_percent" => "memory consumption", + "cpu_times" => "process time", + "username" => "user name", + "name" => "processs name", + "cpu_num" => "CPU core number", + "auto" => "auto", + _ => "None", + } +} + +#[derive(Debug, Clone, Default)] +pub struct ProcessCount { + pub total: i64, + pub running: i64, + pub sleeping: i64, + pub thread: i64, + pub pid_max: Option, +} + +#[derive(Debug)] +pub struct Processes { + system: System, + users: Users, + users_timer: f64, + + pub processlist: Vec, + pub processlist_export: Vec, + pub processcount: ProcessCount, + + pub sort_key: String, + pub auto_sort: bool, + pub sort_reverse: bool, + + pub disable_tag: bool, + pub disable_extended_tag: bool, + pub extended_process_pid: Option, + pub extended_stats: Option, + + pub no_kernel_threads: bool, + pub disable_io_counters: bool, + pub disable_gids: bool, + pub disable_cpu_num: bool, + pub disable_stats: Vec, + + /// List of processes to focus on. + pub filter_focus: FilterList, + /// Process filter (regular expression). + pub filter: Filter, + /// List of processes stats to export. + pub filter_export: FilterList, + + pub max_values: HashMap, + io_old: HashMap, + programs: Vec, + programs_valid: bool, +} + +impl Default for Processes { + fn default() -> Self { + Self::new() + } +} + +impl Processes { + pub fn new() -> Self { + let system = System::new(); + let users = Users::new_with_refreshed_list(); + let mut p = Self { + system, + users, + users_timer: 0.0, + processlist: Vec::new(), + processlist_export: Vec::new(), + processcount: ProcessCount::default(), + sort_key: "cpu_percent".to_string(), + auto_sort: true, + sort_reverse: true, + disable_tag: false, + disable_extended_tag: false, + extended_process_pid: None, + extended_stats: None, + no_kernel_threads: false, + disable_io_counters: false, + disable_gids: false, + disable_cpu_num: false, + disable_stats: Vec::new(), + filter_focus: FilterList::default(), + filter: Filter::default(), + filter_export: FilterList::default(), + max_values: HashMap::new(), + io_old: HashMap::new(), + programs: Vec::new(), + programs_valid: false, + }; + p.reset_max_values(); + p + } + + pub fn reset_max_values(&mut self) { + self.max_values.clear(); + self.max_values.insert("cpu_percent".to_string(), 0.0); + self.max_values.insert("memory_percent".to_string(), 0.0); + } + + pub fn set_sort_key(&mut self, key: &str, auto: bool) { + if key == "auto" { + self.auto_sort = true; + self.sort_key = "cpu_percent".to_string(); + } else { + self.auto_sort = auto; + self.sort_key = key.to_string(); + } + self.sort_reverse = !matches!(self.sort_key.as_str(), "name" | "username" | "cpu_num"); + } + + pub fn enable(&mut self) { + self.disable_tag = false; + } + + pub fn disable(&mut self) { + self.disable_tag = true; + } + + pub fn enable_extended(&mut self) { + self.disable_extended_tag = false; + } + + pub fn disable_extended(&mut self) { + self.disable_extended_tag = true; + } + + pub fn pid_max(&self) -> Option { + std::fs::read_to_string("/proc/sys/kernel/pid_max").ok().and_then(|v| v.trim().parse::().ok()) + } + + pub fn processes_count(&self, max_processes: Option) -> i64 { + match max_processes { + Some(max) => (max - 2).min(self.processcount.total - 1), + None => 0, + } + } + + /// Update the processes stats. + pub fn update(&mut self, cursor_position: i64, args: &crate::args::Args) -> &[Value] { + if self.disable_tag { + return &[]; + } + self.no_kernel_threads = args.no_kernel_threads; + + let time_since_update = crate::timer::get_time_since_last_update("process_disk"); + + // Refresh the user list at most every 60 seconds. + if self.users_timer <= 0.0 { + self.users = Users::new_with_refreshed_list(); + self.users_timer = 60.0; + } + self.users_timer -= 1.0; + + self.system.refresh_memory(); + self.system.refresh_processes(ProcessesToUpdate::All, true); + + let total_mem = self.system.total_memory().max(1) as f64; + let mut processlist: Vec = Vec::with_capacity(self.system.processes().len()); + + for (pid, process) in self.system.processes() { + // Skip threads: the process list shows processes only. + if process.thread_kind().is_some() { + continue; + } + let pid = pid.as_u32() as i64; + if self.no_kernel_threads && process.parent().map(|p| p.as_u32()) == Some(2) { + continue; + } + + let stat = read_proc_stat(pid); + let name = process.name().to_string_lossy().to_string(); + let cmdline: Vec = + process.cmd().iter().map(|c| c.to_string_lossy().to_string()).collect(); + let username = process + .user_id() + .and_then(|uid| self.users.get_user_by_id(uid)) + .map(|u| u.name().to_string()) + .unwrap_or_default(); + + let status = stat.as_ref().map(|s| s.state.clone()).unwrap_or_else(|| "?".to_string()); + let nice = stat.as_ref().map(|s| s.nice).unwrap_or(0); + let num_threads = stat.as_ref().map(|s| s.num_threads).unwrap_or(0); + let cpu_num = stat.as_ref().map(|s| s.processor).unwrap_or(0); + let (user_time, system_time) = + stat.as_ref().map(|s| (s.utime, s.stime)).unwrap_or((0.0, 0.0)); + + let rss = process.memory() as f64; + let vms = process.virtual_memory() as f64; + let memory_percent = rss * 100.0 / total_mem; + + let mut proc = Map::new(); + proc.insert("pid".to_string(), json!(pid)); + proc.insert("name".to_string(), json!(name)); + proc.insert("cmdline".to_string(), json!(cmdline)); + proc.insert("username".to_string(), json!(username)); + proc.insert("num_threads".to_string(), json!(num_threads)); + proc.insert("cpu_percent".to_string(), json!(process.cpu_usage() as f64)); + proc.insert("memory_percent".to_string(), json!(memory_percent)); + proc.insert("memory_info".to_string(), json!({"rss": rss, "vms": vms})); + proc.insert("status".to_string(), json!(status)); + proc.insert("nice".to_string(), json!(nice)); + proc.insert( + "cpu_times".to_string(), + json!({"user": user_time, "system": system_time, "iowait": 0.0}), + ); + proc.insert("cpu_num".to_string(), json!(cpu_num)); + + // IO counters: [read_bytes, write_bytes, read_bytes_old, write_bytes_old, io_tag] + let (read_bytes, write_bytes) = match &stat { + Some(stat) => (stat.read_bytes, stat.write_bytes), + None => { + let disk = process.disk_usage(); + (disk.total_read_bytes, disk.total_written_bytes) + } + }; + let (io_tag, io_old) = match self.io_old.get(&pid) { + Some(old) => (1, *old), + None => (0, [0, 0]), + }; + self.io_old.insert(pid, [read_bytes, write_bytes]); + proc.insert( + "io_counters".to_string(), + json!([read_bytes, write_bytes, io_old[0], io_old[1], io_tag]), + ); + + // GIDs + if let Some(stat) = &stat { + proc.insert( + "gids".to_string(), + json!({"real": stat.gid_real, "effective": stat.gid_effective, "saved": stat.gid_saved}), + ); + } + + // Metadata + proc.insert("key".to_string(), json!("pid")); + proc.insert("time_since_update".to_string(), json!(time_since_update)); + + processlist.push(Value::Object(proc)); + } + + // Sort processes by the current sort key. + sort_stats(&mut processlist, &self.sort_key, "memory_percent", self.sort_reverse); + + self.update_processcount(&processlist); + + // Extended stats for the selected process. + for (position, proc) in processlist.iter_mut().enumerate() { + let pid = proc.get("pid").and_then(Value::as_i64).unwrap_or(0); + if args.enable_process_extended + && !args.programs + && !self.disable_extended_tag + && position as i64 == cursor_position + && !args.disable_cursor + { + self.extended_process_pid = Some(pid); + } + if let Some(target) = self.extended_process_pid { + if target == pid { + let extended = self.set_extended_stats(pid, proc); + if let Value::Object(map) = extended { + if let Some(obj) = proc.as_object_mut() { + for (k, v) in map { + obj.insert(k, v); + } + } + } + } + } + } + + // Remove non running processes from the IO cache. + let running: std::collections::HashSet = + processlist.iter().filter_map(|p| p.get("pid").and_then(Value::as_i64)).collect(); + self.io_old.retain(|pid, _| running.contains(pid)); + + // Export list. + self.processlist_export = self.update_export_list(&processlist); + + // Filtered process list. + let filtered = self.update_list(&processlist); + self.programs_valid = false; + + // Compute the maximum values for CPU and MEM. + for key in ["cpu_percent", "memory_percent"] { + if self.disable_stats.iter().any(|s| s == key) { + continue; + } + let max = filtered + .iter() + .filter_map(|p| p.get(key).and_then(Value::as_f64)) + .fold(f64::NEG_INFINITY, f64::max); + if max.is_finite() { + self.max_values.insert(key.to_string(), max); + } + } + + self.processlist = filtered; + &self.processlist + } + + fn set_extended_stats(&mut self, pid: i64, proc: &Value) -> Value { + let mut ret = Map::new(); + // CPU and MEM min/max/mean + let previous = self.extended_stats.clone(); + let prefix = |ret: &mut Map, + stat_prefix: &str, + current: f64, + previous: Option<&Value>| { + let prev = previous.and_then(|p| p.as_object()); + let min_key = format!("{stat_prefix}_min"); + let max_key = format!("{stat_prefix}_max"); + let mean_sum_key = format!("{stat_prefix}_mean_sum"); + let mean_counter_key = format!("{stat_prefix}_mean_counter"); + let prev_min = prev.and_then(|p| p.get(&min_key)).and_then(Value::as_f64); + let prev_max = prev.and_then(|p| p.get(&max_key)).and_then(Value::as_f64); + let prev_sum = prev.and_then(|p| p.get(&mean_sum_key)).and_then(Value::as_f64); + let prev_count = prev.and_then(|p| p.get(&mean_counter_key)).and_then(Value::as_i64); + let min = prev_min.map(|m| m.min(current)).unwrap_or(current); + let max = prev_max.map(|m| m.max(current)).unwrap_or(current); + let sum = prev_sum.map(|s| s + current).unwrap_or(current); + let count = prev_count.map(|c| c + 1).unwrap_or(1); + ret.insert(min_key, json!(min)); + ret.insert(max_key, json!(max)); + ret.insert(mean_sum_key, json!(sum)); + ret.insert(mean_counter_key, json!(count)); + ret.insert(format!("{stat_prefix}_mean"), json!(sum / count as f64)); + }; + + let cpu_percent = proc.get("cpu_percent").and_then(Value::as_f64).unwrap_or(0.0); + let memory_percent = proc.get("memory_percent").and_then(Value::as_f64).unwrap_or(0.0); + prefix(&mut ret, "cpu", cpu_percent, previous.as_ref()); + prefix(&mut ret, "memory", memory_percent, previous.as_ref()); + ret.insert("extended_stats".to_string(), json!(true)); + + let extended = Value::Object(ret); + self.extended_stats = Some(extended.clone()); + self.extended_process_pid = Some(pid); + extended + } + + pub fn update_processcount(&mut self, processlist: &[Value]) { + self.processcount.pid_max = self.pid_max(); + self.processcount.running = processlist + .iter() + .filter(|p| p.get("status").and_then(Value::as_str) == Some("R")) + .count() as i64; + self.processcount.sleeping = processlist + .iter() + .filter(|p| p.get("status").and_then(Value::as_str) == Some("S")) + .count() as i64; + self.processcount.thread = processlist + .iter() + .filter_map(|p| p.get("num_threads").and_then(Value::as_i64)) + .sum(); + self.processcount.total = processlist.len() as i64; + } + + fn update_list(&self, processlist: &[Value]) -> Vec { + if !self.filter_focus.is_empty() { + return processlist.iter().filter(|p| self.filter_focus.is_filtered(p)).cloned().collect(); + } + if self.filter.filter().is_none() { + return processlist.to_vec(); + } + processlist.iter().filter(|p| self.filter.is_filtered(p)).cloned().collect() + } + + fn update_export_list(&self, processlist: &[Value]) -> Vec { + if self.filter_export.is_empty() { + return Vec::new(); + } + processlist.iter().filter(|p| self.filter_export.is_filtered(p)).cloned().collect() + } + + /// Get the process list (sorted or not). + pub fn get_list(&self, sorted: bool) -> &[Value] { + let _ = sorted; // the list is always kept sorted after update() + &self.processlist + } + + /// Get the process list as programs (aggregated by name). + pub fn get_list_as_programs(&mut self) -> &[Value] { + if !self.programs_valid { + self.programs = processes_to_programs(&self.processlist); + self.programs_valid = true; + } + &self.programs + } + + pub fn get_export(&self) -> &[Value] { + &self.processlist_export + } + + pub fn get_stats(&self, pid: i64) -> Option<&Value> { + self.processlist.iter().find(|p| p.get("pid").and_then(Value::as_i64) == Some(pid)) + } + + /// Decrease the nice level of a process (higher priority). + pub fn nice_decrease(&self, pid: i64) { + let current = read_nice(pid).unwrap_or(0) as i32; + if unsafe { libc::setpriority(libc::PRIO_PROCESS, pid as libc::id_t, current - 1) } == 0 { + tracing::info!("Set nice level of process {pid} to {} (higher the priority)", current - 1); + } else { + tracing::warn!("Can not decrease (higher the priority) the nice level of process {pid} (access denied)"); + } + } + + /// Increase the nice level of a process (lower priority). + pub fn nice_increase(&self, pid: i64) { + let current = read_nice(pid).unwrap_or(0) as i32; + if unsafe { libc::setpriority(libc::PRIO_PROCESS, pid as libc::id_t, current + 1) } == 0 { + tracing::info!("Set nice level of process {pid} to {} (lower the priority)", current + 1); + } else { + tracing::warn!("Can not increase (lower the priority) the nice level of process {pid} (access denied)"); + } + } + + /// Kill a process. + pub fn kill(&self, pid: i64) { + assert!(pid != std::process::id() as i64, "Glances can kill itself..."); + tracing::debug!("Send kill signal to process: {pid}"); + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + } +} + +/// The global process manager (equivalent of `glances_processes`). +pub fn processes() -> &'static Mutex { + static PROCESSES: OnceLock> = OnceLock::new(); + PROCESSES.get_or_init(|| Mutex::new(Processes::new())) +} + +fn weighted(value: Option) -> f64 { + value.unwrap_or(f64::NEG_INFINITY) +} + +/// Sort a list of process dicts by the given key. +pub fn sort_stats(stats: &mut [Value], sorted_by: &str, sorted_by_secondary: &str, reverse: bool) { + let key_of = |process: &Value, key: &str| -> f64 { + match key { + "io_counters" => { + // [read_bytes, write_bytes, read_bytes_old, write_bytes_old, io_tag] + let counters = process.get("io_counters").and_then(Value::as_array); + let read = counters.and_then(|c| c.first()).and_then(Value::as_f64).unwrap_or(0.0); + let write = counters.and_then(|c| c.get(1)).and_then(Value::as_f64).unwrap_or(0.0); + let read_old = counters.and_then(|c| c.get(2)).and_then(Value::as_f64).unwrap_or(0.0); + let write_old = counters.and_then(|c| c.get(3)).and_then(Value::as_f64).unwrap_or(0.0); + read - read_old + write - write_old + } + "cpu_times" => { + let times = process.get("cpu_times"); + let user = times.and_then(|t| t.get("user")).and_then(Value::as_f64).unwrap_or(0.0); + let system = times.and_then(|t| t.get("system")).and_then(Value::as_f64).unwrap_or(0.0); + user + system + } + _ => process.get(key).and_then(Value::as_f64).unwrap_or(f64::NEG_INFINITY), + } + }; + let string_key = |process: &Value, key: &str| -> String { + process.get(key).and_then(Value::as_str).unwrap_or("~").to_string() + }; + + stats.sort_by(|a, b| { + let (primary, secondary) = match sorted_by { + "name" | "username" => { + let cmp = string_key(a, sorted_by).cmp(&string_key(b, sorted_by)); + let ord = if reverse { cmp.reverse() } else { cmp }; + return ord; + } + "cpu_num" => { + let av = a.get("cpu_num").and_then(Value::as_i64).unwrap_or(0); + let bv = b.get("cpu_num").and_then(Value::as_i64).unwrap_or(0); + let ord = av.cmp(&bv); + return if reverse { ord.reverse() } else { ord }; + } + _ => (key_of(a, sorted_by), key_of(b, sorted_by)), + }; + let ord = weighted(Some(primary)) + .partial_cmp(&weighted(Some(secondary))) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + weighted(Some(key_of(a, sorted_by_secondary))) + .partial_cmp(&weighted(Some(key_of(b, sorted_by_secondary)))) + .unwrap_or(std::cmp::Ordering::Equal) + }); + if reverse { ord.reverse() } else { ord } + }); +} + +/// Aggregate processes by program name (port of `processes_to_programs`). +pub fn processes_to_programs(processlist: &[Value]) -> Vec { + let no_io = [0u64; 5]; + let mut programs: indexmap::IndexMap = indexmap::IndexMap::new(); + for process in processlist { + let name = process.get("name").and_then(Value::as_str).unwrap_or("?").to_string(); + let num_threads = process.get("num_threads").and_then(Value::as_f64).unwrap_or(0.0).max(0.0); + let cpu_percent = process.get("cpu_percent").and_then(Value::as_f64).unwrap_or(0.0); + let memory_percent = process.get("memory_percent").and_then(Value::as_f64).unwrap_or(0.0); + let cpu_times = process.get("cpu_times").cloned().unwrap_or_else(|| json!({})); + let memory_info = process.get("memory_info").cloned().unwrap_or_else(|| json!({})); + let io_counters: Vec = process + .get("io_counters") + .and_then(Value::as_array) + .map(|values| values.iter().map(|v| v.as_u64().unwrap_or(0)).collect()) + .unwrap_or_else(|| no_io.to_vec()); + let pid = process.get("pid").and_then(Value::as_i64).unwrap_or(0); + let username = process.get("username").and_then(Value::as_str).unwrap_or("_").to_string(); + let nice = process.get("nice").and_then(Value::as_i64).unwrap_or(0); + let status = process.get("status").and_then(Value::as_str).unwrap_or("?").to_string(); + let time_since_update = process.get("time_since_update").cloned().unwrap_or(Value::Null); + + match programs.get_mut(&name) { + None => { + programs.insert( + name.clone(), + json!({ + "time_since_update": time_since_update, + "num_threads": num_threads, + "cpu_percent": cpu_percent, + "memory_percent": memory_percent, + "cpu_times": cpu_times, + "memory_info": memory_info, + "io_counters": io_counters, + "childrens": [pid], + "name": name, + "cmdline": [name], + "pid": "_", + "username": username, + "nice": nice, + "status": status, + }), + ); + } + Some(program) => { + let Some(program) = program.as_object_mut() else { + continue; + }; + let add = |program: &mut Map, key: &str, value: f64| { + let current = program.get(key).and_then(Value::as_f64).unwrap_or(0.0); + program.insert(key.to_string(), json!(current + value)); + }; + add(program, "num_threads", num_threads); + add(program, "cpu_percent", cpu_percent); + add(program, "memory_percent", memory_percent); + for key in ["user", "system", "iowait"] { + let value = cpu_times.get(key).and_then(Value::as_f64).unwrap_or(0.0); + if value != 0.0 || cpu_times.get(key).is_some() { + if let Some(times) = program.get_mut("cpu_times").and_then(Value::as_object_mut) { + let current = times.get(key).and_then(Value::as_f64).unwrap_or(0.0); + times.insert(key.to_string(), json!(current + value)); + } + } + } + if let Some(program_memory) = program.get_mut("memory_info").and_then(Value::as_object_mut) { + if let Some(addition) = memory_info.as_object() { + for (key, value) in addition { + let current = program_memory.get(key).and_then(Value::as_f64).unwrap_or(0.0); + let value = value.as_f64().unwrap_or(0.0); + program_memory.insert(key.clone(), json!(current + value)); + } + } + } + // io_counters: sum slots 0..4, OR the tag. + let mut summed: Vec = vec![0; 5]; + let existing: Vec = program + .get("io_counters") + .and_then(Value::as_array) + .map(|values| values.iter().map(|v| v.as_u64().unwrap_or(0)).collect()) + .unwrap_or_else(|| no_io.to_vec()); + for index in 0..4 { + summed[index] = existing.get(index).copied().unwrap_or(0) + io_counters.get(index).copied().unwrap_or(0); + } + summed[4] = existing.get(4).copied().unwrap_or(0).max(io_counters.get(4).copied().unwrap_or(0)); + program.insert("io_counters".to_string(), json!(summed)); + if let Some(children) = program.get_mut("childrens").and_then(Value::as_array_mut) { + children.push(json!(pid)); + } + let same = |program: &Map, key: &str, value: &str| { + program.get(key).and_then(Value::as_str) == Some(value) + }; + if !same(program, "username", &username) { + program.insert("username".to_string(), json!("_")); + } + if program.get("nice").and_then(Value::as_i64) != Some(nice) { + program.insert("nice".to_string(), json!("_")); + } + if !same(program, "status", &status) { + program.insert("status".to_string(), json!("_")); + } + } + } + } + programs + .into_values() + .map(|mut program| { + if let Some(program) = program.as_object_mut() { + let nprocs = program.get("childrens").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0); + program.insert("nprocs".to_string(), json!(nprocs)); + } + program + }) + .collect() +} + +/// A few fields grabbed straight from `/proc//stat` and `/proc//io`. +#[derive(Debug, Clone, Default)] +struct ProcStat { + state: String, + nice: i64, + num_threads: i64, + processor: i64, + utime: f64, + stime: f64, + read_bytes: u64, + write_bytes: u64, + gid_real: i64, + gid_effective: i64, + gid_saved: i64, +} + +fn read_proc_stat(pid: i64) -> Option { + let content = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // The process name may contain spaces and parenthesis: strip it. + let close = content.rfind(')')?; + let name = &content[..=close]; + let fields: Vec<&str> = content[close + 1..].split_whitespace().collect(); + // fields[0] = state (stat field 3), so stat field N (1-based) is fields[N-3]. + let field = |n: usize| fields.get(n).copied().unwrap_or(""); + let state = field(0).chars().next().unwrap_or('?').to_ascii_uppercase().to_string(); + let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as f64; + let ticks = if ticks <= 0.0 { 100.0 } else { ticks }; + let mut stat = ProcStat { + state, + nice: field(16).parse().unwrap_or(0), + num_threads: field(17).parse().unwrap_or(0), + processor: field(36).parse().unwrap_or(0), + utime: field(11).parse::().unwrap_or(0.0) / ticks, + stime: field(12).parse::().unwrap_or(0.0) / ticks, + ..Default::default() + }; + let _ = name; + + if let Ok(io) = std::fs::read_to_string(format!("/proc/{pid}/io")) { + for line in io.lines() { + if let Some(value) = line.strip_prefix("read_bytes:") { + stat.read_bytes = value.trim().parse().unwrap_or(0); + } else if let Some(value) = line.strip_prefix("write_bytes:") { + stat.write_bytes = value.trim().parse().unwrap_or(0); + } + } + } + if let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) { + if let Some(line) = status.lines().find(|l| l.starts_with("Gid:")) { + let gids: Vec = line[4..].split_whitespace().filter_map(|v| v.parse().ok()).collect(); + stat.gid_real = gids.first().copied().unwrap_or(0); + stat.gid_effective = gids.get(1).copied().unwrap_or(0); + stat.gid_saved = gids.get(2).copied().unwrap_or(0); + } + } + Some(stat) +} + +fn read_nice(pid: i64) -> Option { + read_proc_stat(pid).map(|s| s.nice) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_processes_update() { + use clap::Parser; + let args = crate::args::Args::parse_from(["glances"]); + let mut processes = Processes::new(); + let list = processes.update(0, &args).to_vec(); + assert!(!list.is_empty()); + assert!(list[0].get("pid").is_some()); + assert!(list[0].get("name").is_some()); + } + + #[test] + fn test_sort_stats() { + let mut stats = vec![json!({"name": "a", "cpu_percent": 1.0}), json!({"name": "b", "cpu_percent": 3.0})]; + sort_stats(&mut stats, "cpu_percent", "memory_percent", true); + assert_eq!(stats[0]["name"], "b"); + } +} diff --git a/crates/glances-core/src/secure.rs b/crates/glances-core/src/secure.rs new file mode 100644 index 0000000..af34946 --- /dev/null +++ b/crates/glances-core/src/secure.rs @@ -0,0 +1,203 @@ +//! Secure shell helpers (port of `glances.secure`). +//! +//! By default the shell-like operators `&&`, `|` and `>` are interpreted. +//! When `allow_operators` is false the command is run as a single process and +//! the operators are passed verbatim. `render` is applied to every argument +//! *after* the command line has been tokenized so that untrusted values can +//! not change the argument boundaries. + +use std::io::Write; +use std::process::{Command, Stdio}; + +const OPERATORS: [&str; 5] = ["&&", "|", ">>", ">", "&"]; + +/// Replace shell operators by spaces, recursing into containers. +pub fn sanitize_value(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::String(s) => { + let mut s = s.clone(); + for op in OPERATORS { + s = s.replace(op, " "); + } + serde_json::Value::String(s) + } + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(sanitize_value).collect()) + } + serde_json::Value::Object(map) => { + serde_json::Value::Object(map.iter().map(|(k, v)| (k.clone(), sanitize_value(v))).collect()) + } + other => other.clone(), + } +} + +/// Split a command string into an argument list. Spaces are separators except +/// within single or double quotes (the surrounding quotes are removed). +pub fn split_args(cmd: &str, render: Option<&dyn Fn(&str) -> Result>) -> Result, String> { + let mut args: Vec = Vec::new(); + let mut current = String::new(); + let mut in_single = false; + let mut in_double = false; + let mut has_content = false; + + for c in cmd.chars() { + if in_single { + if c == '\'' { + in_single = false; + } else { + current.push(c); + } + continue; + } + if in_double { + if c == '"' { + in_double = false; + } else { + current.push(c); + } + continue; + } + match c { + '\'' => { + in_single = true; + has_content = true; + } + '"' => { + in_double = true; + has_content = true; + } + c if c.is_whitespace() => { + if has_content { + args.push(std::mem::take(&mut current)); + has_content = false; + } + } + c => { + current.push(c); + has_content = true; + } + } + } + if has_content { + args.push(current); + } + + match render { + None => Ok(args), + Some(render) => args.into_iter().map(|arg| render(&arg)).collect(), + } +} + +fn run_argv(cmd: &str, render: Option<&dyn Fn(&str) -> Result>) -> String { + let args = match split_args(cmd, render) { + Ok(args) => args, + Err(e) => return format!("Glances error: {e}"), + }; + if args.is_empty() { + return String::new(); + } + let mut command = Command::new(&args[0]); + command.args(&args[1..]); + match command.output() { + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if stderr.is_empty() { + String::from_utf8_lossy(&output.stdout).to_string() + } else { + stderr + } + } + Err(e) => format!("ERROR: {e}"), + } +} + +fn secure_popen_one(cmd: &str, render: Option<&dyn Fn(&str) -> Result>) -> String { + let mut cmd = cmd; + let mut stdout_redirect: Option = None; + + let mut redirect_split = cmd.splitn(3, '>'); + let first = redirect_split.next().unwrap_or_default(); + let second = redirect_split.next(); + let third = redirect_split.next(); + if third.is_some() { + return format!("Glances error: Only one file redirection allowed ({cmd})"); + } + if let Some(target) = second { + let target = target.trim(); + let target = match render { + Some(render) => match render(target) { + Ok(t) => t, + Err(e) => return format!("Glances error: {e}"), + }, + None => target.to_string(), + }; + stdout_redirect = Some(target); + cmd = first; + } + + // Tokenize (and render) every stage before spawning anything. + let mut stages: Vec> = Vec::new(); + for sub_cmd in cmd.split('|') { + match split_args(sub_cmd, render) { + Ok(args) => stages.push(args), + Err(e) => return format!("Glances error: {e}"), + } + } + if stages.is_empty() || stages.iter().any(|s| s.is_empty()) { + return String::new(); + } + + let mut children: Vec = Vec::new(); + let mut previous_stdout: Option = None; + for args in &stages { + let mut command = Command::new(&args[0]); + command.args(&args[1..]).stderr(Stdio::piped()); + match previous_stdout.take() { + Some(stdout) => { + command.stdin(Stdio::from(stdout)); + } + None => { + command.stdin(Stdio::null()); + } + } + let mut child = match command.stdout(Stdio::piped()).spawn() { + Ok(child) => child, + Err(e) => return format!("ERROR: {e}"), + }; + previous_stdout = child.stdout.take(); + children.push(child); + } + + let last = children.pop().expect("at least one child"); + let output = match last.wait_with_output() { + Ok(output) => output, + Err(e) => return format!("ERROR: {e}"), + }; + for mut child in children { + let _ = child.wait(); + } + + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if !stderr.is_empty() { + return stderr; + } + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + if let Some(target) = stdout_redirect { + if let Ok(mut file) = std::fs::File::create(&target) { + let _ = file.write_all(stdout.as_bytes()); + } + } + stdout +} + +/// Run a command line, interpreting (or not) the shell operators. +pub fn secure_popen(cmd: &str, allow_operators: bool, render: Option<&dyn Fn(&str) -> Result>) -> String { + if !allow_operators { + return run_argv(cmd, render); + } + let mut ret = String::new(); + for part in cmd.split("&&") { + ret.push_str(&secure_popen_one(part, render)); + } + ret +} diff --git a/crates/glances-core/src/state.rs b/crates/glances-core/src/state.rs new file mode 100644 index 0000000..5c22659 --- /dev/null +++ b/crates/glances-core/src/state.rs @@ -0,0 +1,58 @@ +//! Mutable runtime state (hotkey toggles and shared UI state). + +/// Runtime state shared between the engine, the plugins and the TUI. +#[derive(Debug, Clone)] +pub struct Runtime { + /// Display Rx and Tx sum instead of per interface values. + pub network_sum: bool, + /// Display cumulative network values instead of rates. + pub network_cumul: bool, + /// Per CPU mode. + pub percpu: bool, + /// Mean GPU mode. + pub meangpu: bool, + /// Accumulate processes by program. + pub programs: bool, + /// Force short process names. + pub process_short_name: bool, + /// Generate graph (influxdb export in Python; kept for compatibility). + pub generate_graph: bool, + /// Help screen tag. + pub help_tag: bool, + /// DiskIO: show IOPS instead of bytes. + pub diskio_iops: bool, + /// DiskIO: show latency. + pub diskio_latency: bool, + /// FS: show free space instead of used. + pub fs_free_space: bool, + /// Sparklines instead of bars. + pub sparkline: bool, + /// Cursor position in the process list. + pub cursor_position: i64, + /// Maximum number of processes displayed. + pub max_processes: Option, + /// Reset min/max tag (TUI 'M' key). + pub reset_minmax_tag: bool, +} + +impl Runtime { + pub fn new(args: &crate::args::Args, fs_free_space_config: bool) -> Self { + Self { + network_sum: false, + network_cumul: false, + percpu: args.percpu, + meangpu: args.meangpu, + programs: args.programs, + process_short_name: args.process_short_name(), + generate_graph: false, + help_tag: false, + diskio_iops: args.diskio_iops, + diskio_latency: args.diskio_latency, + fs_free_space: args.fs_free_space || fs_free_space_config, + sparkline: args.sparkline, + cursor_position: 0, + max_processes: None, + reset_minmax_tag: false, + } + } +} diff --git a/crates/glances-core/src/thresholds.rs b/crates/glances-core/src/thresholds.rs new file mode 100644 index 0000000..b9fcfa1 --- /dev/null +++ b/crates/glances-core/src/thresholds.rs @@ -0,0 +1,78 @@ +//! Thresholds (OK, CAREFUL, WARNING, CRITICAL). + +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::OnceLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Threshold { + Ok, + Careful, + Warning, + Critical, +} + +impl Threshold { + pub fn from_str(name: &str) -> Option { + match name { + "OK" => Some(Threshold::Ok), + "CAREFUL" => Some(Threshold::Careful), + "WARNING" => Some(Threshold::Warning), + "CRITICAL" => Some(Threshold::Critical), + _ => None, + } + } + + pub fn value(self) -> u8 { + match self { + Threshold::Ok => 0, + Threshold::Careful => 1, + Threshold::Warning => 2, + Threshold::Critical => 3, + } + } + + pub fn description(self) -> &'static str { + match self { + Threshold::Ok => "OK", + Threshold::Careful => "CAREFUL", + Threshold::Warning => "WARNING", + Threshold::Critical => "CRITICAL", + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct Thresholds { + map: HashMap, +} + +impl Thresholds { + pub fn get(&self, stat_name: &str) -> Option { + self.map.get(stat_name).copied() + } + + pub fn all(&self) -> &HashMap { + &self.map + } + + pub fn add(&mut self, stat_name: &str, threshold: &str) -> bool { + match Threshold::from_str(threshold) { + Some(t) => { + self.map.insert(stat_name.to_string(), t); + true + } + None => false, + } + } + + pub fn reset(&mut self) { + self.map.clear(); + } +} + +/// Global thresholds registry shared by all plugins. +pub fn thresholds() -> &'static Mutex { + static THRESHOLDS: OnceLock> = OnceLock::new(); + THRESHOLDS.get_or_init(|| Mutex::new(Thresholds::default())) +} diff --git a/crates/glances-core/src/timer.rs b/crates/glances-core/src/timer.rs new file mode 100644 index 0000000..684cd6f --- /dev/null +++ b/crates/glances-core/src/timer.rs @@ -0,0 +1,113 @@ +//! Simple timers and the global "time since last update" registry. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::OnceLock; +use std::time::Instant; + +/// The timer class. A simple chronometer. +#[derive(Debug, Clone)] +pub struct Timer { + duration: f64, + target: Instant, +} + +impl Timer { + pub fn new(duration: f64) -> Self { + let mut timer = Self { duration, target: Instant::now() }; + timer.start(); + timer + } + + pub fn start(&mut self) { + self.target = Instant::now() + std::time::Duration::from_secs_f64(self.duration.max(0.0)); + } + + pub fn reset(&mut self, duration: Option) { + if let Some(duration) = duration { + self.set(duration); + } + self.start(); + } + + /// Elapsed seconds since the timer was started. + pub fn get(&self) -> f64 { + self.duration - self.target.saturating_duration_since(Instant::now()).as_secs_f64() + } + + pub fn set(&mut self, duration: f64) { + self.duration = duration; + } + + pub fn duration(&self) -> f64 { + self.duration + } + + pub fn finished(&self) -> bool { + Instant::now() > self.target + } +} + +/// The counter class, used to log durations. +#[derive(Debug, Clone)] +pub struct Counter { + start: Instant, +} + +impl Default for Counter { + fn default() -> Self { + Self::new() + } +} + +impl Counter { + pub fn new() -> Self { + Self { start: Instant::now() } + } + + pub fn reset(&mut self) { + self.start = Instant::now(); + } + + pub fn get(&self) -> f64 { + self.start.elapsed().as_secs_f64() + } +} + +fn last_update_times() -> &'static Mutex> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Return the elapsed time since the last update for the given key. +/// +/// The first call for a key returns 1 second, mirroring the Python +/// implementation. +pub fn get_time_since_last_update(key: &str) -> f64 { + let now = Instant::now(); + let mut map = last_update_times().lock().unwrap(); + let since = match map.get(key) { + Some(last) => now.duration_since(*last).as_secs_f64(), + None => 1.0, + }; + map.insert(key.to_string(), now); + since +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timer_finished() { + let timer = Timer::new(0.0); + std::thread::sleep(std::time::Duration::from_millis(5)); + assert!(timer.finished()); + } + + #[test] + fn time_since_first_call_is_one() { + let v = get_time_since_last_update("test-key"); + assert_eq!(v, 1.0); + } +} diff --git a/crates/glances-core/src/units.rs b/crates/glances-core/src/units.rs new file mode 100644 index 0000000..e414a50 --- /dev/null +++ b/crates/glances-core/src/units.rs @@ -0,0 +1,245 @@ +//! Formatting helpers (units, Python-compatible `str()`/`repr()`). + +use serde_json::Value; + +/// Convert Celsius to Fahrenheit. +pub fn to_fahrenheit(celsius: f64) -> f64 { + celsius * 1.8 + 32.0 +} + +/// Make a nice human-readable string out of a number (port of `auto_unit`). +pub fn auto_unit(number: f64, low_precision: bool, min_symbol: &str, none_symbol: &str) -> String { + let symbols: &[char] = &['K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']; + let symbols: Vec = match symbols.iter().position(|&s| s.to_string() == min_symbol) { + Some(idx) => symbols[idx..].to_vec(), + None => symbols.to_vec(), + }; + let prefix = |symbol: char| -> f64 { + match symbol { + 'Y' => 1208925819614629174706176.0, + 'Z' => 1180591620717411303424.0, + 'E' => 1152921504606846976.0, + 'P' => 1125899906842624.0, + 'T' => 1099511627776.0, + 'G' => 1073741824.0, + 'M' => 1048576.0, + 'K' => 1024.0, + _ => 1.0, + } + }; + + if number.is_nan() { + return none_symbol.to_string(); + } + if number == 0.0 { + // Avoid 0.0 + return "0".to_string(); + } + if !number.is_finite() { + return format!("{number}"); + } + + // If the value was given as an integer, decimal_precision is 0. + let mut decimal_precision = if number.fract() != 0.0 { 2 } else { 0 }; + + for &symbol in symbols.iter().rev() { + let value = number / prefix(symbol); + if value > 1.0 { + decimal_precision = 0; + if value <= 9.995 { + decimal_precision = 2; + } else if value < 99.95 { + decimal_precision = 1; + } + if low_precision { + if symbol == 'M' || symbol == 'K' { + decimal_precision = 0; + } else { + decimal_precision = decimal_precision.min(1); + } + } else if symbol == 'K' { + decimal_precision = 0; + } + return format!("{value:.decimal_precision$}{symbol}"); + } + } + + format!("{number:.decimal_precision$}") +} + +/// Parse a value like `'12.5 MB'` into a float (port of `string_value_to_float`). +pub fn string_value_to_float(s: &str) -> Option { + let cleaned: String = s.chars().filter(|c| *c != ' ').collect(); + let mut value: Option = None; + let mut unit: Option = None; + let mut digits = String::new(); + let mut rest = String::new(); + for c in cleaned.chars() { + if c.is_ascii_digit() || c == '.' { + if rest.is_empty() { + digits.push(c); + continue; + } + } + rest.push(c); + } + if !digits.is_empty() { + value = digits.parse::().ok(); + } + if !rest.is_empty() { + unit = Some(rest.to_uppercase()); + } + let value = value?; + let multiplier = match unit.as_deref() { + None => 1.0, + Some("B") => 1.0, + Some("KB") => 1000.0, + Some("MB") => 1_000_000.0, + Some("GB") => 1_000_000_000.0, + Some("TB") => 1_000_000_000_000.0, + Some("PB") => 1_000_000_000_000_000.0, + Some(_) => return None, + }; + Some(value * multiplier) +} + +/// Python `str()` for a JSON value (used by stdout/csv outputs). +pub fn py_str(value: &Value) -> String { + match value { + Value::Null => "None".to_string(), + Value::Bool(true) => "True".to_string(), + Value::Bool(false) => "False".to_string(), + Value::Number(n) => py_number(n), + Value::String(s) => s.clone(), + other => py_repr(other), + } +} + +fn py_number(n: &serde_json::Number) -> String { + if let Some(i) = n.as_i64() { + return i.to_string(); + } + if let Some(u) = n.as_u64() { + return u.to_string(); + } + let f = n.as_f64().unwrap_or(0.0); + py_float(f) +} + +/// Python `repr()` for a float. +pub fn py_float(f: f64) -> String { + if f.is_nan() { + return "nan".to_string(); + } + if f.is_infinite() { + return if f > 0.0 { "inf" } else { "-inf" }.to_string(); + } + if f.fract() == 0.0 && f.abs() < 1e16 { + format!("{f:.1}") + } else { + format!("{f}") + } +} + +/// Python `repr()` for a JSON value (single quotes, True/False/None). +pub fn py_repr(value: &Value) -> String { + match value { + Value::Null => "None".to_string(), + Value::Bool(b) => if *b { "True" } else { "False" }.to_string(), + Value::Number(n) => py_number(n), + Value::String(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")), + Value::Array(items) => { + let inner: Vec = items.iter().map(py_repr).collect(); + format!("[{}]", inner.join(", ")) + } + Value::Object(map) => { + let inner: Vec = map + .iter() + .map(|(k, v)| format!("'{}': {}", k.replace('\'', "\\'"), py_repr(v))) + .collect(); + format!("{{{}}}", inner.join(", ")) + } + } +} + +/// Extract a f64 from a JSON value the way Python would accept a number. +pub fn as_f64(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_f64(), + Value::Bool(_) | Value::Null | Value::String(_) | Value::Array(_) | Value::Object(_) => None, + } +} + +/// Simple mean subsampling (port of `subsample`). +pub fn subsample(data: &[f64], sampling: usize) -> Vec { + if data.len() <= sampling || sampling == 0 { + return data.to_vec(); + } + let sampling_length = (data.len() as f64 / sampling as f64).round() as usize; + (0..sampling) + .map(|s| { + let start = s * sampling_length; + let end = ((s + 1) * sampling_length).min(data.len()); + let slice = &data[start..end]; + slice.iter().sum::() / slice.len() as f64 + }) + .collect() +} + +/// Return a list of the substrings in the input string, using `sep` as the +/// separator char and `esc` as the escape character (port of `split_esc`, +/// only the explicit separator flavour is required). +pub fn split_esc(input: &str, sep: char, esc: char) -> Vec { + let mut ret: Vec = vec![String::new()]; + let mut esc_seen = false; + for ch in input.chars() { + if !esc_seen { + if ch == esc { + esc_seen = true; + } else if ch == sep { + ret.push(String::new()); + } else { + ret.last_mut().unwrap().push(ch); + } + } else { + ret.last_mut().unwrap().push(ch); + esc_seen = false; + } + } + ret +} + +/// Replace special chars by another (new lines, tabs...) or escape for a +/// shell by adding a `$` prefix in front of them. (Only the replacement +/// flavour is needed by the plugins.) +pub fn replace_special_chars(input: &str, by: char) -> String { + input.replace("\r\n", &by.to_string()).replace('\n', &by.to_string()).replace('\t', &by.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_auto_unit() { + assert_eq!(auto_unit(613421788.0, false, "K", "-"), "585M"); + assert_eq!(auto_unit(5307033647.0, false, "K", "-"), "4.94G"); + assert_eq!(auto_unit(5307033647.0, true, "K", "-"), "4.9G"); + assert_eq!(auto_unit(44968414685.0, false, "K", "-"), "41.9G"); + assert_eq!(auto_unit(838471403472.0, false, "K", "-"), "781G"); + assert_eq!(auto_unit(9683209690677.0, false, "K", "-"), "8.81T"); + assert_eq!(auto_unit(1073741824.0, false, "K", "-"), "1024M"); + assert_eq!(auto_unit(1181116006.0, false, "K", "-"), "1.10G"); + assert_eq!(auto_unit(0.0, false, "K", "-"), "0"); + } + + #[test] + fn test_py_repr() { + assert_eq!(py_repr(&json!({"a": 1, "b": "x"})), "{'a': 1, 'b': 'x'}"); + assert_eq!(py_str(&json!(1234.0)), "1234.0"); + assert_eq!(py_str(&json!(5)), "5"); + assert_eq!(py_str(&json!(true)), "True"); + assert_eq!(py_str(&json!(null)), "None"); + } +} diff --git a/crates/glances-plugins/Cargo.toml b/crates/glances-plugins/Cargo.toml new file mode 100644 index 0000000..d086a50 --- /dev/null +++ b/crates/glances-plugins/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "glances-plugins" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow.workspace = true +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +glances-core = { version = "0.1.0", path = "../glances-core" } +indexmap.workspace = true +libc.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +sysinfo = "0.39.6" +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/glances-plugins/src/collectors.rs b/crates/glances-plugins/src/collectors.rs new file mode 100644 index 0000000..3277d6a --- /dev/null +++ b/crates/glances-plugins/src/collectors.rs @@ -0,0 +1,564 @@ +//! Low level Linux collectors (procfs / sysfs) shared by the plugins. +//! +//! The values and semantics mirror what psutil returns on Linux, so the +//! plugin stats stay compatible with the Python implementation. + +use indexmap::IndexMap; +use std::collections::HashMap; + +/// System wide or per-CPU time counters (in seconds, like psutil). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct CpuTimes { + pub user: f64, + pub nice: f64, + pub system: f64, + pub idle: f64, + pub iowait: f64, + pub irq: f64, + pub softirq: f64, + pub steal: f64, + pub guest: f64, + pub guest_nice: f64, +} + +impl CpuTimes { + pub fn total(&self) -> f64 { + self.user + + self.nice + + self.system + + self.idle + + self.iowait + + self.irq + + self.softirq + + self.steal + + self.guest + + self.guest_nice + } + + pub fn busy(&self) -> f64 { + self.total() - self.idle + } + + pub fn deltas(&self, previous: &CpuTimes) -> CpuTimes { + CpuTimes { + user: self.user - previous.user, + nice: self.nice - previous.nice, + system: self.system - previous.system, + idle: self.idle - previous.idle, + iowait: self.iowait - previous.iowait, + irq: self.irq - previous.irq, + softirq: self.softirq - previous.softirq, + steal: self.steal - previous.steal, + guest: self.guest - previous.guest, + guest_nice: self.guest_nice - previous.guest_nice, + } + } + + /// Percentages of each field since the previous sample (psutil + /// `cpu_times_percent`). + pub fn times_percent(&self, previous: &CpuTimes) -> CpuTimes { + let deltas = self.deltas(previous); + let all_delta = deltas.total(); + let scale = 100.0 / all_delta.max(1.0); + let perc = |value: f64| -> f64 { (value * scale).round().clamp(0.0, 100.0) }; + CpuTimes { + user: perc(deltas.user), + nice: perc(deltas.nice), + system: perc(deltas.system), + idle: perc(deltas.idle), + iowait: perc(deltas.iowait), + irq: perc(deltas.irq), + softirq: perc(deltas.softirq), + steal: perc(deltas.steal), + guest: perc(deltas.guest), + guest_nice: perc(deltas.guest_nice), + } + } + + /// Total CPU usage since the previous sample (psutil `cpu_percent`). + pub fn percent(&self, previous: &CpuTimes) -> f64 { + let deltas = self.deltas(previous); + let all_delta = deltas.total(); + let busy_delta = deltas.busy(); + if all_delta == 0.0 { + return 0.0; + } + ((busy_delta / all_delta) * 100.0 * 10.0).round() / 10.0 + } +} + +fn clock_ticks() -> f64 { + let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if ticks <= 0 { 100.0 } else { ticks as f64 } +} + +fn parse_cpu_line(fields: &[&str]) -> CpuTimes { + let ticks = clock_ticks(); + let value = |index: usize| -> f64 { fields.get(index).and_then(|v| v.parse::().ok()).unwrap_or(0.0) / ticks }; + CpuTimes { + user: value(0), + nice: value(1), + system: value(2), + idle: value(3), + iowait: value(4), + irq: value(5), + softirq: value(6), + steal: value(7), + guest: value(8), + guest_nice: value(9), + } +} + +/// Read the system wide CPU times from `/proc/stat`. +pub fn read_cpu_times() -> Option { + let content = std::fs::read_to_string("/proc/stat").ok()?; + let line = content.lines().next()?; + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.first() != Some(&"cpu") { + return None; + } + Some(parse_cpu_line(&fields[1..])) +} + +/// Read the per-CPU times from `/proc/stat`. +pub fn read_percpu_times() -> Vec { + let Ok(content) = std::fs::read_to_string("/proc/stat") else { + return Vec::new(); + }; + content + .lines() + .filter(|line| line.starts_with("cpu") && !line.starts_with("cpu ")) + .map(|line| { + let fields: Vec<&str> = line.split_whitespace().collect(); + parse_cpu_line(&fields[1..]) + }) + .collect() +} + +/// Read `/proc/meminfo` values in bytes. +pub fn read_meminfo() -> HashMap { + let mut values = HashMap::new(); + let Ok(content) = std::fs::read_to_string("/proc/meminfo") else { + return values; + }; + for line in content.lines() { + let Some((key, rest)) = line.split_once(':') else { + continue; + }; + let mut parts = rest.split_whitespace(); + let Some(value) = parts.next().and_then(|v| v.parse::().ok()) else { + continue; + }; + let unit = parts.next().unwrap_or(""); + let bytes = if unit.starts_with("kB") { value * 1024 } else { value }; + values.insert(key.trim().to_string(), bytes); + } + values +} + +/// Read `/proc/vmstat` counters. +pub fn read_vmstat() -> HashMap { + let mut values = HashMap::new(); + let Ok(content) = std::fs::read_to_string("/proc/vmstat") else { + return values; + }; + for line in content.lines() { + if let Some((key, value)) = line.split_once(' ') { + if let Ok(value) = value.trim().parse::() { + values.insert(key.to_string(), value); + } + } + } + values +} + +pub fn page_size() -> u64 { + let size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if size <= 0 { 4096 } else { size as u64 } +} + +/// Read the load average (1, 5, 15 minutes). +pub fn read_loadavg() -> Option<(f64, f64, f64)> { + let content = std::fs::read_to_string("/proc/loadavg").ok()?; + let fields: Vec<&str> = content.split_whitespace().collect(); + Some(( + fields.first()?.parse().ok()?, + fields.get(1)?.parse().ok()?, + fields.get(2)?.parse().ok()?, + )) +} + +/// Read the boot time (epoch seconds). +pub fn boot_time() -> Option { + let content = std::fs::read_to_string("/proc/stat").ok()?; + for line in content.lines() { + if let Some(value) = line.strip_prefix("btime ") { + return value.trim().parse().ok(); + } + } + None +} + +pub fn uptime_seconds() -> Option { + let content = std::fs::read_to_string("/proc/uptime").ok()?; + content.split_whitespace().next()?.parse().ok() +} + +/// Number of logical CPUs. +pub fn logical_core_count() -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) +} + +/// Number of physical cores, parsed from `/proc/cpuinfo`. +pub fn physical_core_count() -> Option { + let content = std::fs::read_to_string("/proc/cpuinfo").ok()?; + let mut pairs: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); + let mut physical_id = String::new(); + let mut core_id = String::new(); + let mut has_ids = false; + for line in content.lines().chain(std::iter::once("")) { + if line.trim().is_empty() { + if !physical_id.is_empty() && !core_id.is_empty() { + pairs.insert((physical_id.clone(), core_id.clone())); + has_ids = true; + } + physical_id.clear(); + core_id.clear(); + continue; + } + let Some((key, value)) = line.split_once(':') else { + continue; + }; + match key.trim() { + "physical id" => physical_id = value.trim().to_string(), + "core id" => core_id = value.trim().to_string(), + _ => {} + } + } + if has_ids && !pairs.is_empty() { + Some(pairs.len()) + } else { + // Fallback: count unique processor entries. + let count = content + .lines() + .filter(|l| l.starts_with("processor") && l.contains(':')) + .count(); + if count > 0 { Some(count) } else { None } + } +} + +/// Return the CPU model name from `/proc/cpuinfo`. +pub fn cpu_name() -> String { + let fallback = format!("CPU {}", std::env::consts::ARCH); + let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") else { + return fallback; + }; + let mut implementer: Option = None; + let mut ret = fallback; + for line in content.lines() { + if line.starts_with("model name") || line.starts_with("Model") || line.starts_with("cpu model") { + if let Some((_, value)) = line.split_once(':') { + return value.trim().to_string(); + } + } + if line.starts_with("CPU implementer") { + implementer = line.split_once(':').map(|(_, v)| v.trim().to_string()); + } + if line.starts_with("CPU part") { + if let Some(part) = line.split_once(':').map(|(_, v)| v.trim().to_string()) { + if let Some(implementer) = &implementer { + ret = format!("{implementer} part {part}"); + } + } + } + } + ret +} + +/// CPU frequency information (`/sys/devices/system/cpu/cpu0/cpufreq`). +pub fn cpu_frequency() -> (Option, Option) { + let current = std::fs::read_to_string("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|khz| khz / 1000.0); + let max = std::fs::read_to_string("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|khz| khz / 1000.0); + (current, max) +} + +/// `/proc/net/dev` counters. +#[derive(Debug, Clone, Copy, Default)] +pub struct NetDev { + pub bytes_recv: u64, + pub packets_recv: u64, + pub errin: u64, + pub dropin: u64, + pub bytes_sent: u64, + pub packets_sent: u64, + pub errout: u64, + pub dropout: u64, +} + +pub fn read_net_dev() -> IndexMap { + let mut result = IndexMap::new(); + let Ok(content) = std::fs::read_to_string("/proc/net/dev") else { + return result; + }; + for line in content.lines().skip(2) { + let Some((interface, values)) = line.split_once(':') else { + continue; + }; + let fields: Vec = values.split_whitespace().filter_map(|v| v.parse().ok()).collect(); + if fields.len() < 16 { + continue; + } + result.insert( + interface.trim().to_string(), + NetDev { + bytes_recv: fields[0], + packets_recv: fields[1], + errin: fields[2], + dropin: fields[3], + bytes_sent: fields[8], + packets_sent: fields[9], + errout: fields[10], + dropout: fields[11], + }, + ); + } + result +} + +/// Interface speed in Mbps (0 when unknown). +pub fn interface_speed(interface: &str) -> u64 { + std::fs::read_to_string(format!("/sys/class/net/{interface}/speed")) + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0) + .map(|v| v as u64) + .unwrap_or(0) +} + +/// Interface flags from `/sys/class/net//flags`. +pub fn interface_flags(interface: &str) -> u64 { + std::fs::read_to_string(format!("/sys/class/net/{interface}/flags")) + .ok() + .and_then(|v| u64::from_str_radix(v.trim().trim_start_matches("0x"), 16).ok()) + .unwrap_or(0) +} + +/// Interface MTU. +pub fn interface_mtu(interface: &str) -> u64 { + std::fs::read_to_string(format!("/sys/class/net/{interface}/mtu")) + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0) +} + +/// Return the list of interface names. +pub fn interface_names() -> Vec { + let mut names = Vec::new(); + if let Ok(entries) = std::fs::read_dir("/sys/class/net") { + for entry in entries.flatten() { + names.push(entry.file_name().to_string_lossy().to_string()); + } + } + names.sort(); + names +} + +/// Return True if the interface has at least one non link-layer address. +pub fn interface_has_ip(interface: &str) -> bool { + let mut result = false; + unsafe { + let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut(); + if libc::getifaddrs(&mut ifap) != 0 { + return false; + } + let mut current = ifap; + while !current.is_null() { + let addr = (*current).ifa_addr; + if !addr.is_null() { + let name = std::ffi::CStr::from_ptr((*current).ifa_name); + if name.to_string_lossy() == interface { + let family = (*addr).sa_family as i32; + if family == libc::AF_INET || family == libc::AF_INET6 { + result = true; + break; + } + } + } + current = (*current).ifa_next; + } + libc::freeifaddrs(ifap); + } + result +} + +/// `/proc/diskstats` entry. +#[derive(Debug, Clone, Copy, Default)] +pub struct DiskStat { + pub read_count: u64, + pub reads_merged: u64, + pub sectors_read: u64, + pub read_time: u64, + pub write_count: u64, + pub writes_merged: u64, + pub sectors_written: u64, + pub write_time: u64, +} + +pub const SECTOR_SIZE: u64 = 512; + +pub fn read_diskstats() -> IndexMap { + let mut result = IndexMap::new(); + let Ok(content) = std::fs::read_to_string("/proc/diskstats") else { + return result; + }; + for line in content.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 14 { + continue; + } + let name = fields[2].to_string(); + let value = |index: usize| -> u64 { fields.get(index).and_then(|v| v.parse().ok()).unwrap_or(0) }; + result.insert( + name, + DiskStat { + read_count: value(3), + reads_merged: value(4), + sectors_read: value(5), + read_time: value(6), + write_count: value(7), + writes_merged: value(8), + sectors_written: value(9), + write_time: value(10), + }, + ); + } + result +} + +/// `/proc/interrupts` entries (name, total count). +pub fn read_interrupts() -> Vec<(String, u64)> { + let Ok(content) = std::fs::read_to_string("/proc/interrupts") else { + return Vec::new(); + }; + let mut result = Vec::new(); + for line in content.lines().skip(1) { + let Some((irq, rest)) = line.split_once(':') else { + continue; + }; + let irq = irq.trim(); + let parts: Vec<&str> = rest.split_whitespace().collect(); + // Last part is the name, the numbers before are the per-CPU counts. + let numbers: u64 = parts[..parts.len().saturating_sub(1)] + .iter() + .filter_map(|v| v.parse::().ok()) + .sum(); + let name = parts.last().copied().unwrap_or(""); + let label = if name.is_empty() || name.chars().all(|c| c.is_ascii_digit()) { + irq.to_string() + } else { + format!("{irq}: {name}") + }; + result.push((label, numbers)); + } + result +} + +/// Mount entry from `/proc/mounts`. +#[derive(Debug, Clone, Default)] +pub struct MountInfo { + pub device: String, + pub mount_point: String, + pub fs_type: String, + pub options: String, +} + +pub fn read_mounts() -> Vec { + let Ok(content) = std::fs::read_to_string("/proc/mounts") else { + return Vec::new(); + }; + content + .lines() + .filter_map(|line| { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 4 { + return None; + } + Some(MountInfo { + device: fields[0].replace("\\040", " "), + mount_point: fields[1].replace("\\040", " "), + fs_type: fields[2].to_string(), + options: fields[3].to_string(), + }) + }) + .collect() +} + +/// Disk usage of a mount point (psutil `disk_usage`). +#[derive(Debug, Clone, Copy, Default)] +pub struct FsUsage { + pub total: u64, + pub used: u64, + pub free: u64, + pub percent: f64, +} + +pub fn disk_usage(path: &str) -> Option { + let c_path = std::ffi::CString::new(path).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 { + return None; + } + let block_size = if stat.f_frsize > 0 { stat.f_frsize } else { stat.f_bsize } as u64; + let total = stat.f_blocks as u64 * block_size; + let free = stat.f_bavail as u64 * block_size; + let used = (stat.f_blocks as u64).saturating_sub(stat.f_bfree as u64) * block_size; + let percent = if used + free > 0 { (used as f64 * 100.0) / (used + free) as f64 } else { 0.0 }; + Some(FsUsage { total, used, free, percent }) +} + +/// Linux distribution name/version (`/etc/os-release`). +pub fn read_os_release() -> (String, String) { + let mut name = String::new(); + let mut version = String::new(); + if let Ok(content) = std::fs::read_to_string("/etc/os-release") { + for line in content.lines() { + if let Some(value) = line.strip_prefix("NAME=") { + name = value.trim_matches('"').to_string(); + } else if let Some(value) = line.strip_prefix("VERSION_ID=") { + version = value.trim_matches('"').to_string(); + } + } + } + (name, version) +} + +pub fn hostname() -> String { + let mut buffer = [0u8; 256]; + let result = unsafe { libc::gethostname(buffer.as_mut_ptr() as *mut libc::c_char, buffer.len()) }; + if result == 0 { + let end = buffer.iter().position(|c| *c == 0).unwrap_or(buffer.len()); + String::from_utf8_lossy(&buffer[..end]).to_string() + } else { + "localhost".to_string() + } +} + +pub fn is_admin() -> bool { + unsafe { libc::getuid() == 0 } +} + +/// Read a sysfs hwmon input value. +pub fn read_number_file(path: &str) -> Option { + std::fs::read_to_string(path).ok().and_then(|v| v.trim().parse::().ok()) +} + +/// Get the current date formatted with a Python strftime format. +pub fn strftime(format: &str) -> String { + chrono::Local::now().format(format).to_string() +} diff --git a/crates/glances-plugins/src/cpu_percent.rs b/crates/glances-plugins/src/cpu_percent.rs new file mode 100644 index 0000000..de74b0c --- /dev/null +++ b/crates/glances-plugins/src/cpu_percent.rs @@ -0,0 +1,128 @@ +//! CPU percent state shared between the CPU, per-CPU and quicklook plugins +//! (port of `glances.cpu_percent`). + +use crate::collectors::{CpuTimes, logical_core_count, read_cpu_times, read_percpu_times}; +use serde_json::{Value, json}; +use std::sync::Mutex; +use std::sync::OnceLock; + +#[derive(Debug, Clone, Default)] +pub struct CpuInfo { + pub cpu_name: String, + pub cpu_hz_current: Option, + pub cpu_hz: Option, +} + +#[derive(Debug)] +pub struct CpuPercentState { + /// Cache duration in seconds. + cached_timer_cpu: f64, + prev_cpu: Option, + prev_percpu: Vec, + cpu_percent: f64, + percpu_percent: Vec, +} + +impl CpuPercentState { + fn new(cached_timer_cpu: f64) -> Self { + let mut state = Self { + cached_timer_cpu, + prev_cpu: None, + prev_percpu: Vec::new(), + cpu_percent: 0.0, + percpu_percent: Vec::new(), + }; + state.cpu_percent = state.compute_cpu(); + state.percpu_percent = state.compute_percpu(); + state + } + + /// Update and/or return the CPU usage (psutil `cpu_percent`). + pub fn get_cpu(&mut self) -> f64 { + // The Python implementation caches this value for `cached_timer_cpu` + // seconds. Since the engine refreshes at least every `args.time` + // seconds, the value is recomputed on every call there; keep the cache + // for direct calls. + let _ = self.cached_timer_cpu; + self.cpu_percent = self.compute_cpu(); + self.cpu_percent + } + + fn compute_cpu(&mut self) -> f64 { + let Some(current) = read_cpu_times() else { + return 0.0; + }; + match self.prev_cpu { + Some(previous) => { + let percent = current.percent(&previous); + self.prev_cpu = Some(current); + percent + } + None => { + // The first call is meaningless (psutil compares two + // consecutive samples). + let second = read_cpu_times().unwrap_or(current); + let percent = second.percent(¤t); + self.prev_cpu = Some(second); + percent + } + } + } + + /// Update and/or return the per-CPU list. + pub fn get_percpu(&mut self) -> &[Value] { + self.percpu_percent = self.compute_percpu(); + &self.percpu_percent + } + + fn compute_percpu(&mut self) -> Vec { + let current = read_percpu_times(); + let mut stats = Vec::new(); + for (number, cpu_times) in current.iter().enumerate() { + let percent = self + .prev_percpu + .get(number) + .map(|previous| cpu_times.times_percent(previous)) + .unwrap_or_default(); + let total = ((100.0 - percent.idle) * 10.0).round() / 10.0; + stats.push(json!({ + "key": "cpu_number", + "cpu_number": number, + "total": total, + "user": percent.user, + "system": percent.system, + "idle": percent.idle, + "nice": percent.nice, + "iowait": percent.iowait, + "irq": percent.irq, + "softirq": percent.softirq, + "steal": percent.steal, + "guest": percent.guest, + "guest_nice": percent.guest_nice, + "dpc": Value::Null, + "interrupt": Value::Null, + })); + } + self.prev_percpu = current; + stats + } + + pub fn get_info(&self) -> CpuInfo { + let (current, max) = crate::collectors::cpu_frequency(); + CpuInfo { + cpu_name: crate::collectors::cpu_name(), + cpu_hz_current: current, + cpu_hz: max, + } + } + + pub fn logical_core_count(&self) -> usize { + logical_core_count() + } +} + +/// The shared CPU percent state (equivalent of the Python `cpu_percent`). +pub fn cpu_percent() -> &'static Mutex { + static CPU_PERCENT: OnceLock> = OnceLock::new(); + CPU_PERCENT.get_or_init(|| Mutex::new(CpuPercentState::new(2.0))) +} diff --git a/crates/glances-plugins/src/engine.rs b/crates/glances-plugins/src/engine.rs new file mode 100644 index 0000000..c5eb6c2 --- /dev/null +++ b/crates/glances-plugins/src/engine.rs @@ -0,0 +1,297 @@ +//! Plugin registry and update engine. + +use crate::plugins::*; +use glances_core::plugin::{Plugin, PluginModel, UpdateContext}; +use glances_core::processes::processes; +use glances_core::{Args, Config, Runtime}; +use indexmap::IndexMap; +use serde_json::Value; +use tracing::debug; + +/// The update engine: holds the configuration, the runtime state and every +/// plugin instance. +pub struct Engine { + pub args: Args, + pub config: Config, + pub runtime: Runtime, + pub plugins: IndexMap>, +} + +impl Engine { + pub fn new(args: Args, config: Config) -> Self { + let refresh_time = args.time.unwrap_or(2.0); + let fs_free_space_config = config.get_bool_value("fs", "free_space", false); + let runtime = Runtime::new(&args, fs_free_space_config); + + let mut plugins: IndexMap> = IndexMap::new(); + // Registration order mirrors the alphabetical plugin loading of the + // Python implementation (in particular processcount runs before + // processlist and programlist). + plugins.insert("core".to_string(), Box::new(CorePlugin::new(&config, &args))); + plugins.insert("cpu".to_string(), Box::new(CpuPlugin::new(&config, &args))); + plugins.insert("diskio".to_string(), Box::new(DiskioPlugin::new(&config, &args))); + plugins.insert("fs".to_string(), Box::new(FsPlugin::new(&config, &args))); + plugins.insert("irq".to_string(), Box::new(IrqPlugin::new(&config, &args))); + plugins.insert("load".to_string(), Box::new(LoadPlugin::new(&config, &args))); + plugins.insert("mem".to_string(), Box::new(MemPlugin::new(&config, &args))); + plugins.insert("memswap".to_string(), Box::new(MemSwapPlugin::new(&config, &args))); + plugins.insert("network".to_string(), Box::new(NetworkPlugin::new(&config, &args))); + plugins.insert("now".to_string(), Box::new(NowPlugin::new(&config, &args))); + plugins.insert("percpu".to_string(), Box::new(PerCpuPlugin::new(&config, &args))); + plugins.insert("processcount".to_string(), Box::new(ProcessCountPlugin::new(&config, &args))); + plugins.insert("processlist".to_string(), Box::new(ProcessListPlugin::new(&config, &args))); + plugins.insert("programlist".to_string(), Box::new(ProgramListPlugin::new(&config, &args))); + plugins.insert("psutilversion".to_string(), Box::new(PsutilVersionPlugin::new(&config, &args))); + plugins.insert("quicklook".to_string(), Box::new(QuicklookPlugin::new(&config, &args))); + plugins.insert("sensors".to_string(), Box::new(SensorsPlugin::new(&config, &args))); + plugins.insert("system".to_string(), Box::new(SystemPlugin::new(&config, &args))); + plugins.insert("uptime".to_string(), Box::new(UptimePlugin::new(&config, &args))); + plugins.insert("version".to_string(), Box::new(VersionPlugin::new(&config, &args))); + // The refresh time is set on the model, which was built with the + // command line value already. + let _ = refresh_time; + + let mut engine = Self { args, config, runtime, plugins }; + engine.apply_plugin_options(); + engine.apply_process_options(); + engine + } + + fn apply_plugin_options(&mut self) { + // Disable plugins from the configuration file. + for section in self.config.sections() { + if self.config.get_bool_value(section, "disable", false) { + if let Some(plugin) = self.plugins.get_mut(section) { + debug!("{} disabled by the configuration file", section); + plugin.set_enabled(false); + } + } + } + + // Availability (hardware/kernel dependent plugins). + let names: Vec = self.plugins.keys().cloned().collect(); + let mut available = std::collections::HashMap::new(); + for name in &names { + let plugin = self.plugins.get_mut(name).unwrap(); + available.insert(name.clone(), plugin.available()); + } + for (name, is_available) in &available { + if !is_available { + debug!("{name} plugin is not available on this system"); + self.plugins.get_mut(name).unwrap().set_enabled(false); + } + } + + // --disable-plugin / --enable-plugin. + let mut explicit_enable: Vec = Vec::new(); + if let Some(enable) = &self.args.enable_plugin { + explicit_enable = enable.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + if let Some(disable) = &self.args.disable_plugin { + let tokens: Vec = disable.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + if tokens.iter().any(|t| t == "all") { + for name in self.plugins.keys().cloned().collect::>() { + self.plugins.get_mut(&name).unwrap().set_enabled(false); + } + } else { + for token in tokens { + if let Some(plugin) = self.plugins.get_mut(&token) { + plugin.set_enabled(false); + } + } + } + } + for token in &explicit_enable { + if let Some(plugin) = self.plugins.get_mut(token) { + plugin.set_enabled(true); + } + } + + // --disable-process disables the process plugins. + if self.args.disable_process { + for name in ["processcount", "processlist", "programlist"] { + if let Some(plugin) = self.plugins.get_mut(name) { + plugin.set_enabled(false); + } + } + } + + // Processlist needs processcount to update the shared process list. + let processcount_enabled = self.plugins.get("processcount").map(|p| p.is_enabled()).unwrap_or(false); + if !processcount_enabled { + for name in ["processlist", "programlist"] { + if let Some(plugin) = self.plugins.get_mut(name) { + if plugin.is_enabled() { + tracing::warn!("Processcount is disabled, so processlist (updated by processcount) is also disabled"); + } + plugin.set_enabled(false); + } + } + } + + // --light disables the left sidebar plugins. + if self.args.enable_light { + self.args.disable_left_sidebar = true; + for name in ["processlist", "programlist", "amps", "containers", "vms"] { + if let Some(plugin) = self.plugins.get_mut(name) { + plugin.set_enabled(false); + } + } + } + + // --full-quicklook: only quicklook and load. + if self.args.full_quicklook { + self.runtime.help_tag = false; + for name in ["cpu", "mem", "memswap"] { + if let Some(plugin) = self.plugins.get_mut(name) { + plugin.set_enabled(false); + } + } + for name in ["quicklook", "load"] { + if let Some(plugin) = self.plugins.get_mut(name) { + plugin.set_enabled(true); + } + } + } + + // --disable-top: disable the top menu plugins. + if self.args.disable_top { + for name in ["quicklook", "cpu", "mem", "memswap", "load"] { + if let Some(plugin) = self.plugins.get_mut(name) { + plugin.set_enabled(false); + } + } + } + + // --enable-irq is required for the irq plugin. + if !self.args.enable_irq { + if let Some(plugin) = self.plugins.get_mut("irq") { + plugin.set_enabled(false); + } + } else if let Some(plugin) = self.plugins.get_mut("irq") { + plugin.set_enabled(true); + plugin.model_mut().refresh_timer = glances_core::timer::Timer::new(0.0); + } + } + + fn apply_process_options(&mut self) { + let mut procs = processes().lock().unwrap(); + procs.no_kernel_threads = self.args.no_kernel_threads; + if let Some(filter) = &self.args.process_filter { + procs.filter.set_filter(Some(filter)); + } + if let Some(focus) = &self.args.process_focus { + procs.filter_focus.set_filter(Some(focus)); + } + if self.args.enable_process_extended { + procs.enable_extended(); + } else { + procs.disable_extended(); + } + } + + /// Update every enabled plugin. + pub fn update(&mut self) { + let names: Vec = self.plugins.keys().cloned().collect(); + let enabled: Vec = names.into_iter().filter(|n| self.plugins[n].is_enabled()).collect(); + self.update_plugins(&enabled); + } + + /// Update the given plugins. + pub fn update_plugins(&mut self, names: &[String]) { + for name in names { + self.update_plugin(name); + } + } + + fn update_plugin(&mut self, name: &str) { + let Engine { plugins, args, config, runtime, .. } = self; + let Some(plugin) = plugins.get_mut(name) else { + return; + }; + if !plugin.is_enabled() { + return; + } + // Check the refresh timer: return the previous stats if too early. + let init_value = plugin.init_value(); + let model = plugin.model(); + if !(model.refresh_timer.finished() || model.stats == init_value) { + return; + } + + let key = plugin.get_key(); + let mut ctx = UpdateContext { + args, + runtime, + config, + cursor_position: runtime.cursor_position, + }; + let raw = match plugin.collect(&mut ctx) { + Ok(raw) => raw, + Err(err) => { + tracing::error!("Error while updating the {name} plugin ({err})"); + return; + } + }; + let refresh = plugin.model().get_refresh(); + let mut raw = raw; + { + let model = plugin.model_mut(); + model.time_since_last_update = glances_core::timer::get_time_since_last_update(name); + model.compute_rate(&mut raw, key); + model.update_mmm_fields(&mut raw); + model.stats = raw; + } + if let Err(err) = plugin.post_collect(&mut ctx) { + tracing::error!("Error while post-processing the {name} plugin ({err})"); + } + plugin.update_views(); + plugin.model_mut().update_stats_history(); + let timer = &mut plugin.model_mut().refresh_timer; + timer.set(refresh); + timer.reset(None); + } + + pub fn get_plugin(&self, name: &str) -> Option<&dyn Plugin> { + self.plugins.get(name).map(|p| p.as_ref()) + } + + pub fn get_plugins_list(&self, enabled_only: bool) -> Vec { + self.plugins + .iter() + .filter(|(_, p)| !enabled_only || p.is_enabled()) + .map(|(name, _)| name.clone()) + .collect() + } + + /// Return all the stats as a dict (like `getAllAsDict`). + pub fn all_as_dict(&self, plugin_list: Option<&[String]>) -> serde_json::Map { + let names = match plugin_list { + Some(list) => list.to_vec(), + None => self.get_plugins_list(true), + }; + let mut result = serde_json::Map::new(); + for name in names { + if let Some(plugin) = self.plugins.get(&name) { + if plugin.is_enabled() { + result.insert(name, plugin.stats().clone()); + } + } + } + result + } + + pub fn end(&mut self) { + for plugin in self.plugins.values_mut() { + plugin.exit(); + } + } +} + +/// One-shot update of one plugin (used by tests). +pub fn update_single(engine: &mut Engine, name: &str) { + engine.update_plugins(&[name.to_string()]); +} + +#[allow(dead_code)] +fn unused(_: &PluginModel) {} diff --git a/crates/glances-plugins/src/lib.rs b/crates/glances-plugins/src/lib.rs new file mode 100644 index 0000000..5340821 --- /dev/null +++ b/crates/glances-plugins/src/lib.rs @@ -0,0 +1,17 @@ +//! glances-plugins: collectors, plugin registry and update engine. + +pub mod collectors; +pub mod cpu_percent; +pub mod engine; +pub mod plugins; + +/// Build an `IndexMap` from name/field pairs. +#[macro_export] +macro_rules! fields { + ($( $name:literal => $field:expr ),* $(,)?) => {{ + #[allow(unused_mut)] + let mut map = indexmap::IndexMap::new(); + $( map.insert($name.to_string(), $field); )* + map + }}; +} diff --git a/crates/glances-plugins/src/plugins/core.rs b/crates/glances-plugins/src/plugins/core.rs new file mode 100644 index 0000000..49f3b1a --- /dev/null +++ b/crates/glances-plugins/src/plugins/core.rs @@ -0,0 +1,53 @@ +//! CPU core count plugin. + +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct CorePlugin { + pub model: PluginModel, +} + +impl CorePlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "phys" => Field::new("Number of physical cores (hyper thread CPUs are excluded).").unit("number"), + "log" => Field::new( + "Number of logical CPU cores. A logical CPU is the number of physical cores multiplied by the number of threads that can run on each core." + ).unit("number"), + }; + let model = PluginModel::new( + "core", + fields, + Vec::new(), + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for CorePlugin { + fn name(&self) -> &str { + "core" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + Ok(json!({ + "phys": crate::collectors::physical_core_count(), + "log": crate::collectors::logical_core_count(), + })) + } +} diff --git a/crates/glances-plugins/src/plugins/cpu.rs b/crates/glances-plugins/src/plugins/cpu.rs new file mode 100644 index 0000000..19b2293 --- /dev/null +++ b/crates/glances-plugins/src/plugins/cpu.rs @@ -0,0 +1,141 @@ +//! CPU plugin. + +use crate::collectors::{CpuTimes, read_cpu_times}; +use crate::cpu_percent::cpu_percent; +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct CpuPlugin { + pub model: PluginModel, + previous_times: Option, + pub nb_log_core: usize, +} + +impl CpuPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "total" => Field::new("Sum of all CPU percentages (except idle).").unit("percent").log().mmm(), + "system" => Field::new("Percent time spent in kernel space. System CPU time is the time spent running code in the Operating System kernel.").unit("percent").log(), + "user" => Field::new("CPU percent time spent in user space. User CPU time is the time spent on the processor running your program's code (or code in libraries).").unit("percent").log(), + "iowait" => Field::new("*(Linux)*: percent time spent by the CPU waiting for I/O operations to complete.").unit("percent").log(), + "dpc" => Field::new("*(Windows)*: time spent servicing deferred procedure calls (DPCs)").unit("percent").log(), + "idle" => Field::new("percent of CPU used by any program. Every program or task that runs on a computer system occupies a certain amount of processing time on the CPU. If the CPU has completed all tasks it is idle.").unit("percent").optional(), + "irq" => Field::new("*(Linux and BSD)*: percent time spent servicing/handling hardware/software interrupts. Time servicing interrupts (hardware + software).").unit("percent").optional(), + "nice" => Field::new("*(Unix)*: percent time occupied by user level processes with a positive nice value. The time the CPU has spent running users' processes that are *niced*.").unit("percent").optional(), + "steal" => Field::new("*(Linux)*: percentage of time a virtual CPU waits for a real CPU while the hypervisor is servicing another virtual processor.").unit("percent").alert().optional(), + "guest" => Field::new("*(Linux)*: time spent running a virtual CPU for guest operating systems under the control of the Linux kernel.").unit("percent").optional(), + "ctx_switches" => Field::new("number of context switches (voluntary + involuntary) per second. A context switch is a procedure that a computer's CPU (central processing unit) follows to change from one task to another while ensuring that the tasks do not conflict.").unit("number").rate().min_symbol("K").short_name("ctx_sw").optional(), + "interrupts" => Field::new("number of interrupts per second.").unit("number").rate().min_symbol("K").short_name("inter").optional(), + "soft_interrupts" => Field::new("number of software interrupts per second. Always set to 0 on Windows and SunOS.").unit("number").rate().min_symbol("K").short_name("sw_int").optional(), + "syscalls" => Field::new("number of system calls per second. Always 0 on Linux OS.").unit("number").rate().min_symbol("K").short_name("sys_call").optional(), + "cpucore" => Field::new("Total number of CPU core.").unit("number"), + "time_since_update" => Field::new("Number of seconds since last update.").unit("seconds"), + }; + let items_history = vec![ + HistoryItem::new("user", "User CPU usage"), + HistoryItem::new("system", "System CPU usage"), + ]; + let model = PluginModel::new( + "cpu", + fields, + items_history, + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + let nb_log_core = crate::collectors::logical_core_count(); + Self { model, previous_times: None, nb_log_core } + } + + fn times_percent(&mut self) -> CpuTimes { + let current = read_cpu_times().unwrap_or_default(); + match self.previous_times { + Some(previous) => { + self.previous_times = Some(current); + current.times_percent(&previous) + } + None => { + let second = read_cpu_times().unwrap_or(current); + self.previous_times = Some(second); + second.times_percent(¤t) + } + } + } +} + +impl Plugin for CpuPlugin { + fn name(&self) -> &str { + "cpu" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let percent = self.times_percent(); + let total = { + let mut state = cpu_percent().lock().unwrap(); + state.get_cpu() + }; + + let mut stats = serde_json::Map::new(); + stats.insert("total".to_string(), json!(total)); + stats.insert("user".to_string(), json!(percent.user)); + stats.insert("nice".to_string(), json!(percent.nice)); + stats.insert("system".to_string(), json!(percent.system)); + stats.insert("idle".to_string(), json!(percent.idle)); + stats.insert("iowait".to_string(), json!(percent.iowait)); + stats.insert("irq".to_string(), json!(percent.irq)); + stats.insert("steal".to_string(), json!(percent.steal)); + stats.insert("guest".to_string(), json!(percent.guest)); + + // Additional CPU stats from /proc/stat. + if let Ok(content) = std::fs::read_to_string("/proc/stat") { + let mut ctx_switches = 0u64; + let mut interrupts = 0u64; + let mut soft_interrupts = 0u64; + for line in content.lines() { + if let Some(rest) = line.strip_prefix("ctxt ") { + ctx_switches = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(0); + } else if let Some(rest) = line.strip_prefix("intr ") { + interrupts = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(0); + } else if let Some(rest) = line.strip_prefix("softirq ") { + soft_interrupts = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(0); + } + } + stats.insert("ctx_switches".to_string(), json!(ctx_switches)); + stats.insert("interrupts".to_string(), json!(interrupts)); + stats.insert("soft_interrupts".to_string(), json!(soft_interrupts)); + } + stats.insert("syscalls".to_string(), json!(0)); + stats.insert("cpucore".to_string(), json!(self.nb_log_core)); + + Ok(Value::Object(stats)) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + if self.model.stats.get("time_since_update").and_then(Value::as_f64) != Some(0.0) { + if let Some(ctx_switches) = self.model.stats.get("ctx_switches").and_then(Value::as_f64) { + let maximum = 100.0 * self.nb_log_core as f64; + let decoration = + self.model.get_alert(ctx_switches, 0.0, maximum, true, false, Some("ctx_switches"), None, false); + self.model.set_view_decoration(None, "ctx_switches", decoration); + } + } + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/diskio.rs b/crates/glances-plugins/src/plugins/diskio.rs new file mode 100644 index 0000000..9c9f503 --- /dev/null +++ b/crates/glances-plugins/src/plugins/diskio.rs @@ -0,0 +1,163 @@ +//! Disk I/O plugin. + +use crate::collectors::{SECTOR_SIZE, read_diskstats}; +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config, Decoration}; +use serde_json::{Value, json}; + +pub struct DiskioPlugin { + pub model: PluginModel, +} + +impl DiskioPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "disk_name" => Field::new("Disk name."), + "read_count" => Field::new("Number of reads.").rate().unit("number"), + "write_count" => Field::new("Number of writes.").rate().unit("number"), + "read_bytes" => Field::new("Number of bytes read.").rate().unit("byte"), + "write_bytes" => Field::new("Number of bytes written.").rate().unit("byte"), + "read_time" => Field::new("Time spent reading.").rate().unit("millisecond"), + "write_time" => Field::new("Time spent writing.").rate().unit("millisecond"), + "read_latency" => Field::new("Mean time spent reading per operation.").unit("millisecond"), + "write_latency" => Field::new("Mean time spent writing per operation.").unit("millisecond"), + }; + let items_history = vec![ + HistoryItem::new("read_bytes_rate_per_sec", "Bytes read per second"), + HistoryItem::new("write_bytes_rate_per_sec", "Bytes write per second"), + ]; + let mut model = PluginModel::new( + "diskio", + fields, + items_history, + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("disk_name"); + model.hide_zero = config.get_bool_value("diskio", "hide_zero", false); + model.hide_threshold_bytes = config.get_int_value("diskio", "hide_threshold_bytes", 0) as f64; + model.hide_zero_fields = + vec!["read_bytes_rate_per_sec".to_string(), "write_bytes_rate_per_sec".to_string()]; + Self { model } + } +} + +impl Plugin for DiskioPlugin { + fn name(&self) -> &str { + "diskio" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("disk_name") + } + + fn collect(&mut self, ctx: &mut UpdateContext) -> anyhow::Result { + let mut stats = Vec::new(); + for (disk_name, disk_stat) in read_diskstats() { + if !ctx.args.diskio_show_ramfs && disk_name.starts_with("ram") { + continue; + } + if !self.model.is_display(&disk_name, "") { + continue; + } + let mut item = serde_json::Map::new(); + item.insert("read_count".to_string(), json!(disk_stat.read_count)); + item.insert("write_count".to_string(), json!(disk_stat.write_count)); + item.insert("read_bytes".to_string(), json!(disk_stat.sectors_read * SECTOR_SIZE)); + item.insert("write_bytes".to_string(), json!(disk_stat.sectors_written * SECTOR_SIZE)); + item.insert("read_time".to_string(), json!(disk_stat.read_time)); + item.insert("write_time".to_string(), json!(disk_stat.write_time)); + item.insert("key".to_string(), json!("disk_name")); + item.insert("disk_name".to_string(), json!(disk_name)); + if let Some(alias) = self.model.has_alias(&disk_name) { + item.insert("alias".to_string(), json!(alias)); + } + stats.push(Value::Object(item)); + } + Ok(Value::Array(stats)) + } + + fn post_collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result<()> { + if let Value::Array(items) = &mut self.model.stats { + for item in items.iter_mut() { + let Some(item) = item.as_object_mut() else { + continue; + }; + let read_time = item.get("read_time_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let read_count = item.get("read_count_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let write_time = item.get("write_time_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let write_count = item.get("write_count_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let read_latency = if item.contains_key("read_time_rate_per_sec") && read_count > 0.0 { + (read_time / read_count).floor() as i64 + } else { + 0 + }; + let write_latency = if item.contains_key("write_time_rate_per_sec") && write_count > 0.0 { + (write_time / write_count).floor() as i64 + } else { + 0 + }; + item.insert("read_latency".to_string(), json!(read_latency)); + item.insert("write_latency".to_string(), json!(write_latency)); + } + } + Ok(()) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let items: Vec<(String, f64, f64, f64, f64)> = match &self.model.stats { + Value::Array(list) => list + .iter() + .filter_map(|item| { + let name = item.get("disk_name")?.as_str()?.to_string(); + let read_rate = item.get("read_bytes_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let write_rate = item.get("write_bytes_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let read_latency = item.get("read_latency").and_then(Value::as_f64).unwrap_or(0.0); + let write_latency = item.get("write_latency").and_then(Value::as_f64).unwrap_or(0.0); + Some((name, read_rate, write_rate, read_latency, write_latency)) + }) + .collect(), + _ => Vec::new(), + }; + for (disk_name, read_rate, write_rate, read_latency, write_latency) in items { + let alert_rx = self.model.get_alert(read_rate, 0.0, 100.0, true, false, Some("rx"), Some(&disk_name), false); + let alert_tx = self.model.get_alert(write_rate, 0.0, 100.0, true, false, Some("tx"), Some(&disk_name), false); + for field in ["read_bytes", "read_bytes_rate_per_sec"] { + self.model.set_view_decoration(Some(&disk_name), field, alert_rx); + } + for field in ["write_bytes", "write_bytes_rate_per_sec"] { + self.model.set_view_decoration(Some(&disk_name), field, alert_tx); + } + let mut alert_latency_rx = + self.model.get_alert(read_latency, 0.0, 100.0, true, false, Some("rx_latency"), Some(&disk_name), false); + let mut alert_latency_tx = + self.model.get_alert(write_latency, 0.0, 100.0, true, false, Some("tx_latency"), Some(&disk_name), false); + if alert_latency_rx == Decoration::Default { + alert_latency_rx = self.model.get_alert(read_latency, 0.0, 100.0, true, false, Some("rx_latency"), None, false); + } + if alert_latency_tx == Decoration::Default { + alert_latency_tx = self.model.get_alert(write_latency, 0.0, 100.0, true, false, Some("tx_latency"), None, false); + } + self.model.set_view_decoration(Some(&disk_name), "read_latency", alert_latency_rx); + self.model.set_view_decoration(Some(&disk_name), "write_latency", alert_latency_tx); + } + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/fs.rs b/crates/glances-plugins/src/plugins/fs.rs new file mode 100644 index 0000000..b40966c --- /dev/null +++ b/crates/glances-plugins/src/plugins/fs.rs @@ -0,0 +1,120 @@ +//! File system plugin. + +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct FsPlugin { + pub model: PluginModel, +} + +impl FsPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "device_name" => Field::new("Device name."), + "fs_type" => Field::new("File system type."), + "mnt_point" => Field::new("Mount point."), + "options" => Field::new("Mount options."), + "size" => Field::new("Total size.").unit("byte"), + "used" => Field::new("Used size.").unit("byte"), + "free" => Field::new("Free size.").unit("byte"), + "percent" => Field::new("File system usage in percent.").unit("percent"), + }; + let items_history = vec![HistoryItem::new("percent", "File system usage in percent")]; + let mut model = PluginModel::new( + "fs", + fields, + items_history, + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("mnt_point"); + Self { model } + } +} + +impl Plugin for FsPlugin { + fn name(&self) -> &str { + "fs" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("mnt_point") + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let allowed_fs_types = self.model.get_conf_value("allow", ""); + let mut stats = Vec::new(); + for mount in crate::collectors::read_mounts() { + let is_physical = mount.device.starts_with("/dev/"); + let is_allowed = allowed_fs_types.iter().any(|fs_type| mount.fs_type.contains(fs_type.as_str())); + if !is_physical && !is_allowed { + continue; + } + if !self.model.is_display_any(&[&mount.mount_point, &mount.device], "") { + continue; + } + let Some(usage) = crate::collectors::disk_usage(&mount.mount_point) else { + continue; + }; + let mnt_point = mount.mount_point.replace('\u{00a0}', " "); + let mut item = serde_json::Map::new(); + item.insert("device_name".to_string(), json!(mount.device)); + item.insert("fs_type".to_string(), json!(mount.fs_type)); + item.insert("mnt_point".to_string(), json!(mnt_point)); + item.insert("options".to_string(), json!(mount.options)); + item.insert("size".to_string(), json!(usage.total)); + item.insert("used".to_string(), json!(usage.used)); + item.insert("free".to_string(), json!(usage.free)); + item.insert("percent".to_string(), json!((usage.percent * 10.0).round() / 10.0)); + item.insert("key".to_string(), json!("mnt_point")); + if let Some(alias) = self.model.has_alias(&mnt_point) { + item.insert("alias".to_string(), json!(alias)); + } + stats.push(Value::Object(item)); + } + Ok(Value::Array(stats)) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let items: Vec<(String, f64, f64, String)> = match &self.model.stats { + Value::Array(list) => list + .iter() + .filter_map(|item| { + let mnt = item.get("mnt_point")?.as_str()?.to_string(); + let options = item.get("options").and_then(Value::as_str).unwrap_or("").to_string(); + let size = item.get("size")?.as_f64()?; + let free = item.get("free")?.as_f64()?; + Some((mnt, size, free, options)) + }) + .collect(), + _ => Vec::new(), + }; + for (mnt_point, size, free, options) in items { + // Do not display thresholds for volumes mounted in 'ro' (#3143). + if options.split(',').any(|o| o == "ro") { + continue; + } + let decoration = self.model.get_alert(size - free, 0.0, size, true, false, Some(&mnt_point), None, false); + self.model.set_view_decoration(Some(&mnt_point), "used", decoration); + } + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/irq.rs b/crates/glances-plugins/src/plugins/irq.rs new file mode 100644 index 0000000..5b4a23b --- /dev/null +++ b/crates/glances-plugins/src/plugins/irq.rs @@ -0,0 +1,101 @@ +//! IRQ plugin (Linux only). + +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use indexmap::IndexMap; +use serde_json::{Value, json}; + +pub struct IrqPlugin { + pub model: PluginModel, + lasts: IndexMap, +} + +impl IrqPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "irq_line" => Field::new("IRQ line name"), + "irq_rate" => Field::new("IRQ rate per second").unit("numberpersecond"), + }; + let mut model = PluginModel::new( + "irq", + fields, + Vec::new(), + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("irq_line"); + Self { model, lasts: IndexMap::new() } + } +} + +impl Plugin for IrqPlugin { + fn name(&self) -> &str { + "irq" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("irq_line") + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let time_since_update = glances_core::timer::get_time_since_last_update("irq"); + let mut stats = Vec::new(); + // /proc/interrupts may not exist in containers. + let Ok(content) = std::fs::read_to_string("/proc/interrupts") else { + return Ok(Value::Array(stats)); + }; + let mut lines = content.lines(); + let cpu_number = lines.next().map(|header| header.split_whitespace().count()).unwrap_or(0); + for line in lines { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 2 { + continue; + } + let first = fields[0].trim_end_matches(':'); + let irq_line = if first.chars().all(|c| c.is_ascii_digit()) { + format!("{first}_{}", fields.last().copied().unwrap_or("")) + } else { + first.to_string() + }; + let current: u64 = fields[1..1 + cpu_number].iter().filter_map(|v| v.parse::().ok()).sum(); + let irq_rate = match self.lasts.get(&irq_line) { + Some(last) if *last > 0 && time_since_update > 0.0 => { + ((current.saturating_sub(*last)) as f64 / time_since_update) as i64 + } + _ => 0, + }; + stats.push(json!({ + "irq_line": irq_line, + "irq_rate": irq_rate, + "key": "irq_line", + "time_since_update": time_since_update, + })); + self.lasts.insert(irq_line, current); + } + // Keep the top 5 by rate. + stats.sort_by(|a, b| { + b.get("irq_rate") + .and_then(Value::as_i64) + .unwrap_or(0) + .cmp(&a.get("irq_rate").and_then(Value::as_i64).unwrap_or(0)) + }); + stats.truncate(5); + Ok(Value::Array(stats)) + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/load.rs b/crates/glances-plugins/src/plugins/load.rs new file mode 100644 index 0000000..2ef2a37 --- /dev/null +++ b/crates/glances-plugins/src/plugins/load.rs @@ -0,0 +1,81 @@ +//! Load average plugin. + +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct LoadPlugin { + pub model: PluginModel, +} + +impl LoadPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "min1" => Field::new("Average sum of the number of processes waiting in the run-queue plus the number currently executing over 1 minute.").unit("float").mmm(), + "min5" => Field::new("Average sum of the number of processes waiting in the run-queue plus the number currently executing over 5 minutes.").unit("float"), + "min15" => Field::new("Average sum of the number of processes waiting in the run-queue plus the number currently executing over 15 minutes.").unit("float"), + "cpucore" => Field::new("Total number of CPU core.").unit("number"), + }; + let items_history = vec![ + HistoryItem::new("min1", "1 minute load"), + HistoryItem::new("min5", "5 minutes load"), + HistoryItem::new("min15", "15 minutes load"), + ]; + let model = PluginModel::new( + "load", + fields, + items_history, + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for LoadPlugin { + fn name(&self) -> &str { + "load" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + match crate::collectors::read_loadavg() { + Some((min1, min5, min15)) => Ok(json!({ + "min1": min1, + "min5": min5, + "min15": min15, + "cpucore": crate::collectors::logical_core_count(), + })), + None => Ok(json!({})), + } + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let max = self.model.stats.get("cpucore").and_then(Value::as_f64).unwrap_or(0.0) * 100.0; + if let Some(min15) = self.model.stats.get("min15").and_then(Value::as_f64) { + let decoration = self.model.get_alert_log(min15, max, None); + self.model.set_view_decoration(None, "min15", decoration); + } + if let Some(min5) = self.model.stats.get("min5").and_then(Value::as_f64) { + let decoration = self.model.get_alert(min5, 0.0, max, true, false, None, None, false); + self.model.set_view_decoration(None, "min5", decoration); + } + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/mem.rs b/crates/glances-plugins/src/plugins/mem.rs new file mode 100644 index 0000000..6bc7103 --- /dev/null +++ b/crates/glances-plugins/src/plugins/mem.rs @@ -0,0 +1,116 @@ +//! Memory plugin. + +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct MemPlugin { + pub model: PluginModel, + /// Display `available` instead of `used` in the TUI. + pub available: bool, +} + +impl MemPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "total" => Field::new("Total physical memory available.").unit("bytes").min_symbol("K"), + "available" => Field::new("The actual amount of available memory that can be given instantly to processes that request more memory in bytes; this is calculated by summing different memory values depending on the platform (e.g. free + buffers + cached on Linux) and it is supposed to be used to monitor actual memory usage in a cross platform fashion.").unit("bytes").min_symbol("K").short_name("avail"), + "percent" => Field::new("The percentage usage calculated as (total - available) / total * 100.").unit("percent").mmm(), + "used" => Field::new("Memory used, calculated differently depending on the platform and designed for informational purposes only.").unit("bytes").min_symbol("K"), + "free" => Field::new("Memory not being used at all (zeroed) that is readily available; note that this doesn't reflect the actual memory available (use 'available' instead).").unit("bytes").min_symbol("K"), + "active" => Field::new("*(UNIX)*: memory currently in use or very recently used, and so it is in RAM.").unit("bytes").min_symbol("K").optional(), + "inactive" => Field::new("*(UNIX)*: memory that is marked as not used.").unit("bytes").min_symbol("K").short_name("inacti").optional(), + "buffers" => Field::new("*(Linux, BSD)*: cache for things like file system metadata.").unit("bytes").min_symbol("K").short_name("buffer").optional(), + "cached" => Field::new("*(Linux, BSD)*: cache for various things (including ZFS cache).").unit("bytes").min_symbol("K").optional(), + "wired" => Field::new("*(BSD, macOS)*: memory that is marked to always stay in RAM. It is never moved to disk.").unit("bytes").min_symbol("K"), + "shared" => Field::new("*(BSD)*: memory that may be simultaneously accessed by multiple processes.").unit("bytes").min_symbol("K"), + }; + let items_history = vec![HistoryItem::new("percent", "RAM memory usage")]; + let model = PluginModel::new( + "mem", + fields, + items_history, + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + let available = model + .get_conf_value("available", "") + .first() + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false); + let mut model = model; + model.mmm_tracked = true; + Self { model, available } + } +} + +impl Plugin for MemPlugin { + fn name(&self) -> &str { + "mem" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let meminfo = crate::collectors::read_meminfo(); + let get = |key: &str| meminfo.get(key).copied().unwrap_or(0); + let total = get("MemTotal"); + let free = get("MemFree"); + let buffers = get("Buffers"); + // procps-ng "free" sums reclaimable slab to cached. + let cached = get("Cached") + get("SReclaimable"); + let shared = if meminfo.contains_key("Shmem") { get("Shmem") } else { get("MemShared") }; + let active = get("Active"); + let inactive = get("Inactive"); + let mut available = get("MemAvailable"); + if available == 0 { + // Kernel bug or old kernel: estimate like the "free" tool. + available = free + buffers + cached; + } + if available > total { + // Container with distorted values. + available = free; + } + let used = total.saturating_sub(available); + let percent = if total > 0 { + ((used as f64 * 100.0 / total as f64) * 10.0).round() / 10.0 + } else { + 0.0 + }; + let mut stats = serde_json::Map::new(); + stats.insert("total".to_string(), json!(total)); + stats.insert("available".to_string(), json!(available)); + stats.insert("percent".to_string(), json!(percent)); + stats.insert("used".to_string(), json!(used)); + stats.insert("free".to_string(), json!(free)); + stats.insert("active".to_string(), json!(active)); + stats.insert("inactive".to_string(), json!(inactive)); + stats.insert("buffers".to_string(), json!(buffers)); + stats.insert("cached".to_string(), json!(cached)); + stats.insert("shared".to_string(), json!(shared)); + Ok(Value::Object(stats)) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let total = self.model.stats.get("total").and_then(Value::as_f64).unwrap_or(0.0); + let used = self.model.stats.get("used").and_then(Value::as_f64).unwrap_or(0.0); + let decoration = self.model.get_alert_log(used, total, None); + self.model.set_view_decoration(None, "percent", decoration); + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/memswap.rs b/crates/glances-plugins/src/plugins/memswap.rs new file mode 100644 index 0000000..197bd9a --- /dev/null +++ b/crates/glances-plugins/src/plugins/memswap.rs @@ -0,0 +1,85 @@ +//! Swap memory plugin. + +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct MemSwapPlugin { + pub model: PluginModel, +} + +impl MemSwapPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "total" => Field::new("Total swap memory.").unit("bytes").min_symbol("K"), + "used" => Field::new("Used swap memory.").unit("bytes").min_symbol("K"), + "free" => Field::new("Free swap memory.").unit("bytes").min_symbol("K"), + "percent" => Field::new("Used swap memory in percentage.").unit("percent"), + "sin" => Field::new("The number of bytes the system has swapped in from disk (cumulative).").unit("bytes").min_symbol("K"), + "sout" => Field::new("The number of bytes the system has swapped out from disk (cumulative).").unit("bytes").min_symbol("K"), + "time_since_update" => Field::new("Number of seconds since last update.").unit("seconds"), + }; + let items_history = vec![HistoryItem::new("percent", "Swap memory usage")]; + let model = PluginModel::new( + "memswap", + fields, + items_history, + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for MemSwapPlugin { + fn name(&self) -> &str { + "memswap" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let meminfo = crate::collectors::read_meminfo(); + let total = meminfo.get("SwapTotal").copied().unwrap_or(0); + let free = meminfo.get("SwapFree").copied().unwrap_or(0); + let used = total.saturating_sub(free); + let percent = if total > 0 { (used as f64 * 100.0 / total as f64 * 10.0).round() / 10.0 } else { 0.0 }; + let vmstat = crate::collectors::read_vmstat(); + // pswpin/pswpout are expressed in 4 KiB pages. + let sin = vmstat.get("pswpin").copied().unwrap_or(0) * 4 * 1024; + let sout = vmstat.get("pswpout").copied().unwrap_or(0) * 4 * 1024; + let time_since_update = glances_core::timer::get_time_since_last_update("memswap"); + let mut stats = serde_json::Map::new(); + stats.insert("total".to_string(), json!(total)); + stats.insert("used".to_string(), json!(used)); + stats.insert("free".to_string(), json!(free)); + stats.insert("percent".to_string(), json!(percent)); + stats.insert("sin".to_string(), json!(sin)); + stats.insert("sout".to_string(), json!(sout)); + stats.insert("time_since_update".to_string(), json!(time_since_update)); + Ok(Value::Object(stats)) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let total = self.model.stats.get("total").and_then(Value::as_f64).unwrap_or(0.0); + let used = self.model.stats.get("used").and_then(Value::as_f64).unwrap_or(0.0); + let decoration = self.model.get_alert_log(used, total, None); + self.model.set_view_decoration(None, "percent", decoration); + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/mod.rs b/crates/glances-plugins/src/plugins/mod.rs new file mode 100644 index 0000000..d9ce231 --- /dev/null +++ b/crates/glances-plugins/src/plugins/mod.rs @@ -0,0 +1,46 @@ +//! Plugin implementations. +//! +//! The extended plugins (containers, GPU, ports, ...) are added in a later +//! milestone. + +pub mod core; +pub mod cpu; +pub mod diskio; +pub mod fs; +pub mod irq; +pub mod load; +pub mod mem; +pub mod memswap; +pub mod network; +pub mod now; +pub mod percpu; +pub mod processcount; +pub mod processlist; +pub mod programlist; +pub mod psutilversion; +pub mod quicklook; +pub mod sensors; +pub mod system; +pub mod uptime; +pub mod version; + +pub use core::CorePlugin; +pub use cpu::CpuPlugin; +pub use diskio::DiskioPlugin; +pub use fs::FsPlugin; +pub use irq::IrqPlugin; +pub use load::LoadPlugin; +pub use mem::MemPlugin; +pub use memswap::MemSwapPlugin; +pub use network::NetworkPlugin; +pub use now::NowPlugin; +pub use percpu::PerCpuPlugin; +pub use processcount::ProcessCountPlugin; +pub use processlist::ProcessListPlugin; +pub use programlist::ProgramListPlugin; +pub use psutilversion::PsutilVersionPlugin; +pub use quicklook::QuicklookPlugin; +pub use sensors::SensorsPlugin; +pub use system::SystemPlugin; +pub use uptime::UptimePlugin; +pub use version::VersionPlugin; diff --git a/crates/glances-plugins/src/plugins/network.rs b/crates/glances-plugins/src/plugins/network.rs new file mode 100644 index 0000000..24945eb --- /dev/null +++ b/crates/glances-plugins/src/plugins/network.rs @@ -0,0 +1,139 @@ +//! Network plugin. + +use crate::collectors; +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct NetworkPlugin { + pub model: PluginModel, + hide_no_up: bool, + hide_no_ip: bool, +} + +impl NetworkPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "interface_name" => Field::new("Interface name."), + "alias" => Field::new("Interface alias name (optional)."), + "bytes_recv" => Field::new("Number of bytes received.").rate().unit("byte"), + "bytes_sent" => Field::new("Number of bytes sent.").rate().unit("byte"), + "bytes_all" => Field::new("Number of bytes received and sent.").rate().unit("byte"), + "speed" => Field::new("Maximum interface speed (in bit per second). Can return 0 on some operating-system.").unit("bitpersecond"), + "is_up" => Field::new("Is the interface up ?").unit("bool"), + }; + let items_history = vec![ + HistoryItem::new("bytes_recv_rate_per_sec", "Download rate per second"), + HistoryItem::new("bytes_sent_rate_per_sec", "Upload rate per second"), + ]; + let mut model = PluginModel::new( + "network", + fields, + items_history, + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("interface_name"); + let hide_no_up = config.has_section("network") && config.get_bool_value("network", "hide_no_up", false); + let hide_no_ip = config.has_section("network") && config.get_bool_value("network", "hide_no_ip", false); + Self { model, hide_no_up, hide_no_ip } + } +} + +impl Plugin for NetworkPlugin { + fn name(&self) -> &str { + "network" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("interface_name") + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let net_dev = collectors::read_net_dev(); + let mut stats = Vec::new(); + for (interface_name, counters) in net_dev { + if !self.model.is_display(&interface_name, "") { + continue; + } + let is_up = collectors::interface_flags(&interface_name) & libc::IFF_UP as u64 != 0; + if self.hide_no_up && !is_up { + continue; + } + if self.hide_no_ip && !collectors::interface_has_ip(&interface_name) { + continue; + } + let speed = collectors::interface_speed(&interface_name); + let alias = self.model.has_alias(&interface_name); + let mut item = serde_json::Map::new(); + item.insert("bytes_sent".to_string(), json!(counters.bytes_sent)); + item.insert("bytes_recv".to_string(), json!(counters.bytes_recv)); + // Note: psutil's field is `isup`, which does not match the + // `is_up` fields description, so the Python implementation drops + // it from the stats (the value is only used to filter interfaces). + item.insert("speed".to_string(), json!(speed * 1048576)); + item.insert("key".to_string(), json!("interface_name")); + item.insert("interface_name".to_string(), json!(interface_name)); + item.insert("alias".to_string(), json!(alias)); + item.insert("bytes_all".to_string(), json!(counters.bytes_sent + counters.bytes_recv)); + stats.push(Value::Object(item)); + } + Ok(Value::Array(stats)) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let items: Vec<(String, f64, f64, f64, Option)> = match &self.model.stats { + Value::Array(list) => list + .iter() + .filter_map(|item| { + let name = item.get("interface_name")?.as_str()?.to_string(); + let rx = item.get("bytes_recv_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let tx = item.get("bytes_sent_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0); + let speed = item.get("speed").and_then(Value::as_f64).unwrap_or(0.0); + let real_name = name.split(':').next().unwrap_or("").to_string(); + Some((name, rx, tx, speed, Some(real_name))) + }) + .collect(), + _ => Vec::new(), + }; + for (interface_name, rx, tx, speed, action_key) in items { + let bps_rx = rx as i64 * 8; + let bps_tx = tx as i64 * 8; + let action_key = action_key.as_deref(); + let mut alert_rx = + self.model.get_alert(bps_rx as f64, 0.0, 100.0, true, false, Some("rx"), action_key, false); + let mut alert_tx = + self.model.get_alert(bps_tx as f64, 0.0, 100.0, true, false, Some("tx"), action_key, false); + if alert_rx == glances_core::Decoration::Default && speed != 0.0 { + alert_rx = self.model.get_alert(bps_rx as f64, 0.0, speed, true, false, Some("rx"), None, false); + } + if alert_tx == glances_core::Decoration::Default && speed != 0.0 { + alert_tx = self.model.get_alert(bps_tx as f64, 0.0, speed, true, false, Some("tx"), None, false); + } + for field in ["bytes_recv", "bytes_recv_rate_per_sec"] { + self.model.set_view_decoration(Some(&interface_name), field, alert_rx); + } + for field in ["bytes_sent", "bytes_sent_rate_per_sec"] { + self.model.set_view_decoration(Some(&interface_name), field, alert_tx); + } + } + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/now.rs b/crates/glances-plugins/src/plugins/now.rs new file mode 100644 index 0000000..091509e --- /dev/null +++ b/crates/glances-plugins/src/plugins/now.rs @@ -0,0 +1,70 @@ +//! Current date/time plugin. + +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct NowPlugin { + pub model: PluginModel, +} + +impl NowPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "custom" => Field::new("Current date in custom format."), + "iso" => Field::new("Current date in ISO 8601 format."), + }; + let model = PluginModel::new( + "now", + fields, + Vec::new(), + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for NowPlugin { + fn name(&self) -> &str { + "now" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, ctx: &mut UpdateContext) -> anyhow::Result { + let now = chrono::Local::now(); + let iso = now.format("%Y-%m-%dT%H:%M:%S%:z").to_string(); + let strftime = if !ctx.args.strftime_format.is_empty() { + ctx.args.strftime_format.clone() + } else { + ctx.config.get_value("global", "strftime_format", Some("")).unwrap_or_default() + }; + let custom = if !strftime.is_empty() { + now.format(&strftime).to_string() + } else { + // Choose the tz format depending on the timezone name length. + let tz_name = now.format("%Z").to_string(); + if tz_name.len() > 6 { + now.format("%Y-%m-%d %H:%M:%S %z").to_string() + } else { + now.format("%Y-%m-%d %H:%M:%S %Z").to_string() + } + }; + Ok(json!({"iso": iso, "custom": custom})) + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/percpu.rs b/crates/glances-plugins/src/plugins/percpu.rs new file mode 100644 index 0000000..345604c --- /dev/null +++ b/crates/glances-plugins/src/plugins/percpu.rs @@ -0,0 +1,67 @@ +//! Per-CPU plugin. + +use crate::cpu_percent::cpu_percent; +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::Value; + +pub struct PerCpuPlugin { + pub model: PluginModel, +} + +impl PerCpuPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "cpu_number" => Field::new("CPU number"), + "total" => Field::new("Sum of CPU percentages (except idle) for current CPU number.").unit("percent"), + "system" => Field::new("Percent time spent in kernel space. System CPU time is the time spent running code in the Operating System kernel.").unit("percent"), + "user" => Field::new("CPU percent time spent in user space. User CPU time is the time spent on the processor running your program's code (or code in libraries).").unit("percent"), + "iowait" => Field::new("*(Linux)*: percent time spent by the CPU waiting for I/O operations to complete.").unit("percent"), + "idle" => Field::new("percent of CPU used by any program. Every program or task that runs on a computer system occupies a certain amount of processing time on the CPU. If the CPU has completed all tasks it is idle.").unit("percent"), + "irq" => Field::new("*(Linux and BSD)*: percent time spent servicing/handling hardware/software interrupts. Time servicing interrupts (hardware + software).").unit("percent"), + "nice" => Field::new("*(Unix)*: percent time occupied by user level processes with a positive nice value. The time the CPU has spent running users' processes that are *niced*.").unit("percent"), + "steal" => Field::new("*(Linux)*: percentage of time a virtual CPU waits for a real CPU while the hypervisor is servicing another virtual processor.").unit("percent"), + "guest" => Field::new("*(Linux)*: percent of time spent running a virtual CPU for guest operating systems under the control of the Linux kernel.").unit("percent"), + "guest_nice" => Field::new("*(Linux)*: percent of time spent running a niced guest (virtual CPU).").unit("percent"), + "softirq" => Field::new("*(Linux)*: percent of time spent handling software interrupts.").unit("percent"), + "dpc" => Field::new("*(Windows)*: percent of time spent handling deferred procedure calls.").unit("percent"), + "interrupt" => Field::new("*(Windows)*: percent of time spent handling software interrupts.").unit("percent"), + }; + let mut model = PluginModel::new( + "percpu", + fields, + Vec::new(), + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("cpu_number"); + Self { model } + } +} + +impl Plugin for PerCpuPlugin { + fn name(&self) -> &str { + "percpu" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("cpu_number") + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let mut state = cpu_percent().lock().unwrap(); + Ok(Value::Array(state.get_percpu().to_vec())) + } +} diff --git a/crates/glances-plugins/src/plugins/processcount.rs b/crates/glances-plugins/src/plugins/processcount.rs new file mode 100644 index 0000000..59050f2 --- /dev/null +++ b/crates/glances-plugins/src/plugins/processcount.rs @@ -0,0 +1,72 @@ +//! Process count plugin. It is also in charge of updating the shared +//! process list (port of `glances.plugins.processcount`). + +use crate::fields; +use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::processes::processes; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct ProcessCountPlugin { + pub model: PluginModel, +} + +impl ProcessCountPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "total" => Field::new("Total number of processes").unit("number"), + "running" => Field::new("Total number of running processes").unit("number"), + "sleeping" => Field::new("Total number of sleeping processes").unit("number"), + "thread" => Field::new("Total number of threads").unit("number"), + "pid_max" => Field::new("Maximum PID number").unit("number"), + }; + let items_history = vec![ + HistoryItem::new("total", "Total number of processes"), + HistoryItem::new("running", "Total number of running processes"), + HistoryItem::new("sleeping", "Total number of sleeping processes"), + HistoryItem::new("thread", "Total number of threads"), + ]; + let model = PluginModel::new( + "processcount", + fields, + items_history, + json!({"total": 0, "running": 0, "sleeping": 0, "thread": 0, "pid_max": null}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for ProcessCountPlugin { + fn name(&self) -> &str { + "processcount" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, ctx: &mut UpdateContext) -> anyhow::Result { + let mut procs = processes().lock().unwrap(); + procs.update(ctx.cursor_position, ctx.args); + let count = &procs.processcount; + Ok(json!({ + "total": count.total, + "running": count.running, + "sleeping": count.sleeping, + "thread": count.thread, + "pid_max": count.pid_max, + })) + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/processlist.rs b/crates/glances-plugins/src/plugins/processlist.rs new file mode 100644 index 0000000..a6a21b7 --- /dev/null +++ b/crates/glances-plugins/src/plugins/processlist.rs @@ -0,0 +1,115 @@ +//! Process list plugin. The stats are collected by the processcount plugin. + +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::processes::processes; +use glances_core::{Args, Config}; +use serde_json::Value; + +pub struct ProcessListPlugin { + pub model: PluginModel, +} + +impl ProcessListPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "pid" => Field::new("Process identifier (ID)").unit("number"), + "name" => Field::new("Process name").unit("string"), + "cmdline" => Field::new("Command line with arguments").unit("list"), + "username" => Field::new("Process owner").unit("string"), + "num_threads" => Field::new("Number of threads").unit("number"), + "cpu_percent" => Field::new("Process CPU consumption (returned value can be > 100.0 in case of a process running multiple threads on different CPU cores)").unit("percent"), + "memory_percent" => Field::new("Process memory consumption").unit("percent"), + "memory_info" => Field::new("Process memory information (dict with rss, vms, shared, text, lib, data, dirty keys)").unit("byte"), + "status" => Field::new("Process status").unit("string"), + "nice" => Field::new("Process nice value").unit("number"), + "cpu_times" => Field::new("Process CPU times (dict with user, system, iowait keys)").unit("second"), + "gids" => Field::new("Process group IDs (dict with real, effective, saved keys)").unit("number"), + "io_counters" => Field::new("Process IO counters (list with read_count, write_count, read_bytes, write_bytes, io_tag keys)").unit("byte"), + "cpu_num" => Field::new("CPU core number where the process is currently executing (0-based indexing)").unit("number"), + }; + let mut model = PluginModel::new( + "processlist", + fields, + Vec::new(), + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("pid"); + + // Load the configuration. + if config.has_option("processlist", "sort_key") { + if let Some(sort_key) = config.get_value("processlist", "sort_key", None) { + let mut procs = processes().lock().unwrap(); + procs.set_sort_key(&sort_key, false); + } + } + if config.has_option("processlist", "focus") { + if let Some(focus) = config.get_value("processlist", "focus", None) { + processes().lock().unwrap().filter_focus.set_filter(Some(&focus)); + } + } + if config.has_option("processlist", "disable_stats") { + if let Some(disabled) = config.get_value("processlist", "disable_stats", None) { + let stats: Vec = disabled + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && !glances_core::processes::MANDATORY_PROCESS_STATS.contains(&s.as_str())) + .collect(); + processes().lock().unwrap().disable_stats = stats; + } + } + if let Some(sort_key) = args.sort_processes_key { + processes().lock().unwrap().set_sort_key(sort_key.as_str(), false); + } + Self { model } + } +} + +impl Plugin for ProcessListPlugin { + fn name(&self) -> &str { + "processlist" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("pid") + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + // The list is updated by the processcount plugin. + let procs = processes().lock().unwrap(); + Ok(Value::Array(procs.get_list(false).to_vec())) + } + + fn update_views(&mut self) { + // The views are built lazily (see the Python implementation): the UI + // computes its own decorations. + self.model.reset_views(); + } + + fn export(&self) -> Value { + Value::Array(processes().lock().unwrap().get_export().to_vec()) + } + + fn display_curse(&self) -> bool { + true + } + + fn align(&self) -> glances_core::Align { + glances_core::Align::Bottom + } +} + +#[allow(dead_code)] +fn unused(_: Value) {} diff --git a/crates/glances-plugins/src/plugins/programlist.rs b/crates/glances-plugins/src/plugins/programlist.rs new file mode 100644 index 0000000..da6c988 --- /dev/null +++ b/crates/glances-plugins/src/plugins/programlist.rs @@ -0,0 +1,70 @@ +//! Program list plugin (processes aggregated by program name). + +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::processes::processes; +use glances_core::{Args, Config}; +use serde_json::Value; + +pub struct ProgramListPlugin { + pub model: PluginModel, +} + +impl ProgramListPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "name" => Field::new("Process name").unit("string"), + "cmdline" => Field::new("Command line with arguments").unit("list"), + "username" => Field::new("Process owner").unit("string"), + "nprocs" => Field::new("Number of children processes").unit("number"), + "num_threads" => Field::new("Number of threads").unit("number"), + "cpu_percent" => Field::new("Process CPU consumption").unit("percent"), + "memory_percent" => Field::new("Process memory consumption").unit("percent"), + "memory_info" => Field::new("Process memory information (dict with rss, vms, shared, text, lib, data, dirty keys)").unit("byte"), + "status" => Field::new("Process status").unit("string"), + "nice" => Field::new("Process nice value").unit("number"), + "cpu_times" => Field::new("Process CPU times (dict with user, system, iowait keys)").unit("second"), + "gids" => Field::new("Process group IDs (dict with real, effective, saved keys)").unit("number"), + "io_counters" => Field::new("Process IO counters (list with read_count, write_count, read_bytes, write_bytes, io_tag keys)").unit("byte"), + }; + let mut model = PluginModel::new( + "programlist", + fields, + Vec::new(), + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("name"); + Self { model } + } +} + +impl Plugin for ProgramListPlugin { + fn name(&self) -> &str { + "programlist" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("name") + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let mut procs = processes().lock().unwrap(); + Ok(Value::Array(procs.get_list_as_programs().to_vec())) + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/psutilversion.rs b/crates/glances-plugins/src/plugins/psutilversion.rs new file mode 100644 index 0000000..8554d21 --- /dev/null +++ b/crates/glances-plugins/src/plugins/psutilversion.rs @@ -0,0 +1,47 @@ +//! Psutil version plugin. +//! +//! The Rust rewrite does not use psutil; the plugin is kept for drop-in +//! compatibility of the stats schema and reports "N/A". + +use crate::fields; +use glances_core::plugin::{Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct PsutilVersionPlugin { + pub model: PluginModel, +} + +impl PsutilVersionPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let model = PluginModel::new( + "psutilversion", + fields! {}, + Vec::new(), + Value::Null, + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for PsutilVersionPlugin { + fn name(&self) -> &str { + "psutilversion" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + Ok(json!("N/A")) + } +} diff --git a/crates/glances-plugins/src/plugins/quicklook.rs b/crates/glances-plugins/src/plugins/quicklook.rs new file mode 100644 index 0000000..dcb0f8e --- /dev/null +++ b/crates/glances-plugins/src/plugins/quicklook.rs @@ -0,0 +1,183 @@ +//! Quicklook plugin (condensed stats displayed in the top-left corner). + +use crate::cpu_percent::cpu_percent; +use crate::fields; +use glances_core::plugin::{Decoration, Field, HistoryItem, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct QuicklookPlugin { + pub model: PluginModel, + pub stats_list: Vec, + pub max_cpu_display: usize, +} + +const AVAILABLE_STATS_LIST: [&str; 6] = ["cpu", "mem", "swap", "load", "gpu_mem", "gpu_proc"]; +const DEFAULT_STATS_LIST: [&str; 3] = ["cpu", "mem", "load"]; + +impl QuicklookPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "cpu" => Field::new("CPU percent usage").unit("percent"), + "percpu" => Field::new("PERCPU percent usage").unit("percent"), + "mem" => Field::new("MEM percent usage").unit("percent"), + "swap" => Field::new("SWAP percent usage").unit("percent"), + "load" => Field::new("LOAD percent usage").unit("percent").alert(), + "cpu_log_core" => Field::new("Number of logical CPU core").unit("number"), + "cpu_phys_core" => Field::new("Number of physical CPU core").unit("number"), + "cpu_name" => Field::new("CPU name"), + "cpu_hz_current" => Field::new("CPU current frequency").unit("hertz"), + "cpu_hz" => Field::new("CPU max frequency").unit("hertz"), + "gpu_mem" => Field::new("Average GPU Memory consumption").unit("percent"), + "gpu_proc" => Field::new("Average GPU Processor consumption").unit("percent"), + }; + let items_history = vec![ + HistoryItem::new("cpu", "CPU percent usage"), + HistoryItem::new("percpu", "PERCPU percent usage"), + HistoryItem::new("mem", "MEM percent usage"), + HistoryItem::new("swap", "SWAP percent usage"), + HistoryItem::new("load", "LOAD percent usage"), + HistoryItem::new("gpu_mem", "GPU memory percent usage"), + HistoryItem::new("gpu_proc", "GPU processor percent usage"), + ]; + let model = PluginModel::new( + "quicklook", + fields, + items_history, + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + let stats_list = model.get_conf_value("list", ""); + let mut stats_list: Vec = + if stats_list.is_empty() { DEFAULT_STATS_LIST.iter().map(|s| s.to_string()).collect() } else { stats_list }; + if stats_list.iter().any(|s| !AVAILABLE_STATS_LIST.contains(&s.as_str())) { + stats_list = DEFAULT_STATS_LIST.iter().map(|s| s.to_string()).collect(); + } + let max_cpu_display = config.get_int_value("percpu", "max_cpu_display", 4).max(0) as usize; + Self { model, stats_list, max_cpu_display } + } + + fn mhz_to_hz(value: Option) -> Option { + value.map(|v| v * 1_000_000.0) + } +} + +impl Plugin for QuicklookPlugin { + fn name(&self) -> &str { + "quicklook" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let mut stats = serde_json::Map::new(); + let (info, cpu_value, percpu_values) = { + let mut state = cpu_percent().lock().unwrap(); + let info = state.get_info(); + let cpu = state.get_cpu(); + let percpu = state.get_percpu().to_vec(); + (info, cpu, percpu) + }; + stats.insert("cpu_name".to_string(), json!(info.cpu_name)); + stats.insert("cpu_hz_current".to_string(), json!(Self::mhz_to_hz(info.cpu_hz_current))); + stats.insert("cpu_hz".to_string(), json!(Self::mhz_to_hz(info.cpu_hz))); + stats.insert("cpu".to_string(), json!(cpu_value)); + stats.insert("percpu".to_string(), Value::Array(percpu_values)); + + let meminfo = crate::collectors::read_meminfo(); + let total = meminfo.get("MemTotal").copied().unwrap_or(0) as f64; + let available = meminfo.get("MemAvailable").copied().unwrap_or(0) as f64; + stats.insert( + "mem".to_string(), + json!(if total > 0.0 { ((total - available) / total * 100.0 * 10.0).round() / 10.0 } else { 0.0 }), + ); + let swap_total = meminfo.get("SwapTotal").copied().unwrap_or(0); + let swap_free = meminfo.get("SwapFree").copied().unwrap_or(0); + stats.insert( + "swap".to_string(), + json!(if swap_total > 0 { + (((swap_total - swap_free) as f64 * 100.0 / swap_total as f64) * 10.0).round() / 10.0 + } else { + 0.0 + }), + ); + + let log_core = crate::collectors::logical_core_count(); + stats.insert("cpu_log_core".to_string(), json!(log_core)); + stats.insert("cpu_phys_core".to_string(), json!(crate::collectors::physical_core_count())); + let load = crate::collectors::read_loadavg().map(|(_, _, min15)| { + if log_core > 0 { min15 / log_core as f64 * 100.0 } else { 0.0 } + }); + stats.insert("load".to_string(), json!(load)); + stats.insert("gpu_mem".to_string(), Value::Null); + stats.insert("gpu_proc".to_string(), Value::Null); + + Ok(Value::Object(stats)) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let stats_list = self.stats_list.clone(); + for stat_key in &stats_list { + if let Some(value) = self.model.stats.get(stat_key).and_then(Value::as_f64) { + let decoration = + self.model.get_alert(value, 0.0, 100.0, true, false, Some(stat_key), None, false); + self.model.set_view_decoration(None, stat_key, decoration); + } + } + // Store the list of stats to display and the per-CPU decorations. + self.model + .views + .insert("list".to_string(), json!(stats_list)); + let mut percpu_decoration = serde_json::Map::new(); + if let Some(percpu) = self.model.stats.get("percpu").and_then(Value::as_array) { + for cpu in percpu { + let number = cpu.get("cpu_number").map(glances_core::py_str).unwrap_or_default(); + let total = cpu.get("total").and_then(Value::as_f64).unwrap_or(0.0); + percpu_decoration.insert(number, json!(decoration_for_percent(&self.model, total).name())); + } + if percpu.len() > self.max_cpu_display && self.max_cpu_display > 0 { + let mut hidden: Vec = + percpu.iter().filter_map(|cpu| cpu.get("total").and_then(Value::as_f64)).collect(); + hidden.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + let hidden = &hidden[self.max_cpu_display.min(hidden.len())..]; + if !hidden.is_empty() { + let mean = hidden.iter().sum::() / hidden.len() as f64; + percpu_decoration.insert("other".to_string(), json!(decoration_for_percent(&self.model, mean).name())); + } + } + } + self.model.views.insert("percpu_decoration".to_string(), Value::Object(percpu_decoration)); + } + + fn display_curse(&self) -> bool { + true + } +} + +/// Return the style for a CPU percentage without the get_alert side effects. +fn decoration_for_percent(model: &PluginModel, percent: f64) -> Decoration { + let stat_name = model.get_stat_name(Some("cpu"), None).to_lowercase(); + let critical = model.get_limit("critical", &stat_name).unwrap_or(0.0); + let warning = model.get_limit("warning", &stat_name).unwrap_or(0.0); + let careful = model.get_limit("careful", &stat_name).unwrap_or(0.0); + if critical > 0.0 && percent >= critical { + Decoration::Critical + } else if warning > 0.0 && percent >= warning { + Decoration::Warning + } else if careful > 0.0 && percent >= careful { + Decoration::Careful + } else { + Decoration::Default + } +} diff --git a/crates/glances-plugins/src/plugins/sensors.rs b/crates/glances-plugins/src/plugins/sensors.rs new file mode 100644 index 0000000..03b0a2f --- /dev/null +++ b/crates/glances-plugins/src/plugins/sensors.rs @@ -0,0 +1,225 @@ +//! Sensors plugin (temperatures, fans, batteries). + +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config, Decoration}; +use serde_json::{Value, json}; + +pub struct SensorsPlugin { + pub model: PluginModel, +} + +impl SensorsPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "label" => Field::new("Sensor label"), + "unit" => Field::new("Sensor unit"), + "value" => Field::new("Sensor value").unit("number"), + "warning" => Field::new("System warning threshold (regardless of the user configuration in the glances.conf file)").unit("number"), + "critical" => Field::new("System critical threshold (regardless of the user configuration in the glances.conf file)").unit("number"), + "type" => Field::new("Sensor type (one of battery, temperature_core, fan_speed)"), + }; + let mut model = PluginModel::new( + "sensors", + fields, + Vec::new(), + Value::Array(Vec::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + model.set_item_key("label"); + Self { model } + } +} + +/// Read all hwmon sensors. +fn read_hwmon() -> Vec { + let mut stats = Vec::new(); + let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") else { + return stats; + }; + for entry in entries.flatten() { + let dir = entry.path(); + let Ok(files) = std::fs::read_dir(&dir) else { + continue; + }; + for file in files.flatten() { + let name = file.file_name().to_string_lossy().to_string(); + let path = file.path(); + // Temperatures (millidegrees). + if let Some(rest) = name.strip_prefix("temp") { + if let Some(rest) = rest.strip_suffix("_input") { + let Some(raw) = read_value(&path) else { + continue; + }; + let value = raw / 1000.0; + let label = read_label(&dir, &format!("temp{rest}_label"), &format!("temp{rest}")); + let warning = read_label_value(&dir, &format!("temp{rest}_max")).map(|v| v / 1000.0); + let critical = read_label_value(&dir, &format!("temp{rest}_crit")).map(|v| v / 1000.0); + stats.push(sensor_item(&label, "C", value, warning, critical, "temperature_core")); + } + } + // Fans (RPM). + if let Some(rest) = name.strip_prefix("fan") { + if let Some(rest) = rest.strip_suffix("_input") { + let Some(value) = read_value(&path) else { + continue; + }; + let label = read_label(&dir, &format!("fan{rest}_label"), &format!("fan{rest}")); + stats.push(sensor_item(&label, "R", value, None, None, "fan_speed")); + } + } + } + } + stats +} + +/// Read battery information from `/sys/class/power_supply`. +fn read_batteries() -> Vec { + let mut stats = Vec::new(); + let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") else { + return stats; + }; + let mut batteries: Vec<_> = entries.flatten().collect(); + batteries.sort_by_key(|e| e.file_name()); + for entry in batteries { + let dir = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let is_battery = name.starts_with("BAT") + || read_string(&dir.join("type")).map(|t| t == "Battery").unwrap_or(false); + if !is_battery { + continue; + } + let Some(value) = read_value(&dir.join("capacity")) else { + continue; + }; + let mut item = sensor_item("Battery", "%", value, None, None, "battery"); + if let Some(status) = read_string(&dir.join("status")) { + item.as_object_mut().unwrap().insert("status".to_string(), json!(status)); + } + stats.push(item); + } + stats +} + +fn sensor_item(label: &str, unit: &str, value: f64, warning: Option, critical: Option, r#type: &str) -> Value { + json!({ + "label": label, + "unit": unit, + "value": value, + "warning": warning, + "critical": critical, + "type": r#type, + "key": "label", + }) +} + +fn read_string(path: &std::path::Path) -> Option { + std::fs::read_to_string(path).ok().map(|v| v.trim().to_string()) +} + +fn read_value(path: &std::path::Path) -> Option { + read_string(path).and_then(|v| v.parse().ok()) +} + +fn read_label_value(dir: &std::path::Path, file: &str) -> Option { + read_value(&dir.join(file)) +} + +fn read_label(dir: &std::path::Path, file: &str, default: &str) -> String { + read_string(&dir.join(file)).unwrap_or_else(|| default.to_string()) +} + +impl Plugin for SensorsPlugin { + fn name(&self) -> &str { + "sensors" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn get_key(&self) -> Option<&'static str> { + Some("label") + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let mut stats = read_hwmon(); + stats.extend(read_batteries()); + // Apply show/hide filters and aliases. + stats.retain(|stat| { + let label = stat.get("label").and_then(Value::as_str).unwrap_or("").to_lowercase(); + self.model.is_display(&label, "") + }); + for stat in stats.iter_mut() { + if let (Some(label), Some(r#type)) = ( + stat.get("label").and_then(Value::as_str).map(|s| s.to_lowercase()), + stat.get("type").and_then(Value::as_str).map(|s| s.to_lowercase()), + ) { + if let Some(alias) = self.model.has_alias(&format!("{}_{}", label, r#type)) { + stat.as_object_mut().unwrap().insert("label".to_string(), json!(alias)); + } else if let Some(alias) = self.model.has_alias(&label) { + stat.as_object_mut().unwrap().insert("label".to_string(), json!(alias)); + } + } + } + // Sort by label. + stats.sort_by_key(|stat| stat.get("label").and_then(Value::as_str).unwrap_or("").to_lowercase()); + Ok(Value::Array(stats)) + } + + fn update_views(&mut self) { + let key = self.get_key(); + self.model.update_views(key); + let items: Vec<(String, String, f64, Option, Option)> = match &self.model.stats { + Value::Array(list) => list + .iter() + .filter_map(|item| { + let label = item.get("label")?.as_str()?.to_string(); + let r#type = item.get("type")?.as_str()?.to_string(); + let value = item.get("value")?.as_f64()?; + let warning = item.get("warning").and_then(Value::as_f64); + let critical = item.get("critical").and_then(Value::as_f64); + Some((label, r#type, value, warning, critical)) + }) + .collect(), + _ => Vec::new(), + }; + for (label, r#type, value, warning, critical) in items { + let decoration = if r#type == "temperature_core" { + if warning.is_some() || critical.is_some() { + system_thresholds(value, warning, critical) + } else { + self.model.get_alert(value, 0.0, 100.0, true, false, Some(&label), Some(&r#type), false) + } + } else if r#type == "battery" { + self.model.get_alert(100.0 - value, 0.0, 100.0, true, false, Some(&r#type), None, false) + } else { + self.model.get_alert(value, 0.0, 100.0, true, false, Some(&r#type), None, false) + }; + self.model.set_view_decoration(Some(&label), "value", decoration); + } + } + + fn display_curse(&self) -> bool { + true + } +} + +fn system_thresholds(value: f64, warning: Option, critical: Option) -> Decoration { + match critical { + None => Decoration::Default, + Some(critical) if value >= critical => Decoration::Critical, + Some(_) => match warning { + None => Decoration::Default, + Some(warning) if value >= warning => Decoration::Warning, + Some(_) => Decoration::Ok, + }, + } +} diff --git a/crates/glances-plugins/src/plugins/system.rs b/crates/glances-plugins/src/plugins/system.rs new file mode 100644 index 0000000..2a0b2b1 --- /dev/null +++ b/crates/glances-plugins/src/plugins/system.rs @@ -0,0 +1,85 @@ +//! Host/system information plugin. + +use crate::fields; +use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct SystemPlugin { + pub model: PluginModel, +} + +impl SystemPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let fields = fields! { + "os_name" => Field::new("Operating system name"), + "hostname" => Field::new("Hostname"), + "platform" => Field::new("Platform (32 or 64 bits)"), + "linux_distro" => Field::new("Linux distribution"), + "os_version" => Field::new("Operating system version"), + "hr_name" => Field::new("Human readable operating system name"), + }; + let model = PluginModel::new( + "system", + fields, + Vec::new(), + json!({}), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for SystemPlugin { + fn name(&self) -> &str { + "system" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let os_name = "Linux"; + let hostname = crate::collectors::hostname(); + let platform = format!("{}bit", std::mem::size_of::() * 8); + let os_version = std::fs::read_to_string("/proc/sys/kernel/osrelease") + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + let (distro_name, distro_version) = crate::collectors::read_os_release(); + let linux_distro = if distro_version.is_empty() { + distro_name.clone() + } else { + format!("{distro_name} {distro_version}") + }; + let hr_name = format!("{linux_distro} {platform} / {os_name} {os_version}"); + Ok(json!({ + "os_name": os_name, + "hostname": hostname, + "platform": platform, + "linux_distro": linux_distro, + "os_version": os_version, + "hr_name": hr_name, + })) + } + + fn display_curse(&self) -> bool { + true + } + + fn align(&self) -> glances_core::Align { + glances_core::Align::Right + } +} + +/// Logical core count helper shared by the load plugin. +pub fn log_core() -> usize { + crate::collectors::logical_core_count() +} diff --git a/crates/glances-plugins/src/plugins/uptime.rs b/crates/glances-plugins/src/plugins/uptime.rs new file mode 100644 index 0000000..cd7e973 --- /dev/null +++ b/crates/glances-plugins/src/plugins/uptime.rs @@ -0,0 +1,70 @@ +//! Uptime plugin. + +use crate::fields; +use glances_core::plugin::{Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +pub struct UptimePlugin { + pub model: PluginModel, +} + +impl UptimePlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let model = PluginModel::new( + "uptime", + fields! {}, + Vec::new(), + Value::String(String::new()), + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +/// Format a duration in seconds the way Python's `str(timedelta)` does. +pub fn format_uptime(seconds: f64) -> String { + let total = seconds.max(0.0) as i64; + let days = total / 86400; + let hours = (total % 86400) / 3600; + let minutes = (total % 3600) / 60; + let secs = total % 60; + if days > 0 { + format!( + "{} day{}, {}:{:02}:{:02}", + days, + if days > 1 { "s" } else { "" }, + hours, + minutes, + secs + ) + } else { + format!("{hours}:{minutes:02}:{secs:02}") + } +} + +impl Plugin for UptimePlugin { + fn name(&self) -> &str { + "uptime" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + let uptime = crate::collectors::uptime_seconds().unwrap_or(0.0); + Ok(json!(format_uptime(uptime))) + } + + fn display_curse(&self) -> bool { + true + } +} diff --git a/crates/glances-plugins/src/plugins/version.rs b/crates/glances-plugins/src/plugins/version.rs new file mode 100644 index 0000000..25f4dce --- /dev/null +++ b/crates/glances-plugins/src/plugins/version.rs @@ -0,0 +1,48 @@ +//! Glances version plugin. + +use crate::fields; +use glances_core::plugin::{Plugin, PluginModel, UpdateContext}; +use glances_core::{Args, Config}; +use serde_json::{Value, json}; + +/// Version reported to be compatible with the Python Glances implementation +/// this rewrite is based on. +pub const COMPAT_VERSION: &str = "4.5.7"; + +pub struct VersionPlugin { + pub model: PluginModel, +} + +impl VersionPlugin { + pub fn new(config: &Config, args: &Args) -> Self { + let model = PluginModel::new( + "version", + fields! {}, + Vec::new(), + Value::Null, + config, + args.time.unwrap_or(2.0), + args.disable_history, + args.disable_config_exec, + ); + Self { model } + } +} + +impl Plugin for VersionPlugin { + fn name(&self) -> &str { + "version" + } + + fn model(&self) -> &PluginModel { + &self.model + } + + fn model_mut(&mut self) -> &mut PluginModel { + &mut self.model + } + + fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result { + Ok(json!(COMPAT_VERSION)) + } +} diff --git a/crates/glances-tui/Cargo.toml b/crates/glances-tui/Cargo.toml new file mode 100644 index 0000000..b7d9e6a --- /dev/null +++ b/crates/glances-tui/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "glances-tui" +version = "0.1.0" +edition = "2024" + +[dependencies] +crossterm = "0.29.0" +glances-core = { version = "0.1.0", path = "../glances-core" } +glances-plugins = { version = "0.1.0", path = "../glances-plugins" } +indexmap.workspace = true +ratatui = "0.30.2" +serde_json.workspace = true +tracing.workspace = true diff --git a/crates/glances-tui/src/lib.rs b/crates/glances-tui/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/crates/glances-tui/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/tools/glances_oracle.py b/tools/glances_oracle.py new file mode 100644 index 0000000..cef0289 --- /dev/null +++ b/tools/glances_oracle.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Glances (Python) stats oracle. + +Prints one JSON line per iteration with the raw stats of the requested +plugins. It uses the Python Glances plugin code directly so that the +comparison harness can observe the same number of iterations as the Rust +implementation (`--stdout-json` exits after one line in Glances 4.5.7). + +Usage: + tools/glances_oracle.py --iterations N --refresh T --plugins cpu,mem +""" + +import argparse +import json +import sys +import time + +parser = argparse.ArgumentParser() +parser.add_argument("--iterations", type=int, default=3) +parser.add_argument("--refresh", type=float, default=1.0) +parser.add_argument("--plugins", required=True) +parser.add_argument("--config", default=None) +args = parser.parse_args() + +plugins = [p.strip() for p in args.plugins.split(",") if p.strip()] + +sys.argv = ["glances", "-t", str(args.refresh), "--disable-plugin", "all", "--enable-plugin", args.plugins] +if args.config: + sys.argv += ["-C", args.config] + +from glances.main import GlancesMain # noqa: E402 +from glances.stats import GlancesStats # noqa: E402 + +core = GlancesMain() +stats = GlancesStats(config=core.get_config(), args=core.get_args()) + +for _ in range(args.iterations): + start = time.time() + stats.update() + line = {} + for name in plugins: + plugin = stats.get_plugin(name) + if plugin is not None: + line[name] = plugin.get_raw() + print(json.dumps(line), flush=True) + elapsed = time.time() - start + time.sleep(max(0.0, args.refresh - elapsed)) diff --git a/tools/parity.py b/tools/parity.py new file mode 100644 index 0000000..3d54e89 --- /dev/null +++ b/tools/parity.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Differential test harness: compares glances-rs stdout-json output with the +Python Glances implementation (the reference oracle). + +Usage: + tools/parity.py [--rust BIN] [--python BIN] [--iterations N] [plugin ...] + +With no plugin list, a default set of core plugins is tested. +""" + +import argparse +import json +import os +import subprocess +import sys + +DEFAULT_PLUGINS = [ + "cpu", + "percpu", + "mem", + "memswap", + "load", + "fs", + "network", + "diskio", + "sensors", + "now", + "uptime", + "system", + "processcount", + "version", + "psutilversion", + "quicklook", +] + + +def flatten(value, prefix=""): + out = {} + if isinstance(value, dict): + for key, item in value.items(): + out.update(flatten(item, f"{prefix}{key}.")) + elif isinstance(value, list): + key_field = value[0].get("key") if value and isinstance(value[0], dict) else None + for index, item in enumerate(value): + identity = item.get(key_field) if key_field and isinstance(item, dict) else index + out.update(flatten(item, f"{prefix}{identity}.")) + else: + out[prefix[:-1]] = value + return out + + +def type_name(value): + if value is None: + return "null" + if isinstance(value, bool): + return "bool" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "array" + if isinstance(value, dict): + return "object" + return type(value).__name__ + + +def run(binary, plugins, iterations, extra): + plugins_arg = ",".join(plugins) + cmd = [ + binary, + "--stdout-json", + plugins_arg, + "--disable-plugin", + "all", + "--enable-plugin", + plugins_arg, + "--stop-after", + str(iterations), + "-t", + "1", + "--disable-check-update", + ] + extra + env = dict(os.environ) + env["PYTHONUNBUFFERED"] = "1" + result = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=120) + if result.returncode != 0: + return None, result.stderr.strip().splitlines()[-3:] + lines = [line for line in result.stdout.splitlines() if line.strip()] + if not lines: + return None, ["no output"] + return json.loads(lines[-1]), None + + +ORACLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "glances_oracle.py") + + +def run_python(python, conf, plugins, iterations, extra): + plugins_arg = ",".join(plugins) + cmd = [ + python, + ORACLE, + "--iterations", + str(iterations), + "--refresh", + "1", + "--plugins", + plugins_arg, + ] + if conf: + cmd += ["--config", conf] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if result.returncode != 0: + return None, result.stderr.strip().splitlines()[-3:] + lines = [line for line in result.stdout.splitlines() if line.strip()] + if not lines: + return None, ["no output"] + return json.loads(lines[-1]), None + + +def compare(plugin, rust, python, errors): + rust_plugin = rust.get(plugin, {}) + python_plugin = python.get(plugin, {}) + rust_flat = flatten(rust_plugin) + python_flat = flatten(python_plugin) + + if plugin not in rust: + errors.append(f"{plugin}: missing in Rust output") + return + if plugin not in python: + return + + for key in sorted(set(rust_flat) | set(python_flat)): + if key not in python_flat: + errors.append(f"{plugin}.{key}: only in Rust ({rust_flat[key]!r})") + elif key not in rust_flat: + errors.append(f"{plugin}.{key}: only in Python ({python_flat[key]!r})") + else: + rust_type = type_name(rust_flat[key]) + python_type = type_name(python_flat[key]) + if rust_type != python_type and not ( + rust_type == "null" or python_type == "null" + ): + errors.append( + f"{plugin}.{key}: type mismatch Rust={rust_type} Python={python_type} " + f"({rust_flat[key]!r} vs {python_flat[key]!r})" + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--rust", default="./target/debug/glances") + parser.add_argument("--python", default="/tmp/opencode/glances-py/bin/python") + parser.add_argument("--config", default="conf/glances.conf") + parser.add_argument("--iterations", type=int, default=3) + parser.add_argument("plugins", nargs="*") + args = parser.parse_args() + + plugins = args.plugins or DEFAULT_PLUGINS + errors = [] + for plugin in plugins: + extra = ["-C", args.config] if args.config else [] + rust, rust_error = run(args.rust, [plugin], args.iterations, extra) + if rust_error is not None: + errors.append(f"{plugin}: Rust run failed: {rust_error}") + continue + python, python_error = run_python(args.python, args.config, [plugin], args.iterations, []) + if python_error is not None: + errors.append(f"{plugin}: Python run failed: {python_error}") + continue + compare(plugin, rust, python, errors) + status = "OK" if not any(e.startswith(f"{plugin}.") or e.startswith(f"{plugin}:") for e in errors) else "DIFF" + print(f" {plugin}: {status}") + + if errors: + print("\nDifferences:") + for error in errors: + print(f" - {error}") + return 1 + print("\nNo differences found") + return 0 + + +if __name__ == "__main__": + sys.exit(main())