From 08fb41b33778671e36559b3a32134f32c05f2067 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 4 Aug 2026 21:45:32 +0200 Subject: [PATCH 01/14] server: expose the per-language stat counts `PerLanguageCount` keeps its counts private and only offers lookups by a single key, which is enough for the text output in this module but not for callers that want to iterate over every language, such as the monitor UI added next. Add `counts()` and `adv_counts()` accessors. --- src/server.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/server.rs b/src/server.rs index 2f142684a..6b592ad0c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1699,6 +1699,16 @@ impl PerLanguageCount { self.adv_counts.get(key) } + /// The per-language counts, keyed by language. + pub fn counts(&self) -> &HashMap { + &self.counts + } + + /// The per-language-and-compiler counts, keyed by `language (compiler)`. + pub fn adv_counts(&self) -> &HashMap { + &self.adv_counts + } + pub fn new() -> PerLanguageCount { Self::default() } From 4de95e8e4253e5e7e1ca46c78b60caca5a07884f Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 4 Aug 2026 21:45:43 +0200 Subject: [PATCH 02/14] monitor: add a live terminal dashboard for the running server `--show-stats` gives a snapshot; watching a build meant re-running it in a loop and diffing the numbers by eye. Add `sccache --monitor`, a Ratatui dashboard that attaches to a running server and refreshes while you build, with `--monitor-interval SECS` to set the poll rate. It is an ordinary client, using only the existing GetStats, ZeroStats and DistStatus requests, so it can attach to a server started by anything else and can be opened and closed at any point in a build. If no server is running it does not start one: it shows `disconnected` and attaches when one appears, surviving a restart underneath it. A poller thread does the blocking requests and feeds the UI thread over a channel, so the interface stays responsive whatever the poll interval. The server only exposes cumulative counters, so per-second rates come from diffing consecutive samples; a counter going backwards means the stats were zeroed or the server restarted, and the history is cleared rather than showing a spike. Five panes: overall rates and counters, per-language (or per-compiler) hits and misses, non-cacheable and distributed-compile reasons, cache location and levels, and dist status. Dist status is only polled while its pane is visible, since it can mean a round trip to the scheduler. Each poll opens a connection and closes it again. Holding it open would stall `--stop-server` for the server's whole drain timeout, because the server waits for connected clients to go away before exiting. What cannot be avoided is that any request resets the idle-shutdown timer, so a monitored server will not idle out; `p` pauses polling. The UI is behind the non-default `monitor` feature, so neither the default nor the `all` feature set pulls Ratatui in, and without it the command reports that the UI was not compiled in. The lockfile picks up newer patch releases of a few crates shared with the new dependency graph. --- .github/workflows/ci.yml | 2 +- Cargo.lock | 1131 ++++++++++++++++++++++++++++---- Cargo.toml | 3 + src/cmdline.rs | 38 ++ src/commands.rs | 16 + src/lib.rs | 2 + src/monitor.rs | 1349 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 2421 insertions(+), 120 deletions(-) create mode 100644 src/monitor.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4d5fded3..85b889063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - feature: [ azure, gcs, gha, memcached, redis, s3, webdav ] + feature: [ azure, gcs, gha, memcached, monitor, redis, s3, webdav ] steps: - name: Clone repository uses: actions/checkout@v5 diff --git a/Cargo.lock b/Cargo.lock index 8ac20c060..e2e1374cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "cipher", "cpufeatures", ] @@ -37,6 +37,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -165,7 +171,16 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -198,7 +213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if 1.0.0", + "cfg-if 1.0.4", "libc", "miniz_oxide", "object", @@ -250,6 +265,21 @@ dependencies = [ "serde", ] +[[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" @@ -258,9 +288,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake3" @@ -271,7 +301,7 @@ dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if 1.0.0", + "cfg-if 1.0.4", "constant_time_eq", ] @@ -320,6 +350,18 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[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 = "byteorder" version = "1.5.0" @@ -332,6 +374,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -360,9 +411,9 @@ checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -431,7 +482,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -482,7 +533,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -491,7 +542,7 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d9de586cc7e9752fc232f08e0733c2016122e16065c4adf0c8a8d9e370749ee" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "clap", "codspeed", "condtype", @@ -530,6 +581,20 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if 1.0.4", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "condtype" version = "1.3.0" @@ -588,6 +653,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[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" version = "0.9.4" @@ -655,16 +729,49 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3a430a770ebd84726f584a90ee7f020d28db52c6d02138900f22341f866d39c" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "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]] @@ -683,6 +790,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "ctor" version = "0.2.9" @@ -690,7 +807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -702,6 +819,47 @@ dependencies = [ "libc", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.8" @@ -715,11 +873,30 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "powerfmt", + "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.106", ] [[package]] @@ -769,7 +946,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -780,7 +957,7 @@ checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -798,6 +975,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[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.9.0" @@ -810,7 +996,7 @@ version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", ] [[package]] @@ -844,12 +1030,31 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.1", +] + +[[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]] @@ -858,18 +1063,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.56", + "winapi", +] + [[package]] name = "filetime" version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "libc", "redox_syscall", "windows-sys 0.52.0", ] +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.5" @@ -907,6 +1135,12 @@ 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 = "foreign-types" version = "0.3.2" @@ -996,7 +1230,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1045,7 +1279,7 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", @@ -1058,7 +1292,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi", @@ -1145,6 +1379,22 @@ 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" @@ -1188,7 +1438,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "libc", "windows-link 0.2.0", ] @@ -1427,6 +1677,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1458,6 +1714,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.3" @@ -1468,6 +1733,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1522,7 +1800,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1574,6 +1852,23 @@ dependencies = [ "simple_asn1", ] +[[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.17", +] + +[[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.4.0" @@ -1585,9 +1880,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -1612,10 +1907,19 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1628,12 +1932,24 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.11" @@ -1650,19 +1966,38 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "md-5" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "digest", ] @@ -1681,6 +2016,21 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1697,6 +2047,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1714,6 +2070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.61.1", ] @@ -1776,18 +2133,41 @@ dependencies = [ "void", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", - "cfg-if 1.0.0", + "bitflags 2.13.1", + "cfg-if 1.0.4", "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 = "normalize-line-endings" version = "0.3.0" @@ -1822,9 +2202,20 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +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.106", +] [[package]] name = "num-integer" @@ -1938,8 +2329,8 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.9.4", - "cfg-if 1.0.0", + "bitflags 2.13.1", + "cfg-if 1.0.4", "foreign-types", "libc", "once_cell", @@ -1955,7 +2346,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1992,6 +2383,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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 = "ordered-multimap" version = "0.7.0" @@ -2002,69 +2402,196 @@ dependencies = [ "hashbrown 0.14.3", ] +[[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.106", +] + +[[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.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" +dependencies = [ + "base64 0.21.7", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ - "lock_api", - "parking_lot_core", + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "parking_lot_core" -version = "0.9.9" +name = "pest_meta" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ - "cfg-if 1.0.0", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.48.5", + "pest", ] [[package]] -name = "paste" -version = "1.0.15" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] [[package]] -name = "pbkdf2" -version = "0.12.2" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "digest", - "hmac", + "phf_generator", + "phf_shared", ] [[package]] -name = "pem" -version = "3.0.3" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "base64 0.21.7", - "serde", + "phf_shared", + "rand 0.8.5", ] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "phf_macros" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "base64ct", + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "percent-encoding" -version = "2.3.1" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] [[package]] name = "pin-project" @@ -2083,7 +2610,7 @@ checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2144,9 +2671,9 @@ checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -2246,7 +2773,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2411,6 +2938,107 @@ dependencies = [ "simplelog", ] +[[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.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.17", + "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 1.0.4", + "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.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redis" version = "0.32.7" @@ -2420,7 +3048,7 @@ dependencies = [ "arc-swap", "backon", "bytes", - "cfg-if 1.0.0", + "cfg-if 1.0.4", "combine", "crc16", "futures-channel", @@ -2658,7 +3286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" dependencies = [ "cc", - "cfg-if 1.0.0", + "cfg-if 1.0.4", "getrandom 0.2.11", "libc", "untrusted", @@ -2714,7 +3342,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d625ed57d8f49af6cfa514c42e1a71fadcff60eb0b1c517ff82fe41aa025b41" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "ordered-multimap", "trim-in-place", ] @@ -2752,13 +3380,26 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.12", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.1", +] + [[package]] name = "rustls" version = "0.23.10" @@ -2931,6 +3572,7 @@ dependencies = [ "openssl", "predicates", "rand 0.8.5", + "ratatui", "regex", "reqsign 0.18.0", "reqwest", @@ -3015,7 +3657,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3065,7 +3707,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3090,7 +3732,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3136,7 +3778,7 @@ checksum = "a9bb72430492e9549b0c4596725c0f82729bff861c45aa8099c0a8e67fc3b721" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3145,7 +3787,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "cpufeatures", "digest", ] @@ -3162,7 +3804,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "cpufeatures", "digest", ] @@ -3173,6 +3815,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -3221,6 +3884,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.9" @@ -3287,6 +3956,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "statrs" version = "0.18.0" @@ -3321,12 +3996,44 @@ 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.106", +] + [[package]] name = "subtle" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +[[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.106" @@ -3355,7 +4062,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3376,7 +4083,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3417,9 +4124,9 @@ version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "fastrand", - "rustix", + "rustix 0.38.34", "windows-sys 0.52.0", ] @@ -3432,22 +4139,98 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.1", +] + [[package]] name = "terminal_size" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" dependencies = [ - "rustix", + "rustix 0.38.34", "windows-sys 0.48.0", ] +[[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 = "termtree" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "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.56", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "test-case" version = "3.3.1" @@ -3463,10 +4246,10 @@ version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3477,7 +4260,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "test-case-core", ] @@ -3491,7 +4274,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", - "cfg-if 1.0.0", + "cfg-if 1.0.4", "const_format", "futures-util", "http", @@ -3517,7 +4300,7 @@ checksum = "5cf0ffc3ba4368e99597bd6afd83f4ff6febad66d9ae541ab46e697d32285fc0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3546,7 +4329,7 @@ checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3557,7 +4340,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3571,32 +4354,31 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3675,7 +4457,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3797,7 +4579,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -3840,7 +4622,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3891,6 +4673,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.7.0" @@ -3906,6 +4694,29 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[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 = "unicode-xid" version = "0.2.6" @@ -3947,6 +4758,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "atomic", "getrandom 0.3.3", "js-sys", "serde", @@ -3997,6 +4809,15 @@ dependencies = [ "quote", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.0" @@ -4055,7 +4876,7 @@ version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -4072,7 +4893,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -4082,7 +4903,7 @@ version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac36a15a220124ac510204aec1c3e5db8a22ab06fd6706d881dc6149f8ed9a12" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "js-sys", "wasm-bindgen", "web-sys", @@ -4106,7 +4927,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4171,6 +4992,78 @@ dependencies = [ "rustls-pki-types", ] +[[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.3", + "mac_address", + "sha2", + "thiserror 1.0.56", + "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.56", + "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 = "which" version = "6.0.3" @@ -4179,7 +5072,7 @@ checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" dependencies = [ "either", "home", - "rustix", + "rustix 0.38.34", "winsafe", ] @@ -4455,8 +5348,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914566e6413e7fa959cc394fb30e563ba80f3541fbd40816d4c05a0fc3f2a0f1" dependencies = [ "libc", - "linux-raw-sys", - "rustix", + "linux-raw-sys 0.4.12", + "rustix 0.38.34", ] [[package]] @@ -4478,7 +5371,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -4499,7 +5392,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -4539,7 +5432,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 683a3bd00..c295417bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ object = "0.37" opendal = { version = "0.55.0", optional = true, default-features = false } openssl = { version = "0.10.75", optional = true } rand = "0.8.4" +ratatui = { version = "0.30", optional = true } regex = "1.10.3" reqsign = { version = "0.18.0", optional = true } reqwest = { version = "0.12", features = [ @@ -185,6 +186,8 @@ default = ["all"] gcs = ["opendal/services-gcs", "reqsign", "url", "reqwest"] gha = ["opendal/services-ghac", "reqwest"] memcached = ["opendal/services-memcached"] +# Enables the `sccache --monitor` terminal dashboard +monitor = ["ratatui"] native-zlib = [] oss = ["opendal/services-oss", "reqsign", "reqwest"] redis = ["url", "opendal/services-redis"] diff --git a/src/cmdline.rs b/src/cmdline.rs index f639396e7..cc21f5f2d 100644 --- a/src/cmdline.rs +++ b/src/cmdline.rs @@ -18,10 +18,16 @@ use std::env; use std::ffi::OsString; use std::path::PathBuf; use std::str::FromStr; +use std::time::Duration; use which::which_in; const ENV_VAR_INTERNAL_START_SERVER: &str = "SCCACHE_START_SERVER"; +/// Accepted range of `--monitor-interval`, in seconds. Kept in step with the +/// `MIN_INTERVAL`/`MAX_INTERVAL` bounds the monitor enforces for `+`/`-`. +const MIN_MONITOR_INTERVAL: f64 = 0.2; +const MAX_MONITOR_INTERVAL: f64 = 60.0; + #[derive(Debug, Clone, ValueEnum, Default)] pub enum StatsFormat { #[default] @@ -54,6 +60,10 @@ impl FromStr for StatsFormat { pub enum Command { /// Show cache statistics and exit. ShowStats(StatsFormat, bool), + /// Watch cache statistics in a terminal dashboard, polling every `interval`. + Monitor { + interval: Duration, + }, /// Run background server. InternalStartServer, /// Start background server as a subprocess. @@ -123,6 +133,9 @@ fn get_clap_command() -> clap::Command { "\n", " COS: ", cfg!(feature = "cos"), + "\n", + " Monitor: ", + cfg!(feature = "monitor"), "\n" )) .args(&[ @@ -132,6 +145,14 @@ fn get_clap_command() -> clap::Command { flag_infer_long("show-adv-stats") .help("show advanced cache statistics") .action(ArgAction::SetTrue), + flag_infer_long("monitor") + .help("watch cache statistics live in a terminal dashboard") + .action(ArgAction::SetTrue), + flag_infer_long("monitor-interval") + .help("polling interval of `--monitor`, in seconds (0.2 to 60)") + .value_name("SECS") + .value_parser(clap::value_parser!(f64)) + .default_value("1"), flag_infer_long("start-server") .help("start background server") .action(ArgAction::SetTrue), @@ -173,6 +194,7 @@ fn get_clap_command() -> clap::Command { "dist-status", "show-stats", "show-adv-stats", + "monitor", "start-server", "stop-server", "zero-stats", @@ -271,6 +293,22 @@ pub fn try_parse() -> Result { .cloned() .expect("There is a default value"); Ok(Command::ShowStats(fmt, true)) + } else if matches.get_flag("monitor") { + let secs: f64 = matches + .get_one("monitor-interval") + .copied() + .expect("There is a default value"); + if !(secs.is_finite() + && (MIN_MONITOR_INTERVAL..=MAX_MONITOR_INTERVAL).contains(&secs)) + { + bail!( + "`--monitor-interval` must be between {MIN_MONITOR_INTERVAL} and \ + {MAX_MONITOR_INTERVAL} seconds" + ); + } + Ok(Command::Monitor { + interval: Duration::from_secs_f64(secs), + }) } else if matches.get_flag("start-server") { Ok(Command::StartServer) } else if matches.get_flag("debug-preprocessor-cache") { diff --git a/src/commands.rs b/src/commands.rs index 5ea8b949c..f4fdc0855 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -736,6 +736,22 @@ pub fn run_command(cmd: Command) -> Result { StatsFormat::Json => serde_json::to_writer(&mut io::stdout(), &stats)?, } } + Command::Monitor { interval } => { + trace!("Command::Monitor({:?})", interval); + #[cfg(feature = "monitor")] + { + // Attach to whatever server is running; the monitor keeps + // retrying if there is none yet, so don't start one here. + crate::monitor::run(get_addr(), interval)?; + } + #[cfg(not(feature = "monitor"))] + { + let _ = interval; + bail!( + "The monitor UI was not compiled in, please rebuild with the `monitor` feature" + ); + } + } Command::DebugPreprocessorCacheEntries => { trace!("Command::DebugPreprocessorCacheEntries"); let entries_dir = default_disk_cache_dir().join("preprocessor"); diff --git a/src/lib.rs b/src/lib.rs index d5aa6a603..f0bb4d512 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,8 @@ pub mod dist; mod jobserver; pub mod lru_disk_cache; mod mock_command; +#[cfg(feature = "monitor")] +mod monitor; mod net; mod protocol; pub mod server; diff --git a/src/monitor.rs b/src/monitor.rs new file mode 100644 index 000000000..ad6675de1 --- /dev/null +++ b/src/monitor.rs @@ -0,0 +1,1349 @@ +// Copyright 2026 Mozilla Foundation +// +// 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. + +//! A live terminal dashboard that attaches to a running sccache server. +//! +//! The monitor is a plain client: it connects to the daemon over the usual +//! socket and polls `Request::GetStats` (and, on the dist tab, +//! `Request::DistStatus`). Counters are cumulative, so per-second rates are +//! derived by diffing consecutive snapshots. If the server goes away the +//! monitor keeps running and reconnects when it comes back. +//! +//! Note that every request resets the server's idle-shutdown timer, so a +//! server being watched won't shut down on its own. Press `p` to pause polling +//! if that matters. + +use std::collections::{HashMap, VecDeque}; +use std::io; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +use number_prefix::NumberPrefix; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, BorderType, Cell, Clear, Gauge, LineGauge, Paragraph, Row, Sparkline, Table, Tabs, Wrap, +}; +use ratatui::{DefaultTerminal, Frame}; + +use crate::client::{ServerConnection, connect_to_server}; +use crate::errors::*; +use crate::protocol::{Request, Response}; +use crate::server::{DistInfo, PerLanguageCount, ServerInfo, ServerStats}; +use crate::util::fmt_duration_as_secs; + +/// How many samples of history to keep for the sparklines. +const HISTORY: usize = 512; +/// Bounds on the poll interval, adjustable with `+`/`-`. +const MIN_INTERVAL: Duration = Duration::from_millis(200); +const MAX_INTERVAL: Duration = Duration::from_secs(60); +/// How long a transient status message stays on screen. +const MESSAGE_TTL: Duration = Duration::from_secs(4); +/// Longest the UI goes without a repaint when nothing has changed, so that the +/// elapsed times in the status bar keep moving. +const REDRAW_INTERVAL: Duration = Duration::from_millis(500); + +const TABS: [&str; 5] = ["Overview", "Languages", "Reasons", "Cache", "Dist"]; + +/// Run the monitor until the user quits. +pub fn run(addr: crate::net::SocketAddr, interval: Duration) -> Result<()> { + let interval = interval.clamp(MIN_INTERVAL, MAX_INTERVAL); + let (cmd_tx, cmd_rx) = mpsc::channel(); + let (sample_tx, sample_rx) = mpsc::channel(); + + let addr_display = addr.to_string(); + // The handle is dropped on purpose: the poller can be blocked in a request + // against a server that accepted the connection and never answered, and + // client sockets have no read timeout, so joining it could hang the exit + // forever with the terminal already restored and no UI left to quit. The + // thread owns nothing that needs unwinding, so let the process reap it. + thread::Builder::new() + .name("sccache-monitor-poll".into()) + .spawn(move || poll_loop(addr, interval, cmd_rx, sample_tx))?; + + let mut app = App::new(addr_display, interval); + install_panic_hook(); + let terminal = ratatui::init(); + let result = app.main_loop(terminal, &cmd_tx, &sample_rx); + ratatui::restore(); + + let _ = cmd_tx.send(Cmd::Quit); + result +} + +// --------------------------------------------------------------------------- +// Polling thread +// --------------------------------------------------------------------------- + +/// A message from the UI thread to the polling thread. +enum Cmd { + /// Poll right now instead of waiting for the next tick. + Refresh, + /// Change the polling interval. + SetInterval(Duration), + /// Stop or resume polling. + Pause(bool), + /// Also fetch dist status on each poll (only while the Dist tab is shown). + WantDist(bool), + /// Zero the server's statistics. + Zero, + /// Shut the polling thread down. + Quit, +} + +/// A message from the polling thread to the UI thread. +enum Sample { + Info(Box), + Dist(Box), + /// Stats were zeroed successfully. + Zeroed, + /// The last request failed. + Error(String), +} + +fn poll_loop( + addr: crate::net::SocketAddr, + mut interval: Duration, + rx: Receiver, + tx: Sender, +) { + let mut want_dist = false; + let mut paused = false; + // Set by `Cmd::Refresh`, so that an explicit refresh polls once even while + // polling is paused. + let mut force = false; + + loop { + if !paused || force { + force = false; + match request(&addr, Request::GetStats) { + Ok(Response::Stats(info)) => { + if tx.send(Sample::Info(info)).is_err() { + return; + } + if want_dist { + match request(&addr, Request::DistStatus) { + Ok(Response::DistStatus(info)) => { + if tx.send(Sample::Dist(Box::new(info))).is_err() { + return; + } + } + Ok(_) => {} + Err(e) => { + if tx.send(Sample::Error(format!("{e}"))).is_err() { + return; + } + } + } + } + } + Ok(other) => { + let msg = format!("unexpected response to GetStats: {other:?}"); + if tx.send(Sample::Error(msg)).is_err() { + return; + } + } + Err(e) => { + if tx.send(Sample::Error(format!("{e:#}"))).is_err() { + return; + } + } + } + } + + // Wait out the interval, reacting to commands as they arrive. + let deadline = Instant::now() + interval; + loop { + let timeout = deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(timeout) { + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) | Ok(Cmd::Quit) => return, + Ok(Cmd::Refresh) => { + force = true; + break; + } + Ok(Cmd::SetInterval(d)) => { + interval = d; + break; + } + Ok(Cmd::Pause(p)) => { + paused = p; + if !paused { + break; + } + } + Ok(Cmd::WantDist(w)) => { + want_dist = w; + if w { + break; + } + } + Ok(Cmd::Zero) => { + let sample = match request(&addr, Request::ZeroStats) { + Ok(Response::ZeroStats) => Sample::Zeroed, + Ok(other) => Sample::Error(format!("failed to zero stats: {other:?}")), + Err(e) => Sample::Error(format!("{e:#}")), + }; + if tx.send(sample).is_err() { + return; + } + break; + } + } + } + } +} + +/// Connect, send `req`, and hang up again. +/// +/// The connection is deliberately not kept open between polls: the server +/// waits for connected clients to go away before shutting down, so a monitor +/// holding a socket open would stall `--stop-server` for its whole drain +/// timeout. +fn request(addr: &crate::net::SocketAddr, req: Request) -> Result { + let mut conn: ServerConnection = + connect_to_server(addr).map_err(|e| anyhow!("no server listening on {addr}: {e}"))?; + conn.request(req) +} + +// --------------------------------------------------------------------------- +// Application state +// --------------------------------------------------------------------------- + +/// Per-second rates derived from two consecutive snapshots. +#[derive(Default, Clone, Copy)] +struct Rates { + requests: f64, + hits: f64, + misses: f64, + writes: f64, + compilations: f64, + errors: f64, +} + +struct App { + addr: String, + interval: Duration, + started: Instant, + tab: usize, + advanced: bool, + paused: bool, + help: bool, + /// `z` was pressed once; a second `z` zeroes the statistics. + confirm_zero: bool, + /// Latest snapshot, and the previous one used for rate computation. + info: Option, + prev: Option<(Instant, ServerStats)>, + dist: Option, + rates: Rates, + hist_requests: VecDeque, + hist_hits: VecDeque, + hist_misses: VecDeque, + updates: u64, + last_update: Option, + error: Option, + message: Option<(String, Instant)>, +} + +impl App { + fn new(addr: String, interval: Duration) -> Self { + App { + addr, + interval, + started: Instant::now(), + tab: 0, + advanced: false, + paused: false, + help: false, + confirm_zero: false, + info: None, + prev: None, + dist: None, + rates: Rates::default(), + hist_requests: VecDeque::with_capacity(HISTORY), + hist_hits: VecDeque::with_capacity(HISTORY), + hist_misses: VecDeque::with_capacity(HISTORY), + updates: 0, + last_update: None, + error: None, + message: None, + } + } + + fn main_loop( + &mut self, + mut terminal: DefaultTerminal, + cmds: &Sender, + samples: &Receiver, + ) -> Result<()> { + let mut dirty = true; + let mut drawn = Instant::now(); + loop { + // Redraw when something changed, and otherwise just often enough + // to keep the clocks in the status bar ticking: a full repaint at + // the input poll rate is wasteful, especially over ssh. + if dirty || drawn.elapsed() >= REDRAW_INTERVAL { + terminal.draw(|frame| self.draw(frame))?; + dirty = false; + drawn = Instant::now(); + } + + // Wait briefly for input so the UI stays responsive, then drain + // whatever the poller produced in the meantime. + if event::poll(Duration::from_millis(100))? { + // Anything the terminal reports (a resize in particular) needs + // a repaint, whether or not we act on it. + dirty = true; + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press && !self.on_key(key, cmds)? { + return Ok(()); + } + } + } + while let Ok(sample) = samples.try_recv() { + self.ingest(sample); + dirty = true; + } + if let Some((_, at)) = self.message { + if at.elapsed() > MESSAGE_TTL { + self.message = None; + dirty = true; + } + } + } + } + + /// Handle a key press. Returns `false` when the user asked to quit. + fn on_key(&mut self, key: KeyEvent, cmds: &Sender) -> Result { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + // Zeroing is irreversible, so it takes two presses of `z` in a row; + // any other key cancels the pending confirmation. + let confirming = std::mem::take(&mut self.confirm_zero); + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(false), + KeyCode::Char('c' | 'd') if ctrl => return Ok(false), + KeyCode::Char('?') | KeyCode::F(1) => self.help = !self.help, + KeyCode::Char('a') => self.advanced = !self.advanced, + KeyCode::Char('r') => { + let _ = cmds.send(Cmd::Refresh); + self.note("refreshing"); + } + KeyCode::Char('z') => { + if confirming { + let _ = cmds.send(Cmd::Zero); + } else { + self.confirm_zero = true; + self.note("press z again to zero the server's statistics"); + } + } + KeyCode::Char('p') | KeyCode::Char(' ') => { + self.paused = !self.paused; + let _ = cmds.send(Cmd::Pause(self.paused)); + self.note(if self.paused { + "polling paused" + } else { + "polling resumed" + }); + } + KeyCode::Char('+' | '=') => self.set_interval(self.interval * 2, cmds), + KeyCode::Char('-' | '_') => self.set_interval(self.interval / 2, cmds), + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + self.select_tab((self.tab + 1) % TABS.len(), cmds); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + self.select_tab((self.tab + TABS.len() - 1) % TABS.len(), cmds); + } + KeyCode::Char(c @ '1'..='5') => { + let idx = c as usize - '1' as usize; + self.select_tab(idx, cmds); + } + _ => {} + } + Ok(true) + } + + fn select_tab(&mut self, tab: usize, cmds: &Sender) { + if tab == self.tab { + return; + } + self.tab = tab; + self.help = false; + // Dist status can be an expensive round trip to the scheduler, so only + // ask for it while its tab is visible. + let _ = cmds.send(Cmd::WantDist(TABS[tab] == "Dist")); + } + + fn set_interval(&mut self, interval: Duration, cmds: &Sender) { + self.interval = interval.clamp(MIN_INTERVAL, MAX_INTERVAL); + let _ = cmds.send(Cmd::SetInterval(self.interval)); + self.note(format!("interval {}", fmt_interval(self.interval))); + } + + fn note>(&mut self, msg: S) { + self.message = Some((msg.into(), Instant::now())); + } + + fn ingest(&mut self, sample: Sample) { + match sample { + Sample::Info(info) => { + let now = Instant::now(); + let stats = &info.stats; + match self.prev.take() { + // A counter going backwards means the stats were zeroed or + // the server restarted: start the history over. + Some((_, prev)) if stats.compile_requests < prev.compile_requests => { + self.reset_history(); + } + Some((at, prev)) => { + let dt = now.duration_since(at).as_secs_f64(); + if dt > 0.0 { + let rate = |new: u64, old: u64| (new.saturating_sub(old)) as f64 / dt; + self.rates = Rates { + requests: rate(stats.compile_requests, prev.compile_requests), + hits: rate(stats.cache_hits.all(), prev.cache_hits.all()), + misses: rate(stats.cache_misses.all(), prev.cache_misses.all()), + writes: rate(stats.cache_writes, prev.cache_writes), + compilations: rate(stats.compilations, prev.compilations), + errors: rate( + stats.cache_errors.all() + + stats.cache_read_errors + + stats.cache_write_errors, + prev.cache_errors.all() + + prev.cache_read_errors + + prev.cache_write_errors, + ), + }; + push(&mut self.hist_requests, self.rates.requests); + push(&mut self.hist_hits, self.rates.hits); + push(&mut self.hist_misses, self.rates.misses); + } + } + None => {} + } + self.prev = Some((now, stats.clone())); + self.info = Some(*info); + self.updates += 1; + self.last_update = Some(now); + self.error = None; + } + Sample::Dist(info) => self.dist = Some(*info), + Sample::Zeroed => { + self.reset_history(); + // Drop the pre-zero snapshot too, otherwise the counters keep + // showing the old totals until the next poll lands. The poller + // re-polls immediately after zeroing, so this is one frame. + self.info = None; + self.note("statistics zeroed"); + } + Sample::Error(e) => { + self.prev = None; + self.rates = Rates::default(); + self.error = Some(e); + } + } + } + + fn reset_history(&mut self) { + self.prev = None; + self.rates = Rates::default(); + self.hist_requests.clear(); + self.hist_hits.clear(); + self.hist_misses.clear(); + } +} + +fn push(hist: &mut VecDeque, value: f64) { + // Sparklines take integers; keep two decimals of resolution. + if hist.len() == HISTORY { + hist.pop_front(); + } + hist.push_back((value.max(0.0) * 100.0).round() as u64); +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +impl App { + fn draw(&self, frame: &mut Frame<'_>) { + let [tabs, status, body, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .areas(frame.area()); + + self.draw_tabs(frame, tabs); + self.draw_status(frame, status); + match TABS[self.tab] { + "Languages" => self.draw_languages(frame, body), + "Reasons" => self.draw_reasons(frame, body), + "Cache" => self.draw_cache(frame, body), + "Dist" => self.draw_dist(frame, body), + _ => self.draw_overview(frame, body), + } + self.draw_footer(frame, footer); + if self.help { + draw_help(frame, body); + } + } + + fn draw_tabs(&self, frame: &mut Frame<'_>, area: Rect) { + let titles = TABS + .iter() + .enumerate() + .map(|(i, t)| Line::from(format!(" {}:{t} ", i + 1))); + frame.render_widget( + Tabs::new(titles) + .select(self.tab) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .divider(""), + area, + ); + } + + fn draw_status(&self, frame: &mut Frame<'_>, area: Rect) { + let (dot, state, colour) = if self.paused { + ("◼", "paused".to_string(), Color::Yellow) + } else if let Some(e) = &self.error { + ("○", format!("disconnected — {e}"), Color::Red) + } else if self.info.is_some() { + ("●", "connected".to_string(), Color::Green) + } else { + ("◌", "connecting…".to_string(), Color::Yellow) + }; + + let version = self + .info + .as_ref() + .map(|i| i.version.clone()) + .unwrap_or_else(|| "?".to_string()); + let age = self + .last_update + .map(|t| format!("{:.1}s ago", t.elapsed().as_secs_f64())) + .unwrap_or_else(|| "never".to_string()); + + let line = Line::from(vec![ + Span::styled(dot, Style::default().fg(colour)), + Span::raw(" "), + Span::styled(state, Style::default().fg(colour).bold()), + sep(), + Span::raw(self.addr.clone()), + sep(), + Span::raw(format!("server v{version}")), + sep(), + Span::raw(format!("every {}", fmt_interval(self.interval))), + sep(), + Span::raw(format!("last {age}")), + sep(), + Span::raw(format!("{} samples", self.updates)), + sep(), + Span::raw(format!("watching {}", fmt_uptime(self.started.elapsed()))), + ]); + + frame.render_widget(Paragraph::new(line).block(titled("sccache monitor")), area); + } + + fn draw_footer(&self, frame: &mut Frame<'_>, area: Rect) { + let text = match &self.message { + Some((msg, _)) => Line::from(vec![ + Span::styled(" ! ", Style::default().fg(Color::Black).bg(Color::Yellow)), + Span::raw(format!(" {msg}")), + ]), + None => Line::from( + " q quit · ?/F1 help · zz zero · p pause · a advanced · r refresh · +/- interval · 1-5/Tab panes", + ) + .style(Style::default().fg(Color::DarkGray)), + }; + frame.render_widget(Paragraph::new(text), area); + } + + fn draw_overview(&self, frame: &mut Frame<'_>, area: Rect) { + let [left, right] = + Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]) + .areas(area); + let [rates_area, counters_area] = + Layout::vertical([Constraint::Length(7), Constraint::Min(0)]).areas(left); + let [req_area, hit_area, miss_area, timing_area] = Layout::vertical([ + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Min(0), + ]) + .areas(right); + + self.draw_rates(frame, rates_area); + self.draw_counters(frame, counters_area); + self.draw_sparkline( + frame, + req_area, + "compile requests/s", + &self.hist_requests, + self.rates.requests, + Color::Cyan, + ); + self.draw_sparkline( + frame, + hit_area, + "cache hits/s", + &self.hist_hits, + self.rates.hits, + Color::Green, + ); + self.draw_sparkline( + frame, + miss_area, + "cache misses/s", + &self.hist_misses, + self.rates.misses, + Color::Magenta, + ); + self.draw_timings(frame, timing_area); + } + + fn draw_rates(&self, frame: &mut Frame<'_>, area: Rect) { + let block = titled("Live"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let [hit_area, size_area, rate_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas(inner); + + let (hits, misses) = self + .info + .as_ref() + .map(|i| (i.stats.cache_hits.all(), i.stats.cache_misses.all())) + .unwrap_or((0, 0)); + let total = hits + misses; + let ratio = if total == 0 { + 0.0 + } else { + hits as f64 / total as f64 + }; + frame.render_widget( + LineGauge::default() + .label(format!("hit rate {:>6.2} %", ratio * 100.0)) + .ratio(ratio) + .filled_style(Style::default().fg(hit_rate_colour(ratio, total))) + .unfilled_style(Style::default().fg(Color::DarkGray)), + hit_area, + ); + + let (size, max) = self + .info + .as_ref() + .map(|i| (i.cache_size, i.max_cache_size)) + .unwrap_or((None, None)); + frame.render_widget(cache_gauge(size, max), size_area); + + let r = &self.rates; + let lines = vec![ + Line::from(vec![ + kv("requests/s", format!("{:.2}", r.requests)), + Span::raw(" "), + kv("compiles/s", format!("{:.2}", r.compilations)), + ]), + Line::from(vec![ + kv("hits/s", format!("{:.2}", r.hits)), + Span::raw(" "), + kv("misses/s", format!("{:.2}", r.misses)), + ]), + Line::from(vec![ + kv("writes/s", format!("{:.2}", r.writes)), + Span::raw(" "), + Span::styled( + format!("errors/s {:.2}", r.errors), + Style::default().fg(if r.errors > 0.0 { + Color::Red + } else { + Color::DarkGray + }), + ), + ]), + ]; + frame.render_widget(Paragraph::new(lines), rate_area); + } + + fn draw_sparkline( + &self, + frame: &mut Frame<'_>, + area: Rect, + title: &str, + hist: &VecDeque, + current: f64, + colour: Color, + ) { + // `Sparkline` draws the *first* `width` samples it is handed and drops + // the rest, so give it only the most recent ones that fit; otherwise + // the plot freezes as soon as the history outgrows the pane. The peak + // is taken over the same window so that it matches what is on screen. + let width = area.width.saturating_sub(2) as usize; + let data: Vec = hist.iter().rev().take(width).rev().copied().collect(); + let peak = data.iter().copied().max().unwrap_or(0) as f64 / 100.0; + let heading = format!("{title} — now {current:.2}, peak {peak:.2}"); + frame.render_widget( + Sparkline::default() + .block(titled(&heading)) + .data(data) + .style(Style::default().fg(colour)), + area, + ); + } + + fn draw_counters(&self, frame: &mut Frame<'_>, area: Rect) { + let block = titled("Counters"); + let Some(info) = self.info.as_ref() else { + frame.render_widget( + placeholder("waiting for the first sample…").block(block), + area, + ); + return; + }; + let s = &info.stats; + let rows: Vec<(&str, u64, bool)> = vec![ + ("Compile requests", s.compile_requests, false), + ("Requests executed", s.requests_executed, false), + ("Cache hits", s.cache_hits.all(), false), + ("Cache misses", s.cache_misses.all(), false), + ("Cache writes", s.cache_writes, false), + ("Compilations", s.compilations, false), + ("Compilation failures", s.compile_fails, true), + ("Cache timeouts", s.cache_timeouts, true), + ("Cache read errors", s.cache_read_errors, true), + ("Cache write errors", s.cache_write_errors, true), + ("Cache errors", s.cache_errors.all(), true), + ("Forced recaches", s.forced_recaches, false), + ( + "Non-cacheable compilations", + s.non_cacheable_compilations, + false, + ), + ("Non-cacheable calls", s.requests_not_cacheable, false), + ("Non-compilation calls", s.requests_not_compile, false), + ( + "Unsupported compiler calls", + s.requests_unsupported_compiler, + false, + ), + ("Failed distributed compiles", s.dist_errors, true), + ]; + let rows = rows.into_iter().map(|(name, value, bad)| { + let style = if bad && value > 0 { + Style::default().fg(Color::Red) + } else if value == 0 { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + Row::new(vec![Cell::from(name), right(value.to_string())]).style(style) + }); + frame.render_widget( + Table::new(rows, [Constraint::Min(20), Constraint::Length(12)]).block(block), + area, + ); + } + + fn draw_timings(&self, frame: &mut Frame<'_>, area: Rect) { + let block = titled("Averages"); + let Some(info) = self.info.as_ref() else { + frame.render_widget(placeholder("no data yet").block(block), area); + return; + }; + let s = &info.stats; + let avg = |total: Duration, n: u64| { + if n == 0 { + "-".to_string() + } else { + fmt_duration_as_secs(&(total / n as u32)) + } + }; + let rows = [ + ( + "Average cache write", + avg(s.cache_write_duration, s.cache_writes), + ), + ( + "Average compiler", + avg(s.compiler_write_duration, s.compilations), + ), + ( + "Average cache read hit", + avg(s.cache_read_hit_duration, s.cache_hits.all()), + ), + ( + "Total time compiling", + fmt_duration_as_secs(&s.compiler_write_duration), + ), + ] + .into_iter() + .map(|(name, value)| Row::new(vec![Cell::from(name), right(value)])); + frame.render_widget( + Table::new(rows, [Constraint::Min(18), Constraint::Length(12)]).block(block), + area, + ); + } + + fn draw_languages(&self, frame: &mut Frame<'_>, area: Rect) { + let block = titled(if self.advanced { + "Per language and compiler (a: per language)" + } else { + "Per language (a: per compiler)" + }); + let Some(info) = self.info.as_ref() else { + frame.render_widget( + placeholder("waiting for the first sample…").block(block), + area, + ); + return; + }; + let langs = language_stats(&info.stats, self.advanced); + if langs.is_empty() { + frame.render_widget( + placeholder("no compilations recorded yet").block(block), + area, + ); + return; + } + + // Widest bar that fits in the last column, and the busiest language it + // is scaled against. The column gets whatever is left of the pane once + // the two borders, the 62 fixed columns of `LANGUAGE_COLUMNS` and the + // five inter-column spaces are taken out. + let bar_width = area.width.saturating_sub(69).clamp(0, 40) as usize; + let busiest = langs.iter().map(LangStat::total).max().unwrap_or(0).max(1); + + let header = Row::new(vec![ + Cell::from("Language"), + right("hits"), + right("misses"), + right("hit rate"), + right("errors"), + Cell::from("hits/misses"), + ]) + .style(Style::default().fg(Color::Cyan).bold()); + + frame.render_widget( + Table::new( + langs.iter().map(|l| l.row(busiest, bar_width)), + LANGUAGE_COLUMNS, + ) + .header(header) + .block(block), + area, + ); + } + + fn draw_reasons(&self, frame: &mut Frame<'_>, area: Rect) { + let [left, right] = + Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) + .areas(area); + let not_cached = titled("Non-cacheable reasons"); + let dist = titled("Successful distributed compiles"); + + let Some(info) = self.info.as_ref() else { + frame.render_widget( + placeholder("waiting for the first sample…").block(not_cached), + left, + ); + frame.render_widget(placeholder("").block(dist), right); + return; + }; + + frame.render_widget( + counted_table( + &info.stats.not_cached, + "nothing was rejected", + Color::Yellow, + ) + .block(not_cached), + left, + ); + frame.render_widget( + counted_table( + &info.stats.dist_compiles, + "no distributed compiles", + Color::Blue, + ) + .block(dist), + right, + ); + } + + fn draw_cache(&self, frame: &mut Frame<'_>, area: Rect) { + let block = titled("Cache"); + let Some(info) = self.info.as_ref() else { + frame.render_widget( + placeholder("waiting for the first sample…").block(block), + area, + ); + return; + }; + + let has_levels = info + .stats + .multi_level + .as_ref() + .is_some_and(|m| !m.0.is_empty()); + let [top, bottom] = if has_levels { + Layout::vertical([Constraint::Length(9), Constraint::Min(0)]).areas(area) + } else { + Layout::vertical([Constraint::Min(0), Constraint::Length(0)]).areas(area) + }; + + let inner = block.inner(top); + frame.render_widget(block, top); + let [gauge_area, text_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner); + frame.render_widget( + cache_gauge(info.cache_size, info.max_cache_size), + gauge_area, + ); + + let mut lines = vec![ + Line::from(vec![kv("location", info.cache_location.clone())]), + Line::from(vec![ + kv("size", fmt_bytes(info.cache_size)), + Span::raw(" "), + kv("max", fmt_bytes(info.max_cache_size)), + ]), + Line::from(vec![kv( + "preprocessor cache mode", + if info.use_preprocessor_cache_mode { + "yes" + } else { + "no" + }, + )]), + Line::from(vec![kv( + "base directories", + if info.basedirs.is_empty() { + "(none)".to_string() + } else { + info.basedirs.join(", ") + }, + )]), + ]; + // `ServerInfo::version` is stamped by the server process, matching the + // `server v…` shown in the status bar. + lines.push(Line::from(vec![kv("server version", info.version.clone())])); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), text_area); + + if let Some(levels) = info.stats.multi_level.as_ref().filter(|_| has_levels) { + let header = Row::new( + [ + "Level", + "hits", + "misses", + "hit rate", + "writes", + "fails", + "backfills", + "location", + ] + .into_iter() + .enumerate() + // Only the name and location columns are left-aligned. + .map(|(i, h)| { + if i == 0 || i == 7 { + Cell::from(h) + } else { + right(h) + } + }), + ) + .style(Style::default().fg(Color::Cyan).bold()); + + let rows = levels.0.iter().map(|l| { + let total = l.hits + l.misses; + let rate = if total == 0 { + "-".to_string() + } else { + format!("{:.2} %", l.hits as f64 / total as f64 * 100.0) + }; + Row::new(vec![ + Cell::from(l.name.as_str()), + num(l.hits), + num(l.misses), + right(rate), + num(l.writes), + right(l.write_failures.to_string()).style(if l.write_failures > 0 { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }), + right(format!("{}→/{}←", l.backfills_from, l.backfills_to)), + Cell::from(l.location.as_str()), + ]) + }); + frame.render_widget( + Table::new( + rows, + [ + Constraint::Length(14), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Length(12), + Constraint::Min(10), + ], + ) + .header(header) + .block(titled("Cache levels")), + bottom, + ); + } + } + + fn draw_dist(&self, frame: &mut Frame<'_>, area: Rect) { + let block = titled("Distributed compilation"); + let text = match &self.dist { + None => "fetching dist status…".to_string(), + Some(DistInfo::Disabled(reason)) => format!("Disabled: {reason}"), + #[cfg(feature = "dist-client")] + Some(DistInfo::NotConnected(url, reason)) => format!( + "Not connected\nScheduler: {}\nReason: {reason}", + url.as_ref() + .map(|u| u.to_url().to_string()) + .unwrap_or_else(|| "(none configured)".to_string()), + ), + #[cfg(feature = "dist-client")] + Some(DistInfo::SchedulerStatus(url, status)) => format!( + "Connected\nScheduler: {}\n\n{}", + url.as_ref() + .map(|u| u.to_url().to_string()) + .unwrap_or_else(|| "(none configured)".to_string()), + serde_json::to_string_pretty(status).unwrap_or_else(|e| e.to_string()), + ), + }; + let mut lines: Vec> = text.lines().map(Line::from).collect(); + if let Some(info) = self.info.as_ref() { + lines.push(Line::from("")); + lines.push(Line::from(vec![kv( + "failed distributed compiles", + info.stats.dist_errors.to_string(), + )])); + for (server, count) in sorted_counts(&info.stats.dist_compiles) { + lines.push(Line::from(vec![kv(server, count.to_string())])); + } + } + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(block), + area, + ); + } +} + +fn draw_help(frame: &mut Frame<'_>, area: Rect) { + let lines = vec![ + Line::from("Keys".to_string()).style(Style::default().fg(Color::Cyan).bold()), + Line::from(" q, Esc, Ctrl-C/D quit"), + Line::from(" 1-5, Tab, ←/→, h/l switch pane"), + Line::from(" a per-compiler instead of per-language counts"), + Line::from(" r poll now, even while paused"), + Line::from(" p, Space pause/resume polling"), + Line::from(" +/- double/halve the poll interval"), + Line::from(" z z zero the server's statistics (twice to confirm)"), + Line::from(" ?, F1 close this help"), + Line::from(""), + Line::from("The monitor is an ordinary client of the running server. Each poll"), + Line::from("resets the server's idle-shutdown timer, so a watched server will not"), + Line::from("time out on its own — pause polling if you need it to."), + ]; + let [area] = Layout::horizontal([Constraint::Length(74)]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::vertical([Constraint::Length(16)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines).block(titled("Help")), area); +} + +// --------------------------------------------------------------------------- +// Small rendering helpers +// --------------------------------------------------------------------------- + +const LANGUAGE_COLUMNS: [Constraint; 6] = [ + Constraint::Length(24), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Min(10), +]; + +/// One line of the languages pane: the counts for a single language, or for a +/// single language and compiler pair in advanced mode. +struct LangStat { + lang: String, + hits: u64, + misses: u64, + errors: u64, +} + +impl LangStat { + fn total(&self) -> u64 { + self.hits + self.misses + } + + fn hit_ratio(&self) -> f64 { + if self.total() == 0 { + 0.0 + } else { + self.hits as f64 / self.total() as f64 + } + } + + fn row(&self, busiest: u64, bar_width: usize) -> Row<'static> { + let rate = if self.total() == 0 { + "-".to_string() + } else { + format!("{:.2} %", self.hit_ratio() * 100.0) + }; + let errors = if self.errors == 0 { + right("-").style(Style::default().fg(Color::DarkGray)) + } else { + right(self.errors.to_string()).style(Style::default().fg(Color::Red)) + }; + Row::new(vec![ + Cell::from(self.lang.clone()), + num(self.hits), + num(self.misses), + right(rate), + errors, + Cell::from(self.bar(busiest, bar_width)), + ]) + } + + /// A bar as long as this language's share of the `busiest` one, split + /// green/magenta at its hit rate. + fn bar(&self, busiest: u64, width: usize) -> Line<'static> { + let scaled = (self.total() as f64 / busiest as f64 * width as f64).round() as usize; + let hits = (self.hit_ratio() * scaled as f64).round() as usize; + Line::from(vec![ + Span::styled("█".repeat(hits), Style::default().fg(Color::Green)), + Span::styled( + "█".repeat(scaled.saturating_sub(hits)), + Style::default().fg(Color::Magenta), + ), + ]) + } +} + +/// Collect the per-language counts, or the per-language-and-compiler counts +/// when `advanced`, for every language that appears in any of them. +fn language_stats(stats: &ServerStats, advanced: bool) -> Vec { + fn pick(count: &PerLanguageCount, advanced: bool) -> &HashMap { + if advanced { + count.adv_counts() + } else { + count.counts() + } + } + let hits = pick(&stats.cache_hits, advanced); + let misses = pick(&stats.cache_misses, advanced); + let errors = pick(&stats.cache_errors, advanced); + + let mut langs: Vec<&String> = hits + .keys() + .chain(misses.keys()) + .chain(errors.keys()) + .collect(); + langs.sort(); + langs.dedup(); + langs + .into_iter() + .map(|lang| LangStat { + lang: lang.clone(), + hits: hits.get(lang).copied().unwrap_or(0), + misses: misses.get(lang).copied().unwrap_or(0), + errors: errors.get(lang).copied().unwrap_or(0), + }) + .collect() +} + +fn titled(title: &str) -> Block<'_> { + Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + format!(" {title} "), + Style::default().fg(Color::Cyan), + )) +} + +fn placeholder(text: &str) -> Paragraph<'_> { + Paragraph::new(text).style(Style::default().fg(Color::DarkGray)) +} + +fn sep() -> Span<'static> { + Span::styled(" │ ", Style::default().fg(Color::DarkGray)) +} + +fn kv>(name: &str, value: S) -> Span<'static> { + Span::raw(format!("{name} {}", value.into())) +} + +/// A right-aligned cell, the shape every numeric column in here wants. +fn right>(text: S) -> Cell<'static> { + Cell::from(Line::from(text.into()).alignment(Alignment::Right)) +} + +/// A right-aligned count, dimmed when it is zero. +fn num(value: u64) -> Cell<'static> { + let cell = right(value.to_string()); + if value == 0 { + cell.style(Style::default().fg(Color::DarkGray)) + } else { + cell + } +} + +fn hit_rate_colour(ratio: f64, total: u64) -> Color { + if total == 0 { + Color::DarkGray + } else if ratio >= 0.75 { + Color::Green + } else if ratio >= 0.4 { + Color::Yellow + } else { + Color::Red + } +} + +fn cache_gauge(size: Option, max: Option) -> Gauge<'static> { + let ratio = match (size, max) { + (Some(s), Some(m)) if m > 0 => (s as f64 / m as f64).clamp(0.0, 1.0), + _ => 0.0, + }; + let label = match (size, max) { + (Some(_), Some(_)) => format!( + "cache {} / {} ({:.1} %)", + fmt_bytes(size), + fmt_bytes(max), + ratio * 100.0 + ), + (Some(_), None) => format!("cache {}", fmt_bytes(size)), + _ => "cache size unknown".to_string(), + }; + let colour = if ratio >= 0.9 { + Color::Red + } else if ratio >= 0.7 { + Color::Yellow + } else { + Color::Blue + }; + Gauge::default() + .ratio(ratio) + .label(label) + .gauge_style(Style::default().fg(colour)) + .use_unicode(true) +} + +fn fmt_bytes(bytes: Option) -> String { + match bytes { + None => "?".to_string(), + Some(b) => match NumberPrefix::binary(b as f64) { + NumberPrefix::Standalone(n) => format!("{n} bytes"), + NumberPrefix::Prefixed(prefix, n) => format!("{n:.1} {prefix}B"), + }, + } +} + +fn fmt_interval(interval: Duration) -> String { + let secs = interval.as_secs_f64(); + if secs < 1.0 { + format!("{}ms", interval.as_millis()) + } else { + format!("{secs:.1}s") + } +} + +fn fmt_uptime(d: Duration) -> String { + let s = d.as_secs(); + format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) +} + +/// Sort counts by descending count, then by key for a stable order. +fn sorted_counts(counts: &HashMap) -> Vec<(&str, usize)> { + let mut v: Vec<(&str, usize)> = counts.iter().map(|(k, &c)| (k.as_str(), c)).collect(); + v.sort_by(|(k1, c1), (k2, c2)| c2.cmp(c1).then_with(|| k1.cmp(k2))); + v +} + +fn counted_table<'a>( + counts: &'a HashMap, + empty: &'a str, + colour: Color, +) -> Table<'a> { + if counts.is_empty() { + return Table::default().rows(vec![Row::new(vec![Cell::from( + Line::from(empty).style(Style::default().fg(Color::DarkGray)), + )])]); + } + let total: usize = counts.values().sum(); + let rows = sorted_counts(counts).into_iter().map(|(reason, count)| { + let share = if total == 0 { + 0.0 + } else { + count as f64 / total as f64 + }; + Row::new(vec![ + Cell::from(reason), + right(count.to_string()), + right(format!("{:.1} %", share * 100.0)), + Cell::from( + Line::from("█".repeat((share * 20.0).round() as usize)) + .style(Style::default().fg(colour)), + ), + ]) + }); + Table::new( + rows, + [ + Constraint::Min(20), + Constraint::Length(9), + Constraint::Length(8), + Constraint::Length(21), + ], + ) +} + +/// Ensure the terminal is restored even if the UI panics. +fn install_panic_hook() { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = io::Write::flush(&mut io::stdout()); + ratatui::restore(); + hook(info); + })); +} From dc06e1da27375005691b30c2d3306438db29aa54 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 4 Aug 2026 21:45:50 +0200 Subject: [PATCH 03/14] doc: document the monitor dashboard Add docs/Monitoring.md covering usage, the panes, the key bindings, how the rates are derived, and the effect on the server's idle shutdown. Point at it from the README, both from the statistics section and from the build instructions, noting that the `monitor` feature is not part of the default build. --- README.md | 15 +++++++++ docs/Monitoring.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/Monitoring.md diff --git a/README.md b/README.md index 1e5c99df9..a73de27a8 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ You can run `sccache --stop-server` to terminate the server. It will also termin Running `sccache --show-stats` will print a summary of cache statistics. +For a live view of the running server, `sccache --monitor` opens a terminal +dashboard. It is not built by default: it requires the `monitor` feature (see +[Build](#build) below), and without it the command reports that the UI was not +compiled in. See [Monitoring](docs/Monitoring.md) for the panes and key +bindings. + Some notes about using `sccache` with [Jenkins](https://jenkins.io) are [here](docs/Jenkins.md). To use sccache with cmake, provide the following command line arguments to cmake 3.4 or newer: @@ -252,6 +258,15 @@ The list of features can be found in the `Cargo.toml` file, `[features]` section By default, `sccache` builds with support for all storage backends, but individual backends may be disabled by resetting the list of features and enabling all the other backends. Refer the [Cargo Documentation](http://doc.crates.io/manifest.html#the-features-section) for details on how to select features with Cargo. +The `monitor` feature is *not* part of the default build, and neither the +default nor the `all` feature set pulls it in — enable it explicitly if you want +the [`sccache --monitor`](docs/Monitoring.md) terminal dashboard and its +[Ratatui](https://ratatui.rs) dependency: + +```bash +cargo build --release --features monitor +``` + ### Building portable binaries When building with the `dist-server` feature, `sccache` will depend on OpenSSL, which can be an annoyance if you want to distribute portable binaries. It is possible to statically link against OpenSSL using the `openssl/vendored` feature. diff --git a/docs/Monitoring.md b/docs/Monitoring.md new file mode 100644 index 000000000..675bd58ce --- /dev/null +++ b/docs/Monitoring.md @@ -0,0 +1,80 @@ +# Monitoring a running server + +`sccache --monitor` attaches a terminal dashboard to a running sccache server +and refreshes it while you build. It is an ordinary client — it does not need +any cooperation from the server beyond the existing `GetStats`, `ZeroStats` and +`DistStatus` requests — so it can be started and stopped at any point in a +build, and it can watch a server that was started by something else. + +The dashboard is built with [Ratatui](https://ratatui.rs) and is behind the +`monitor` cargo feature, which is not enabled by default: + +``` +cargo build --release --features monitor +``` + +Without that feature, `sccache --monitor` reports that the UI was not compiled +in. + +## Usage + +``` +sccache --monitor # poll once a second +sccache --monitor --monitor-interval 0.25 # poll four times a second +``` + +`--monitor-interval` accepts 0.2 to 60 seconds; anything outside that range is +rejected rather than silently clamped. + +The monitor connects to the same address as every other sccache client, so +`SCCACHE_SERVER_PORT` and `SCCACHE_SERVER_UDS` are honoured: + +``` +env SCCACHE_SERVER_UDS=$HOME/sccache.sock sccache --monitor +``` + +If no server is running, the monitor does *not* start one: it shows +`disconnected` and attaches as soon as a server appears. It also survives the +server being stopped and restarted underneath it. + +## Panes + +| Pane | Contents | +| --- | --- | +| Overview | Overall hit rate, cache fill, per-second rates, the full counter list, and average cache-write / compile / cache-read-hit times. Sparklines plot compile requests, hits and misses per second. | +| Languages | Hits, misses, hit rate and errors per language, with a hit/miss bar. Press `a` for the per-compiler (advanced) breakdown. | +| Reasons | Why compilations were not cached, and which distributed servers ran compilations, each with its share of the total. | +| Cache | Cache location, size against the configured maximum, base directories, preprocessor cache mode, and a per-level table when [multi-level caching](MultiLevel.md) is enabled. | +| Dist | Distributed-compilation status: scheduler URL, scheduler status, and failed distributed compilations. Only polled while this pane is open, since it can involve a round trip to the scheduler. | + +## Keys + +| Key | Action | +| --- | --- | +| `q`, `Esc`, `Ctrl-C`, `Ctrl-D` | quit | +| `1`–`5`, `Tab`, `←`/`→`, `h`/`l` | switch pane | +| `a` | per-compiler instead of per-language counts | +| `r` | poll now, even while paused | +| `p`, `Space` | pause / resume polling | +| `+` / `-` | double / halve the poll interval (200 ms to 60 s) | +| `z` `z` | zero the server's statistics; the second `z` confirms, any other key cancels | +| `?`, `F1` | help | + +## How the numbers are derived + +The server only exposes cumulative counters, so per-second rates are computed +by diffing consecutive samples. A counter going backwards means the statistics +were zeroed or the server restarted, and the rate history is cleared rather +than showing a spike. Rates are therefore blank until a second sample arrives. + +## Effect on the server + +Each poll is a normal request, and any request resets the server's +idle-shutdown timer. A server that is being monitored will not shut down on its +own after `SCCACHE_IDLE_TIMEOUT`; press `p` to pause polling if you want the +idle timeout to apply. + +The monitor connects for each poll and disconnects immediately afterwards. It +deliberately does not hold the socket open, because the server waits for +connected clients to disappear before exiting, and a persistent connection +would delay `sccache --stop-server` by the server's whole drain timeout. From 9163b2bf6f536340e848de3feaa61290199ccd9c Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 4 Aug 2026 21:45:51 +0200 Subject: [PATCH 04/14] scripts: add a demo script for the monitor An empty dashboard says little about whether the panes are right. This script builds sccache with the `monitor` feature, starts a server on its own port with its own cache directory, generates a mix of hits, misses, a non-cacheable call and a failing compile in the background so every pane has data, and opens the dashboard. Everything is torn down on exit, leaving the user's own server and cache alone. --- scripts/try-monitor.sh | 115 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100755 scripts/try-monitor.sh diff --git a/scripts/try-monitor.sh b/scripts/try-monitor.sh new file mode 100755 index 000000000..7b772122e --- /dev/null +++ b/scripts/try-monitor.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# Try out `sccache --monitor` against a throwaway server. +# +# Builds sccache with the `monitor` feature, starts a server on its own port +# with its own cache directory, generates a trickle of compilations in the +# background so the dashboard has something to plot, and opens the monitor. +# +# Everything is torn down on exit; your real sccache server and cache are left +# alone. Press `q` in the dashboard to quit. +# +# Usage: scripts/try-monitor.sh [--release] [--port N] [--interval SECS] + +set -euo pipefail + +PROFILE=debug +PORT=4299 +INTERVAL=0.5 + +while [ $# -gt 0 ]; do + case "$1" in + --release) PROFILE=release; shift ;; + --port) PORT="$2"; shift 2 ;; + --interval) INTERVAL="$2"; shift 2 ;; + # Print the header comment, i.e. everything between the shebang and the + # first blank line, rather than a hard-coded line range. + -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \?//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +cd "$(dirname "$0")/.." +ROOT=$(pwd) + +echo "==> building sccache --features monitor ($PROFILE)" +if [ "$PROFILE" = release ]; then + cargo build --release --features monitor +else + cargo build --features monitor +fi +SCCACHE="$ROOT/target/$PROFILE/sccache" + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/sccache-monitor-demo.XXXXXX") +export SCCACHE_DIR="$WORK/cache" +export SCCACHE_SERVER_PORT="$PORT" +export SCCACHE_CACHE_SIZE=200M +# Keep the server around even while the load generator is idle. +export SCCACHE_IDLE_TIMEOUT=0 +unset SCCACHE_SERVER_UDS + +LOAD_PID= + +cleanup() { + set +e + if [ -n "$LOAD_PID" ]; then + # The load generator runs in its own process group (see `set -m` + # below), so signal the group: killing just the subshell would leave a + # compile it had already started running. + kill -- -"$LOAD_PID" 2>/dev/null || kill "$LOAD_PID" 2>/dev/null + fi + "$SCCACHE" --stop-server >/dev/null 2>&1 + rm -rf "$WORK" +} +trap cleanup EXIT + +CC=${CC:-cc} +if ! command -v "$CC" >/dev/null; then + echo "no C compiler found (set CC to override)" >&2 + exit 1 +fi + +echo "==> starting a server on 127.0.0.1:$PORT with cache in $SCCACHE_DIR" +"$SCCACHE" --start-server + +# Background load: a mix of misses (fresh sources), hits (recompiling the same +# source), a non-cacheable call (-E) and a compile failure, so that every pane +# of the dashboard has data in it. +cat > "$WORK/hit.c" <<'EOF' +#include +int hit(void) { return 42; } +EOF +printf 'int broken(void) {\n' > "$WORK/broken.c" + +# Job control, so that the background loop below becomes the leader of its own +# process group and `cleanup` can tear the whole thing down at once. +set -m +( + cd "$WORK" + i=0 + while true; do + i=$((i + 1)) + printf '#include \nint f%d(void) { return %d; }\n' "$i" "$i" > "miss$i.c" + "$SCCACHE" "$CC" -c "miss$i.c" -o "miss$i.o" >/dev/null 2>&1 + rm -f "miss$i.c" "miss$i.o" + # Two cache hits per miss. + "$SCCACHE" "$CC" -c hit.c -o hit.o >/dev/null 2>&1 + "$SCCACHE" "$CC" -c hit.c -o hit.o >/dev/null 2>&1 + if [ $((i % 7)) -eq 0 ]; then + # Non-cacheable call: shows up in the Reasons pane. + "$SCCACHE" "$CC" -E hit.c >/dev/null 2>&1 + fi + if [ $((i % 11)) -eq 0 ]; then + # Failed compilation. + "$SCCACHE" "$CC" -c broken.c -o broken.o >/dev/null 2>&1 + fi + sleep 0.3 + done +) & +LOAD_PID=$! +# Back to the default, so the monitor keeps the terminal's foreground group. +set +m + +echo "==> opening the dashboard (q to quit, ? for help, 1-5 for panes)" +sleep 1 +"$SCCACHE" --monitor --monitor-interval "$INTERVAL" From 825ce192ff9bbf86ee70a844114993b04b59e1b1 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 5 Aug 2026 22:54:32 +0200 Subject: [PATCH 05/14] scripts: keep the monitor demo's load generator alive The load generator inherited `set -e` from the top of the script, so the deliberate failing compile took the whole loop down on its eleventh iteration, about five seconds in. The dashboard then sat at 35 compile requests for as long as you left it open, with the plots flat, which looks like the monitor is broken rather than the demo. Drop errexit and pipefail inside the subshell, where a non-zero exit is expected: the failing compile is the point, and so are the sccache calls that run under `wait`. While here, make the load worth plotting. Compile `JOBS` files at a time instead of one, alternate a burst of misses with a burst of the much faster cache hits, and idle for a couple of seconds between rounds so the sparklines have troughs as well as peaks instead of a flat line. Trigger the non-cacheable call and the failing compile more often, so the Reasons pane fills in within the first few rounds. --- scripts/try-monitor.sh | 47 ++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/scripts/try-monitor.sh b/scripts/try-monitor.sh index 7b772122e..d81df8de3 100755 --- a/scripts/try-monitor.sh +++ b/scripts/try-monitor.sh @@ -3,13 +3,14 @@ # Try out `sccache --monitor` against a throwaway server. # # Builds sccache with the `monitor` feature, starts a server on its own port -# with its own cache directory, generates a trickle of compilations in the -# background so the dashboard has something to plot, and opens the monitor. +# with its own cache directory, compiles in bursts in the background so the +# dashboard has something to plot, and opens the monitor. # # Everything is torn down on exit; your real sccache server and cache are left # alone. Press `q` in the dashboard to quit. # # Usage: scripts/try-monitor.sh [--release] [--port N] [--interval SECS] +# JOBS=8 scripts/try-monitor.sh # compile 8 files at a time set -euo pipefail @@ -81,29 +82,53 @@ int hit(void) { return 42; } EOF printf 'int broken(void) {\n' > "$WORK/broken.c" +# Compile several files at a time, so the rates are large enough to see. Cache +# hits are quick, so a burst of them makes a clear spike. +JOBS=${JOBS:-4} + # Job control, so that the background loop below becomes the leader of its own # process group and `cleanup` can tear the whole thing down at once. set -m ( + # A failing compile and a `-E` call are part of the demo, and the sccache + # calls run under `wait`; none of that should take the loop down, so drop + # the errexit and pipefail inherited from above. + set +e +o pipefail cd "$WORK" i=0 while true; do i=$((i + 1)) - printf '#include \nint f%d(void) { return %d; }\n' "$i" "$i" > "miss$i.c" - "$SCCACHE" "$CC" -c "miss$i.c" -o "miss$i.o" >/dev/null 2>&1 - rm -f "miss$i.c" "miss$i.o" - # Two cache hits per miss. - "$SCCACHE" "$CC" -c hit.c -o hit.o >/dev/null 2>&1 - "$SCCACHE" "$CC" -c hit.c -o hit.o >/dev/null 2>&1 - if [ $((i % 7)) -eq 0 ]; then + + # Burst of misses: fresh sources, so each one is compiled and written to + # the cache. + for j in $(seq 1 "$JOBS"); do + printf '#include \nint f%d_%d(void) { return %d; }\n' \ + "$i" "$j" "$i" > "miss$i-$j.c" + "$SCCACHE" "$CC" -c "miss$i-$j.c" -o "miss$i-$j.o" >/dev/null 2>&1 & + done + wait + rm -f "miss$i-"*.c "miss$i-"*.o + + # Burst of hits: the same source over and over, which is much faster and + # shows up as a taller, narrower spike than the misses. + for j in $(seq 1 $((JOBS * 3))); do + "$SCCACHE" "$CC" -c hit.c -o "hit$j.o" >/dev/null 2>&1 & + done + wait + rm -f hit*.o + + if [ $((i % 3)) -eq 0 ]; then # Non-cacheable call: shows up in the Reasons pane. "$SCCACHE" "$CC" -E hit.c >/dev/null 2>&1 fi - if [ $((i % 11)) -eq 0 ]; then + if [ $((i % 5)) -eq 0 ]; then # Failed compilation. "$SCCACHE" "$CC" -c broken.c -o broken.o >/dev/null 2>&1 fi - sleep 0.3 + + # Idle beat, so the plots have troughs as well as peaks instead of a + # flat line at the top. + sleep 2 done ) & LOAD_PID=$! From 4be493c662d7e8b67f17056a98d6efc4842a0972 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 6 Aug 2026 09:24:22 +0200 Subject: [PATCH 06/14] monitor: draw local cache usage as a pie and a fill plot A local disk cache reports both its size and the ceiling it is trimmed to, which the Cache pane spent on a one-line gauge and a "size 28.9 KiB max 200.0 MiB" line. Give it the space instead: a used/free pie beside the details, and a plot of the cache size over time underneath. The pie is rasterised onto a canvas of braille dots, which are half as wide as they are tall, so the column is sized 2:1 to come out round; the slice starts at twelve o'clock and runs clockwise. Colour follows the same thresholds as the gauge, turning yellow at 70% and red at 90%. Panes too narrow or too short for it, and remote caches, which usually report neither a size nor a maximum, keep the gauge. The plot is of absolute bytes rather than a rate, so unlike the overview plots it keeps its shape across a `z`, and it stays useful when the pie cannot say much: a cache holding 28.9 KiB of 200 MiB is an empty circle either way, but the trend still shows it filling. --- docs/Monitoring.md | 2 +- src/monitor.rs | 234 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 201 insertions(+), 35 deletions(-) diff --git a/docs/Monitoring.md b/docs/Monitoring.md index 675bd58ce..47c9bc1e5 100644 --- a/docs/Monitoring.md +++ b/docs/Monitoring.md @@ -44,7 +44,7 @@ server being stopped and restarted underneath it. | Overview | Overall hit rate, cache fill, per-second rates, the full counter list, and average cache-write / compile / cache-read-hit times. Sparklines plot compile requests, hits and misses per second. | | Languages | Hits, misses, hit rate and errors per language, with a hit/miss bar. Press `a` for the per-compiler (advanced) breakdown. | | Reasons | Why compilations were not cached, and which distributed servers ran compilations, each with its share of the total. | -| Cache | Cache location, size against the configured maximum, base directories, preprocessor cache mode, and a per-level table when [multi-level caching](MultiLevel.md) is enabled. | +| Cache | Cache location, size against the configured maximum, base directories, preprocessor cache mode, and a per-level table when [multi-level caching](MultiLevel.md) is enabled. A local disk cache also gets a used/free pie and a plot of how the cache has filled up; a remote one, whose size and ceiling are usually unknown, keeps a one-line gauge. | | Dist | Distributed-compilation status: scheduler URL, scheduler status, and failed distributed compilations. Only polled while this pane is open, since it can involve a round trip to the scheduler. | ## Keys diff --git a/src/monitor.rs b/src/monitor.rs index ad6675de1..83646d4e6 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -34,7 +34,9 @@ use number_prefix::NumberPrefix; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; +use ratatui::symbols::Marker; use ratatui::text::{Line, Span}; +use ratatui::widgets::canvas::{Canvas, Points}; use ratatui::widgets::{ Block, BorderType, Cell, Clear, Gauge, LineGauge, Paragraph, Row, Sparkline, Table, Tabs, Wrap, }; @@ -253,6 +255,9 @@ struct App { hist_requests: VecDeque, hist_hits: VecDeque, hist_misses: VecDeque, + /// Cache size in bytes, one entry per sample. Unlike the rates this is an + /// absolute figure, so it survives a `z`. + hist_size: VecDeque, updates: u64, last_update: Option, error: Option, @@ -277,6 +282,7 @@ impl App { hist_requests: VecDeque::with_capacity(HISTORY), hist_hits: VecDeque::with_capacity(HISTORY), hist_misses: VecDeque::with_capacity(HISTORY), + hist_size: VecDeque::with_capacity(HISTORY), updates: 0, last_update: None, error: None, @@ -435,6 +441,12 @@ impl App { None => {} } self.prev = Some((now, stats.clone())); + if let Some(size) = info.cache_size { + if self.hist_size.len() == HISTORY { + self.hist_size.pop_front(); + } + self.hist_size.push_back(size); + } self.info = Some(*info); self.updates += 1; self.last_update = Some(now); @@ -894,6 +906,37 @@ impl App { ); } + /// Plot how the cache has filled up. Absolute bytes rather than a rate, so + /// unlike the overview plots this one keeps its shape across a `z`. + fn draw_cache_trend(&self, frame: &mut Frame<'_>, area: Rect) { + let width = area.width.saturating_sub(2) as usize; + let data: Vec = self + .hist_size + .iter() + .rev() + .take(width) + .rev() + .copied() + .collect(); + let heading = match (data.first(), data.last()) { + (Some(&first), Some(&last)) => format!( + "cache size — now {}, {}{} over {} samples", + fmt_bytes(Some(last)), + if last >= first { "+" } else { "-" }, + fmt_bytes(Some(last.abs_diff(first))), + data.len(), + ), + _ => "cache size".to_string(), + }; + frame.render_widget( + Sparkline::default() + .block(titled(&heading)) + .data(data) + .style(Style::default().fg(Color::Blue)), + area, + ); + } + fn draw_cache(&self, frame: &mut Frame<'_>, area: Rect) { let block = titled("Cache"); let Some(info) = self.info.as_ref() else { @@ -909,20 +952,30 @@ impl App { .multi_level .as_ref() .is_some_and(|m| !m.0.is_empty()); - let [top, bottom] = if has_levels { - Layout::vertical([Constraint::Length(9), Constraint::Min(0)]).areas(area) - } else { - Layout::vertical([Constraint::Min(0), Constraint::Length(0)]).areas(area) + // A local cache has a size and a ceiling worth drawing; a remote one + // usually reports neither, so it keeps the plain gauge. + let usage = match (info.cache_size, info.max_cache_size) { + (Some(size), Some(max)) if max > 0 && info.cache_location.starts_with("Local disk") => { + Some((size, max)) + } + _ => None, }; + let levels_height = match info.stats.multi_level.as_ref().filter(|_| has_levels) { + // Border, header, and a line per level. + Some(levels) => 3 + levels.0.len() as u16, + None => 0, + }; + let (top_height, trend_height) = match usage { + // Enough for the pie plus its legend, and a plot of the fill. + Some(_) => (Constraint::Length(12), Constraint::Min(0)), + None => (Constraint::Min(0), Constraint::Length(0)), + }; + let [top, trend, bottom] = + Layout::vertical([top_height, trend_height, Constraint::Length(levels_height)]) + .areas(area); let inner = block.inner(top); frame.render_widget(block, top); - let [gauge_area, text_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner); - frame.render_widget( - cache_gauge(info.cache_size, info.max_cache_size), - gauge_area, - ); let mut lines = vec![ Line::from(vec![kv("location", info.cache_location.clone())]), @@ -931,27 +984,60 @@ impl App { Span::raw(" "), kv("max", fmt_bytes(info.max_cache_size)), ]), - Line::from(vec![kv( - "preprocessor cache mode", - if info.use_preprocessor_cache_mode { - "yes" - } else { - "no" - }, - )]), - Line::from(vec![kv( - "base directories", - if info.basedirs.is_empty() { - "(none)".to_string() - } else { - info.basedirs.join(", ") - }, - )]), ]; + if let Some((size, max)) = usage { + lines.push(Line::from(vec![kv( + "free", + fmt_bytes(Some(max.saturating_sub(size))), + )])); + } + lines.push(Line::from(vec![kv( + "preprocessor cache mode", + if info.use_preprocessor_cache_mode { + "yes" + } else { + "no" + }, + )])); + lines.push(Line::from(vec![kv( + "base directories", + if info.basedirs.is_empty() { + "(none)".to_string() + } else { + info.basedirs.join(", ") + }, + )])); // `ServerInfo::version` is stamped by the server process, matching the // `server v…` shown in the status bar. lines.push(Line::from(vec![kv("server version", info.version.clone())])); - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), text_area); + + match usage.and_then(|(size, max)| pie_width(inner).map(|w| (size, max, w))) { + Some((size, max, width)) => { + let [pie, _gap, text_area] = Layout::horizontal([ + Constraint::Length(width), + Constraint::Length(2), + Constraint::Min(20), + ]) + .areas(inner); + draw_pie(frame, pie, size, max); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), text_area); + } + // Nothing to scale against, or too small for the pie: fall back to + // the one-line gauge. + None => { + let [gauge_area, text_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner); + frame.render_widget( + cache_gauge(info.cache_size, info.max_cache_size), + gauge_area, + ); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), text_area); + } + } + + if usage.is_some() { + self.draw_cache_trend(frame, trend); + } if let Some(levels) = info.stats.multi_level.as_ref().filter(|_| has_levels) { let header = Row::new( @@ -1255,18 +1341,98 @@ fn cache_gauge(size: Option, max: Option) -> Gauge<'static> { (Some(_), None) => format!("cache {}", fmt_bytes(size)), _ => "cache size unknown".to_string(), }; - let colour = if ratio >= 0.9 { + Gauge::default() + .ratio(ratio) + .label(label) + .gauge_style(Style::default().fg(fill_colour(ratio))) + .use_unicode(true) +} + +/// How full is too full: the same scale for the gauge and the pie. +fn fill_colour(ratio: f64) -> Color { + if ratio >= 0.9 { Color::Red } else if ratio >= 0.7 { Color::Yellow } else { Color::Blue - }; - Gauge::default() - .ratio(ratio) - .label(label) - .gauge_style(Style::default().fg(colour)) - .use_unicode(true) + } +} + +/// Width to give the pie column, or `None` when the pane is too small for one. +/// +/// Braille cells are twice as tall as they are wide, so a 2:1 area comes out +/// round; one line goes to the legend underneath. +fn pie_width(inner: Rect) -> Option { + let plot_height = inner.height.checked_sub(1)?; + if plot_height < 5 || inner.width < 46 { + return None; + } + let width = (plot_height * 2).min(inner.width / 2).min(30); + (width >= 10).then_some(width) +} + +/// Draw the cache fill as a two-slice pie with a legend under it. +fn draw_pie(frame: &mut Frame<'_>, area: Rect, size: u64, max: u64) { + let [plot, legend] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area); + let ratio = (size as f64 / max as f64).clamp(0.0, 1.0); + let colour = fill_colour(ratio); + let (used, free) = pie_points(plot, ratio); + frame.render_widget( + Canvas::default() + .marker(Marker::Braille) + .x_bounds([-1.0, 1.0]) + .y_bounds([-1.0, 1.0]) + .paint(|ctx| { + ctx.draw(&Points { + coords: &free, + color: Color::DarkGray, + }); + ctx.draw(&Points { + coords: &used, + color: colour, + }); + }), + plot, + ); + // Kept short: the column is only as wide as the pie, and the byte figures + // are already spelled out beside it. + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("█", Style::default().fg(colour)), + Span::raw(format!(" {:.2} % full", ratio * 100.0)), + ])) + .alignment(Alignment::Center), + legend, + ); +} + +/// Rasterise a two-slice pie: the used slice starts at twelve o'clock and runs +/// clockwise. One point per braille dot of `area`, in canvas coordinates. +fn pie_points(area: Rect, ratio: f64) -> (Vec<(f64, f64)>, Vec<(f64, f64)>) { + let (nx, ny) = (area.width as usize * 2, area.height as usize * 4); + let mut used = Vec::new(); + let mut free = Vec::new(); + for iy in 0..ny { + let y = -1.0 + 2.0 * (iy as f64 + 0.5) / ny as f64; + for ix in 0..nx { + let x = -1.0 + 2.0 * (ix as f64 + 0.5) / nx as f64; + if x * x + y * y > 1.0 { + continue; + } + // `atan2(x, y)` is zero at twelve o'clock and grows clockwise. + let mut angle = x.atan2(y); + if angle < 0.0 { + angle += std::f64::consts::TAU; + } + if angle / std::f64::consts::TAU < ratio { + used.push((x, y)); + } else { + free.push((x, y)); + } + } + } + (used, free) } fn fmt_bytes(bytes: Option) -> String { From 5eb508f588b076d31a323af17154364cb433f71d Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 6 Aug 2026 10:06:52 +0200 Subject: [PATCH 07/14] monitor: plot how fast the cache fills, not how full it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache size only ever climbs until the LRU trims it at the ceiling, so plotting it drew a staircase that never came back down: the current size was already on the line above it, and the shape said nothing the number did not. Plot the growth rate instead, in bytes per second between samples. That rises and falls with what the build is writing, so the plot earns its space, and it answers the question the size cannot: is this build still filling the cache, and how fast? Alongside the pie, report the average growth over the retained window and, from it, when the cache will be full. Once the LRU has started trimming, the projection would only ever read "~0 s", so it gives way to a count of how often the cache has been trimmed and how much was freed — which is what being at the ceiling actually looks like from outside. --- docs/Monitoring.md | 2 +- src/monitor.rs | 111 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/docs/Monitoring.md b/docs/Monitoring.md index 47c9bc1e5..207f839d0 100644 --- a/docs/Monitoring.md +++ b/docs/Monitoring.md @@ -44,7 +44,7 @@ server being stopped and restarted underneath it. | Overview | Overall hit rate, cache fill, per-second rates, the full counter list, and average cache-write / compile / cache-read-hit times. Sparklines plot compile requests, hits and misses per second. | | Languages | Hits, misses, hit rate and errors per language, with a hit/miss bar. Press `a` for the per-compiler (advanced) breakdown. | | Reasons | Why compilations were not cached, and which distributed servers ran compilations, each with its share of the total. | -| Cache | Cache location, size against the configured maximum, base directories, preprocessor cache mode, and a per-level table when [multi-level caching](MultiLevel.md) is enabled. A local disk cache also gets a used/free pie and a plot of how the cache has filled up; a remote one, whose size and ceiling are usually unknown, keeps a one-line gauge. | +| Cache | Cache location, size against the configured maximum, base directories, preprocessor cache mode, and a per-level table when [multi-level caching](MultiLevel.md) is enabled. A local disk cache also gets a used/free pie, how fast it is filling, when it will be full at that rate, and a plot of the growth; a remote one, whose size and ceiling are usually unknown, keeps a one-line gauge. Once the LRU starts trimming the cache at its ceiling, the projection gives way to a count of how much has been evicted. | | Dist | Distributed-compilation status: scheduler URL, scheduler status, and failed distributed compilations. Only polled while this pane is open, since it can involve a round trip to the scheduler. | ## Keys diff --git a/src/monitor.rs b/src/monitor.rs index 83646d4e6..db0085f5f 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -255,9 +255,15 @@ struct App { hist_requests: VecDeque, hist_hits: VecDeque, hist_misses: VecDeque, - /// Cache size in bytes, one entry per sample. Unlike the rates this is an - /// absolute figure, so it survives a `z`. - hist_size: VecDeque, + /// Timestamped cache size in bytes, one entry per sample, used to work out + /// how fast the cache is filling and when it will be full. + hist_size: VecDeque<(Instant, u64)>, + /// Bytes per second added to the cache, one entry per sample. + hist_growth: VecDeque, + /// How often the cache was seen to shrink, and by how much in total. The + /// LRU trims the cache at its ceiling, so this is what "full" looks like. + trims: u64, + trimmed: u64, updates: u64, last_update: Option, error: Option, @@ -283,6 +289,9 @@ impl App { hist_hits: VecDeque::with_capacity(HISTORY), hist_misses: VecDeque::with_capacity(HISTORY), hist_size: VecDeque::with_capacity(HISTORY), + hist_growth: VecDeque::with_capacity(HISTORY), + trims: 0, + trimmed: 0, updates: 0, last_update: None, error: None, @@ -442,10 +451,25 @@ impl App { } self.prev = Some((now, stats.clone())); if let Some(size) = info.cache_size { + if let Some(&(at, previous)) = self.hist_size.back() { + let dt = now.duration_since(at).as_secs_f64(); + if size < previous { + // The LRU trimmed the cache: it was at its ceiling. + self.trims += 1; + self.trimmed += previous - size; + } + if dt > 0.0 { + let growth = (size.saturating_sub(previous)) as f64 / dt; + if self.hist_growth.len() == HISTORY { + self.hist_growth.pop_front(); + } + self.hist_growth.push_back(growth.round() as u64); + } + } if self.hist_size.len() == HISTORY { self.hist_size.pop_front(); } - self.hist_size.push_back(size); + self.hist_size.push_back((now, size)); } self.info = Some(*info); self.updates += 1; @@ -906,27 +930,38 @@ impl App { ); } - /// Plot how the cache has filled up. Absolute bytes rather than a rate, so - /// unlike the overview plots this one keeps its shape across a `z`. + /// Average bytes per second the cache has grown over the samples still in + /// the window, and how long that window covers. Negative after a trim. + fn cache_growth(&self) -> Option<(f64, Duration)> { + let &(first_at, first) = self.hist_size.front()?; + let &(last_at, last) = self.hist_size.back()?; + let window = last_at.duration_since(first_at); + if window.is_zero() { + return None; + } + Some(((last as f64 - first as f64) / window.as_secs_f64(), window)) + } + + /// Plot how fast the cache is filling. The size itself only ever climbs + /// until the LRU trims it, so its own plot is a staircase; the rate rises + /// and falls with what the build is writing. fn draw_cache_trend(&self, frame: &mut Frame<'_>, area: Rect) { let width = area.width.saturating_sub(2) as usize; let data: Vec = self - .hist_size + .hist_growth .iter() .rev() .take(width) .rev() .copied() .collect(); - let heading = match (data.first(), data.last()) { - (Some(&first), Some(&last)) => format!( - "cache size — now {}, {}{} over {} samples", - fmt_bytes(Some(last)), - if last >= first { "+" } else { "-" }, - fmt_bytes(Some(last.abs_diff(first))), - data.len(), + let heading = match (data.last(), data.iter().max()) { + (Some(&now), Some(&peak)) => format!( + "cache growth — now {}/s, peak {}/s", + fmt_bytes(Some(now)), + fmt_bytes(Some(peak)), ), - _ => "cache size".to_string(), + _ => "cache growth".to_string(), }; frame.render_widget( Sparkline::default() @@ -990,6 +1025,37 @@ impl App { "free", fmt_bytes(Some(max.saturating_sub(size))), )])); + if let Some((rate, window)) = self.cache_growth() { + lines.push(Line::from(vec![kv( + "growth", + format!( + "{}/s over the last {}", + fmt_bytes(Some(rate.max(0.0) as u64)), + fmt_eta(window.as_secs_f64()), + ), + )])); + // Once the LRU has started trimming, the cache is full and + // stays there; the projection would only ever read "~0 s". + if rate > 0.0 && self.trims == 0 && max > size { + lines.push(Line::from(vec![kv( + "full in", + format!("~{}", fmt_eta((max - size) as f64 / rate)), + )])); + } + } + if self.trims > 0 { + lines.push(Line::from(vec![ + kv( + "trimmed", + format!( + "{} times, {} freed", + self.trims, + fmt_bytes(Some(self.trimmed)) + ), + ), + Span::styled(" (at its ceiling)", Style::default().fg(Color::DarkGray)), + ])); + } } lines.push(Line::from(vec![kv( "preprocessor cache mode", @@ -1454,6 +1520,21 @@ fn fmt_interval(interval: Duration) -> String { } } +/// A coarse duration for projections, where minutes of precision on an +/// estimate measured in hours would be false confidence. +fn fmt_eta(secs: f64) -> String { + if !secs.is_finite() || secs >= 99.0 * 86_400.0 { + return "a very long time".to_string(); + } + let s = secs as u64; + match s { + 0..=59 => format!("{s} s"), + 60..=3599 => format!("{} min", s / 60), + 3600..=86_399 => format!("{} h {} min", s / 3600, (s % 3600) / 60), + _ => format!("{} d {} h", s / 86_400, (s % 86_400) / 3600), + } +} + fn fmt_uptime(d: Duration) -> String { let s = d.as_secs(); format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) From 74eab86b0410d5b761144145fa2259ed569dba7a Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 6 Aug 2026 21:58:20 +0200 Subject: [PATCH 08/14] monitor: keep the plots on a continuous baseline Sparkline scales the series by its largest sample and renders anything that rounds down to zero as a blank cell, so an idle stretch left a hole in the plot and a bursty build came out as islands with gaps between them rather than one shape. Scale the window onto bar heights here instead, mapping it onto 1..=ticks and handing the widget that ceiling, so the lowest bar sits under every sample. Only the floor moves: the tallest sample still fills the pane and the heading carries the real figures. Setting `empty` in the bar set would also fill the gaps, but the widget draws that symbol for every cell above a bar too, which hatches the whole background. --- src/monitor.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/monitor.rs b/src/monitor.rs index db0085f5f..0701b5237 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -742,10 +742,12 @@ impl App { let data: Vec = hist.iter().rev().take(width).rev().copied().collect(); let peak = data.iter().copied().max().unwrap_or(0) as f64 / 100.0; let heading = format!("{title} — now {current:.2}, peak {peak:.2}"); + let (scaled, ticks) = scale_to_ticks(&data, area.height.saturating_sub(2)); frame.render_widget( Sparkline::default() .block(titled(&heading)) - .data(data) + .data(scaled) + .max(ticks) .style(Style::default().fg(colour)), area, ); @@ -963,10 +965,12 @@ impl App { ), _ => "cache growth".to_string(), }; + let (scaled, ticks) = scale_to_ticks(&data, area.height.saturating_sub(2)); frame.render_widget( Sparkline::default() .block(titled(&heading)) - .data(data) + .data(scaled) + .max(ticks) .style(Style::default().fg(Color::Blue)), area, ); @@ -1365,6 +1369,28 @@ fn kv>(name: &str, value: S) -> Span<'static> { Span::raw(format!("{name} {}", value.into())) } +/// Scale a window of samples onto bar heights, in eighths of a cell, and +/// return them with the ceiling to hand `Sparkline::max`. +/// +/// Left to itself the widget scales by the largest sample and renders anything +/// that rounds down to zero as a blank cell, so an idle stretch comes out as a +/// gap and the plot reads as separate islands. Mapping the range onto +/// `1..=ticks` instead keeps the lowest bar under every sample, so the baseline +/// is continuous. Only the floor moves: the tallest sample still fills the +/// pane, and the heading carries the real figures. +fn scale_to_ticks(window: &[u64], height: u16) -> (Vec, u64) { + let ticks = u64::from(height.max(1)) * 8; + let peak = window.iter().copied().max().unwrap_or(0); + let scaled = window + .iter() + .map(|&value| match peak { + 0 => 1, + peak => 1 + value * (ticks - 1) / peak, + }) + .collect(); + (scaled, ticks) +} + /// A right-aligned cell, the shape every numeric column in here wants. fn right>(text: S) -> Cell<'static> { Cell::from(Line::from(text.into()).alignment(Alignment::Right)) From 9b6dbe1b076b68b2dc13fa9c657126da8e9ab1ae Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 7 Aug 2026 10:31:43 +0200 Subject: [PATCH 09/14] monitor: add a pane that follows the server log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watching a build go wrong meant leaving the dashboard for a second terminal running `tail -f` on the file `SCCACHE_ERROR_LOG` points at. Bring it in as a sixth pane. The server has no logging RPC — it logs by having its stderr redirected — so there is nothing to ask it for: a thread follows the file the way `tail -f` does, reading the last 64 KiB at startup and appending what arrives after that. The file need not exist yet, since the monitor may well be started before the server, and a truncated or replaced file starts the view over rather than going quiet. A line the writer has not finished is held back until its newline arrives, so a message never shows up split in two. `--monitor-log` says which file to follow and defaults to `$SCCACHE_ERROR_LOG`. With neither set the pane explains how to turn logging on instead of sitting empty. Lines are coloured by level, and one with no level of its own — the middle of a panic backtrace, say — keeps the colour of the line above it. `e` cycles a level filter, and the pane follows the tail until you scroll, with `End` to catch up again. Also stop this process from logging while the UI is up: the monitor's stderr is the terminal the dashboard is drawn on, and `SCCACHE_LOG` tends to be exported for a whole shell rather than a single command, so any log line at all would land on top of the display and stay there. --- src/cmdline.rs | 19 ++- src/commands.rs | 8 +- src/monitor.rs | 340 +++++++++++++++++++++++++++++++++++++++++--- src/monitor/tail.rs | 144 +++++++++++++++++++ 4 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 src/monitor/tail.rs diff --git a/src/cmdline.rs b/src/cmdline.rs index cc21f5f2d..b73069ff7 100644 --- a/src/cmdline.rs +++ b/src/cmdline.rs @@ -60,9 +60,11 @@ impl FromStr for StatsFormat { pub enum Command { /// Show cache statistics and exit. ShowStats(StatsFormat, bool), - /// Watch cache statistics in a terminal dashboard, polling every `interval`. + /// Watch cache statistics in a terminal dashboard, polling every + /// `interval` and following `log` if there is one. Monitor { interval: Duration, + log: Option, }, /// Run background server. InternalStartServer, @@ -153,6 +155,10 @@ fn get_clap_command() -> clap::Command { .value_name("SECS") .value_parser(clap::value_parser!(f64)) .default_value("1"), + flag_infer_long("monitor-log") + .help("log file for `--monitor` to follow [default: $SCCACHE_ERROR_LOG]") + .value_name("FILE") + .value_parser(clap::value_parser!(PathBuf)), flag_infer_long("start-server") .help("start background server") .action(ArgAction::SetTrue), @@ -306,8 +312,19 @@ pub fn try_parse() -> Result { {MAX_MONITOR_INTERVAL} seconds" ); } + // The server logs to wherever its stderr was redirected, so + // the same variable that told it where to write tells us where + // to read. + let log = matches + .get_one::("monitor-log") + .cloned() + .or_else(|| match env::var_os("SCCACHE_ERROR_LOG") { + Some(path) if !path.is_empty() => Some(PathBuf::from(path)), + _ => None, + }); Ok(Command::Monitor { interval: Duration::from_secs_f64(secs), + log, }) } else if matches.get_flag("start-server") { Ok(Command::StartServer) diff --git a/src/commands.rs b/src/commands.rs index f4fdc0855..bc696de65 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -736,17 +736,17 @@ pub fn run_command(cmd: Command) -> Result { StatsFormat::Json => serde_json::to_writer(&mut io::stdout(), &stats)?, } } - Command::Monitor { interval } => { - trace!("Command::Monitor({:?})", interval); + Command::Monitor { interval, log } => { + trace!("Command::Monitor({:?}, {:?})", interval, log); #[cfg(feature = "monitor")] { // Attach to whatever server is running; the monitor keeps // retrying if there is none yet, so don't start one here. - crate::monitor::run(get_addr(), interval)?; + crate::monitor::run(get_addr(), interval, log)?; } #[cfg(not(feature = "monitor"))] { - let _ = interval; + let _ = (interval, log); bail!( "The monitor UI was not compiled in, please rebuild with the `monitor` feature" ); diff --git a/src/monitor.rs b/src/monitor.rs index 0701b5237..ff8611c63 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -24,8 +24,11 @@ //! server being watched won't shut down on its own. Press `p` to pause polling //! if that matters. +mod tail; + use std::collections::{HashMap, VecDeque}; use std::io; +use std::path::PathBuf; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::thread; use std::time::{Duration, Instant}; @@ -47,6 +50,7 @@ use crate::errors::*; use crate::protocol::{Request, Response}; use crate::server::{DistInfo, PerLanguageCount, ServerInfo, ServerStats}; use crate::util::fmt_duration_as_secs; +use tail::LogEvent; /// How many samples of history to keep for the sparklines. const HISTORY: usize = 512; @@ -59,13 +63,18 @@ const MESSAGE_TTL: Duration = Duration::from_secs(4); /// elapsed times in the status bar keep moving. const REDRAW_INTERVAL: Duration = Duration::from_millis(500); -const TABS: [&str; 5] = ["Overview", "Languages", "Reasons", "Cache", "Dist"]; +const TABS: [&str; 6] = ["Overview", "Languages", "Reasons", "Cache", "Dist", "Logs"]; + +/// How many log lines to keep. Enough to scroll back through a build's worth of +/// output without letting a chatty `SCCACHE_LOG=trace` grow without bound. +const LOG_LINES: usize = 10_000; /// Run the monitor until the user quits. -pub fn run(addr: crate::net::SocketAddr, interval: Duration) -> Result<()> { +pub fn run(addr: crate::net::SocketAddr, interval: Duration, log: Option) -> Result<()> { let interval = interval.clamp(MIN_INTERVAL, MAX_INTERVAL); let (cmd_tx, cmd_rx) = mpsc::channel(); let (sample_tx, sample_rx) = mpsc::channel(); + let (log_tx, log_rx) = mpsc::channel(); let addr_display = addr.to_string(); // The handle is dropped on purpose: the poller can be blocked in a request @@ -77,11 +86,25 @@ pub fn run(addr: crate::net::SocketAddr, interval: Duration) -> Result<()> { .name("sccache-monitor-poll".into()) .spawn(move || poll_loop(addr, interval, cmd_rx, sample_tx))?; - let mut app = App::new(addr_display, interval); + if let Some(path) = log.clone() { + // Same reasoning as the poller: nothing to join, let the process reap + // it when the UI is done. + tail::spawn(path, log_tx)?; + } + + let mut app = App::new(addr_display, interval, log); install_panic_hook(); + // This process logs to its own stderr, which is the terminal the dashboard + // is drawing on, so anything logged lands on top of the display and stays + // there. `SCCACHE_LOG` is usually exported for a whole shell rather than a + // single command, so silence our own logging while the UI owns the screen; + // the server's log is a separate file, and the Logs pane shows it. + let log_level = log::max_level(); + log::set_max_level(log::LevelFilter::Off); let terminal = ratatui::init(); - let result = app.main_loop(terminal, &cmd_tx, &sample_rx); + let result = app.main_loop(terminal, &cmd_tx, &sample_rx, &log_rx); ratatui::restore(); + log::set_max_level(log_level); let _ = cmd_tx.send(Cmd::Quit); result @@ -268,10 +291,33 @@ struct App { last_update: Option, error: Option, message: Option<(String, Instant)>, + logs: LogView, +} + +/// The Logs pane: what has been read from the server's log file, and where the +/// reader is looking. +struct LogView { + /// The file being followed, if one was configured. + path: Option, + lines: VecDeque, + /// Lines scrolled back from the end. Zero means sitting on the tail. + scroll: usize, + /// Whether new lines pull the view along with them. + follow: bool, + /// Hide anything less severe than this. + min_level: Option, + /// Why there is nothing to show, when there is nothing to show. + status: Option, +} + +/// A log line and the level it was written at. +struct LogLine { + text: String, + level: log::Level, } impl App { - fn new(addr: String, interval: Duration) -> Self { + fn new(addr: String, interval: Duration, log: Option) -> Self { App { addr, interval, @@ -296,6 +342,7 @@ impl App { last_update: None, error: None, message: None, + logs: LogView::new(log), } } @@ -304,6 +351,7 @@ impl App { mut terminal: DefaultTerminal, cmds: &Sender, samples: &Receiver, + logs: &Receiver, ) -> Result<()> { let mut dirty = true; let mut drawn = Instant::now(); @@ -323,21 +371,26 @@ impl App { // Anything the terminal reports (a resize in particular) needs // a repaint, whether or not we act on it. dirty = true; - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press && !self.on_key(key, cmds)? { - return Ok(()); - } + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + && !self.on_key(key, cmds)? + { + return Ok(()); } } while let Ok(sample) = samples.try_recv() { self.ingest(sample); dirty = true; } - if let Some((_, at)) = self.message { - if at.elapsed() > MESSAGE_TTL { - self.message = None; - dirty = true; - } + while let Ok(event) = logs.try_recv() { + self.logs.ingest(event); + dirty = true; + } + if let Some((_, at)) = self.message + && at.elapsed() > MESSAGE_TTL + { + self.message = None; + dirty = true; } } } @@ -348,6 +401,11 @@ impl App { // Zeroing is irreversible, so it takes two presses of `z` in a row; // any other key cancels the pending confirmation. let confirming = std::mem::take(&mut self.confirm_zero); + // The Logs pane takes the keys that only mean something there before + // they reach the global bindings. + if TABS[self.tab] == "Logs" && self.on_log_key(key) { + return Ok(true); + } match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(false), KeyCode::Char('c' | 'd') if ctrl => return Ok(false), @@ -382,7 +440,7 @@ impl App { KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { self.select_tab((self.tab + TABS.len() - 1) % TABS.len(), cmds); } - KeyCode::Char(c @ '1'..='5') => { + KeyCode::Char(c @ '1'..='6') => { let idx = c as usize - '1' as usize; self.select_tab(idx, cmds); } @@ -391,6 +449,39 @@ impl App { Ok(true) } + /// Handle the keys that belong to the Logs pane. Returns whether the key + /// was one of them. + fn on_log_key(&mut self, key: KeyEvent) -> bool { + // A screenful, near enough: the pane is most of the window. + const PAGE: usize = 20; + match key.code { + KeyCode::Up | KeyCode::Char('k') => self.logs.scroll_back(1), + KeyCode::Down | KeyCode::Char('j') => self.logs.scroll_forward(1), + KeyCode::PageUp => self.logs.scroll_back(PAGE), + KeyCode::PageDown => self.logs.scroll_forward(PAGE), + KeyCode::Home => self.logs.jump_to_start(), + KeyCode::End => self.logs.jump_to_tail(), + KeyCode::Char('f') => { + self.logs.follow = !self.logs.follow; + if self.logs.follow { + self.logs.scroll = 0; + } + let following = self.logs.follow; + self.note(if following { + "following the log" + } else { + "stopped following" + }); + } + KeyCode::Char('e') => { + let note = self.logs.cycle_level(); + self.note(note); + } + _ => return false, + } + true + } + fn select_tab(&mut self, tab: usize, cmds: &Sender) { if tab == self.tab { return; @@ -502,6 +593,132 @@ impl App { } } +impl LogView { + fn new(path: Option) -> Self { + LogView { + path, + lines: VecDeque::new(), + scroll: 0, + follow: true, + min_level: None, + status: None, + } + } + + fn ingest(&mut self, event: LogEvent) { + match event { + LogEvent::Lines(lines) => { + self.status = None; + for text in lines { + // A line with no level of its own continues the one before + // it: panics and backtraces run over several lines and + // should not lose their colour halfway down. + let level = level_of(&text) + .or_else(|| self.lines.back().map(|l| l.level)) + .unwrap_or(log::Level::Info); + if self.lines.len() == LOG_LINES { + self.lines.pop_front(); + // Keep the viewport over the same lines as they slide. + self.scroll = self.scroll.saturating_sub(1); + } + self.lines.push_back(LogLine { text, level }); + } + if self.follow { + self.scroll = 0; + } + } + LogEvent::Rotated => { + self.lines.clear(); + self.scroll = 0; + self.status = Some("the log file was truncated or replaced".to_string()); + } + LogEvent::Waiting(reason) => { + if self.lines.is_empty() { + self.status = Some(reason); + } + } + } + } + + /// The lines that pass the level filter, oldest first. + fn visible(&self) -> Vec<&LogLine> { + self.lines + .iter() + .filter(|line| match self.min_level { + Some(min) => line.level <= min, + None => true, + }) + .collect() + } + + /// Scroll back by `lines`, which stops following the tail. + fn scroll_back(&mut self, lines: usize) { + self.scroll = (self.scroll + lines).min(self.visible().len()); + self.follow = false; + } + + /// Scroll forward by `lines`, resuming the tail on arrival. + fn scroll_forward(&mut self, lines: usize) { + self.scroll = self.scroll.saturating_sub(lines); + if self.scroll == 0 { + self.follow = true; + } + } + + fn jump_to_tail(&mut self) { + self.scroll = 0; + self.follow = true; + } + + fn jump_to_start(&mut self) { + self.scroll = self.visible().len(); + self.follow = false; + } + + /// Cycle the level filter: everything, then progressively less. + fn cycle_level(&mut self) -> String { + self.min_level = match self.min_level { + None => Some(log::Level::Debug), + Some(log::Level::Debug) => Some(log::Level::Info), + Some(log::Level::Info) => Some(log::Level::Warn), + Some(log::Level::Warn) => Some(log::Level::Error), + Some(_) => None, + }; + // The filter changes what "scrolled back N lines" points at; the tail + // is the one position that always makes sense. + self.jump_to_tail(); + format!("showing {}", self.level_label()) + } + + fn level_label(&self) -> String { + match self.min_level { + None => "all levels".to_string(), + Some(level) => format!("{level} and worse"), + } + } +} + +/// The level env_logger wrote a line at, if it wrote one. +/// +/// The format is `[timestamp LEVEL module] message`, so only the start of the +/// line is worth searching: a message that happens to mention `ERROR` should +/// not turn the line red. +fn level_of(line: &str) -> Option { + let head = line.get(..line.len().min(64)).unwrap_or(line); + for (name, level) in [ + ("ERROR", log::Level::Error), + ("WARN", log::Level::Warn), + ("INFO", log::Level::Info), + ("DEBUG", log::Level::Debug), + ("TRACE", log::Level::Trace), + ] { + if head.contains(name) { + return Some(level); + } + } + None +} + fn push(hist: &mut VecDeque, value: f64) { // Sparklines take integers; keep two decimals of resolution. if hist.len() == HISTORY { @@ -531,6 +748,7 @@ impl App { "Reasons" => self.draw_reasons(frame, body), "Cache" => self.draw_cache(frame, body), "Dist" => self.draw_dist(frame, body), + "Logs" => self.draw_logs(frame, body), _ => self.draw_overview(frame, body), } self.draw_footer(frame, footer); @@ -1177,6 +1395,66 @@ impl App { } } + fn draw_logs(&self, frame: &mut Frame<'_>, area: Rect) { + let Some(path) = self.logs.path.as_ref() else { + frame.render_widget( + Paragraph::new(LOG_HELP.lines().map(Line::from).collect::>()) + .block(titled("Logs")) + .wrap(Wrap { trim: false }), + area, + ); + return; + }; + + let lines = self.logs.visible(); + let heading = format!( + "Logs — {} · {} lines · {} · {}", + path.display(), + lines.len(), + self.logs.level_label(), + if self.logs.follow { + "following" + } else { + "paused, End to catch up" + }, + ); + let block = titled(&heading); + if lines.is_empty() { + let empty = if !self.logs.lines.is_empty() { + // There is a log; the filter is simply hiding all of it. + format!( + "no lines at {} — press e to widen the filter", + self.logs.level_label() + ) + } else if let Some(reason) = self.logs.status.as_deref() { + format!("waiting for {}: {reason}", path.display()) + } else { + format!( + "nothing to show yet — is the server running with SCCACHE_LOG set?\n\n{LOG_HELP}" + ) + }; + frame.render_widget( + placeholder(&empty).block(block).wrap(Wrap { trim: false }), + area, + ); + return; + } + + // A screenful of lines, shifted back from the newest by `scroll`. + // Clamping the start rather than the end keeps the pane full when the + // reader scrolls past the oldest line we still hold. + let height = block.inner(area).height as usize; + let start = lines.len().saturating_sub(height + self.logs.scroll); + let end = (start + height).min(lines.len()); + let text: Vec> = lines[start..end] + .iter() + .map(|line| { + Line::from(line.text.as_str()).style(Style::default().fg(level_colour(line.level))) + }) + .collect(); + frame.render_widget(Paragraph::new(text).block(block), area); + } + fn draw_dist(&self, frame: &mut Frame<'_>, area: Rect) { let block = titled("Distributed compilation"); let text = match &self.dist { @@ -1222,7 +1500,7 @@ fn draw_help(frame: &mut Frame<'_>, area: Rect) { let lines = vec![ Line::from("Keys".to_string()).style(Style::default().fg(Color::Cyan).bold()), Line::from(" q, Esc, Ctrl-C/D quit"), - Line::from(" 1-5, Tab, ←/→, h/l switch pane"), + Line::from(" 1-6, Tab, ←/→, h/l switch pane"), Line::from(" a per-compiler instead of per-language counts"), Line::from(" r poll now, even while paused"), Line::from(" p, Space pause/resume polling"), @@ -1230,6 +1508,12 @@ fn draw_help(frame: &mut Frame<'_>, area: Rect) { Line::from(" z z zero the server's statistics (twice to confirm)"), Line::from(" ?, F1 close this help"), Line::from(""), + Line::from("In the Logs pane".to_string()).style(Style::default().fg(Color::Cyan).bold()), + Line::from(" ↑/↓, k/j, PgUp/PgDn scroll, which stops following"), + Line::from(" Home / End oldest line / back to following the tail"), + Line::from(" f follow the tail or stop"), + Line::from(" e cycle the level filter"), + Line::from(""), Line::from("The monitor is an ordinary client of the running server. Each poll"), Line::from("resets the server's idle-shutdown timer, so a watched server will not"), Line::from("time out on its own — pause polling if you need it to."), @@ -1237,7 +1521,7 @@ fn draw_help(frame: &mut Frame<'_>, area: Rect) { let [area] = Layout::horizontal([Constraint::Length(74)]) .flex(Flex::Center) .areas(area); - let [area] = Layout::vertical([Constraint::Length(16)]) + let [area] = Layout::vertical([Constraint::Length(22)]) .flex(Flex::Center) .areas(area); frame.render_widget(Clear, area); @@ -1369,6 +1653,28 @@ fn kv>(name: &str, value: S) -> Span<'static> { Span::raw(format!("{name} {}", value.into())) } +/// What to tell someone who opened the Logs pane with no log to show. +const LOG_HELP: &str = "\ +The server logs by having its stderr redirected to a file, so start it with one +and point the monitor at it: + + SCCACHE_ERROR_LOG=/tmp/sccache.log SCCACHE_LOG=debug sccache --start-server + sccache --monitor --monitor-log /tmp/sccache.log + +`--monitor-log` defaults to $SCCACHE_ERROR_LOG, so exporting that in the shell +you run the monitor from is enough. The file is followed as it grows, and +picked up whenever it appears, so the monitor can be started first."; + +fn level_colour(level: log::Level) -> Color { + match level { + log::Level::Error => Color::Red, + log::Level::Warn => Color::Yellow, + log::Level::Info => Color::Reset, + log::Level::Debug => Color::Gray, + log::Level::Trace => Color::DarkGray, + } +} + /// Scale a window of samples onto bar heights, in eighths of a cell, and /// return them with the ceiling to hand `Sparkline::max`. /// diff --git a/src/monitor/tail.rs b/src/monitor/tail.rs new file mode 100644 index 000000000..4103c9638 --- /dev/null +++ b/src/monitor/tail.rs @@ -0,0 +1,144 @@ +// Copyright 2026 Mozilla Foundation +// +// 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. + +//! Following a log file, the way `tail -f` does. +//! +//! The server writes its log by having stderr redirected to `SCCACHE_ERROR_LOG`, +//! so there is nothing to ask it for: the monitor just follows the file. It may +//! not exist yet when the monitor starts, and it may be replaced or truncated +//! underneath us, so keep checking rather than giving up. + +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::Sender; +use std::thread; +use std::time::Duration; + +/// How much of the end of the file to read at startup. Enough to fill the pane +/// several times over without reading a log that has been growing all day. +const INITIAL_BYTES: u64 = 64 * 1024; +/// How often to look for new output once the end of the file is reached. +const POLL: Duration = Duration::from_millis(250); +/// Most lines to hand over at once, so that a burst cannot stall the UI thread. +const BATCH: usize = 512; + +/// Something that happened to the log file being followed. +pub enum LogEvent { + /// Lines appended since the last event, oldest first. + Lines(Vec), + /// The file was truncated or replaced: what came before is gone. + Rotated, + /// The file cannot be read yet; the reason is worth showing. + Waiting(String), +} + +/// Follow `path` on a background thread, reporting to `tx` until it hangs up. +pub fn spawn(path: PathBuf, tx: Sender) -> std::io::Result<()> { + thread::Builder::new() + .name("sccache-monitor-tail".into()) + .spawn(move || follow(&path, &tx))?; + Ok(()) +} + +fn follow(path: &Path, tx: &Sender) { + let mut complained = false; + loop { + let file = match File::open(path) { + Ok(file) => file, + Err(e) => { + // Say why once, rather than on every attempt. + if !complained { + complained = true; + if tx.send(LogEvent::Waiting(format!("{e}"))).is_err() { + return; + } + } + thread::sleep(POLL); + continue; + } + }; + complained = false; + if !read_to_end_of_time(path, file, tx) { + return; + } + } +} + +/// Read `file` until it is rotated or the receiver hangs up. Returns false when +/// the caller should stop, true when it should reopen the path. +fn read_to_end_of_time(path: &Path, mut file: File, tx: &Sender) -> bool { + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + let mut pos = len.saturating_sub(INITIAL_BYTES); + // Seeking into the middle of the file lands mid-line as often as not; drop + // whatever is left of that line rather than showing half of it. + let mut skip_partial_line = pos > 0; + if file.seek(SeekFrom::Start(pos)).is_err() { + return true; + } + + let mut reader = BufReader::new(file); + let mut batch: Vec = Vec::new(); + // A line the writer has not finished yet: hold it until its newline shows + // up, so a message never arrives split in two. + let mut partial = String::new(); + let mut buf = String::new(); + + loop { + buf.clear(); + match reader.read_line(&mut buf) { + Ok(0) => { + if !batch.is_empty() + && tx + .send(LogEvent::Lines(std::mem::take(&mut batch))) + .is_err() + { + return false; + } + // A file that has shrunk was truncated or replaced. + match std::fs::metadata(path) { + Ok(meta) if meta.len() < pos => { + return tx.send(LogEvent::Rotated).is_ok(); + } + Err(_) => return true, + _ => {} + } + thread::sleep(POLL); + } + Ok(read) => { + pos += read as u64; + if !buf.ends_with('\n') { + partial.push_str(&buf); + continue; + } + let mut line = std::mem::take(&mut partial); + line.push_str(buf.trim_end_matches(['\n', '\r'])); + if skip_partial_line { + skip_partial_line = false; + } else { + batch.push(line); + } + if batch.len() >= BATCH + && tx + .send(LogEvent::Lines(std::mem::take(&mut batch))) + .is_err() + { + return false; + } + } + // A read error is usually the file going away under us. + Err(_) => return true, + } + } +} From 7284bb605ff3e8ca9f2c4edd15ecad93943ffbc0 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 7 Aug 2026 10:31:43 +0200 Subject: [PATCH 10/14] scripts: give the monitor demo a log to show, and keep it off the screen Point the demo server at a log file and turn logging on, so the Logs pane has something to follow. Two things were writing over the dashboard. Job control is enabled so the load generator leads its own process group, but that also had the shell announcing every background compile ("[1] 1234", "[1]+ Done") on the terminal; turn the announcements off inside the subshell and send anything else it says to /dev/null. The monitor's own stderr now goes to the log file, so a panic ends up in the Logs pane instead of smeared across the display. Document the pane, its keys, and how the log gets there. --- README.md | 2 +- docs/Monitoring.md | 40 +++++++++++++++++++++++++++++++++++++++- scripts/try-monitor.sh | 18 +++++++++++++++--- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a73de27a8..3239d5483 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,7 @@ In situations where the cache contains broken build artifacts, it can be necessa Debugging --------- -You can set the `SCCACHE_ERROR_LOG` environment variable to a path and set `SCCACHE_LOG` to get the server process to redirect its logging there (including the output of unhandled panics, since the server sets `RUST_BACKTRACE=1` internally). +You can set the `SCCACHE_ERROR_LOG` environment variable to a path and set `SCCACHE_LOG` to get the server process to redirect its logging there (including the output of unhandled panics, since the server sets `RUST_BACKTRACE=1` internally). The [monitor](docs/Monitoring.md) can follow that file live in its Logs pane. SCCACHE_ERROR_LOG=/tmp/sccache_log.txt SCCACHE_LOG=debug sccache diff --git a/docs/Monitoring.md b/docs/Monitoring.md index 207f839d0..6b2acd01d 100644 --- a/docs/Monitoring.md +++ b/docs/Monitoring.md @@ -46,13 +46,14 @@ server being stopped and restarted underneath it. | Reasons | Why compilations were not cached, and which distributed servers ran compilations, each with its share of the total. | | Cache | Cache location, size against the configured maximum, base directories, preprocessor cache mode, and a per-level table when [multi-level caching](MultiLevel.md) is enabled. A local disk cache also gets a used/free pie, how fast it is filling, when it will be full at that rate, and a plot of the growth; a remote one, whose size and ceiling are usually unknown, keeps a one-line gauge. Once the LRU starts trimming the cache at its ceiling, the projection gives way to a count of how much has been evicted. | | Dist | Distributed-compilation status: scheduler URL, scheduler status, and failed distributed compilations. Only polled while this pane is open, since it can involve a round trip to the scheduler. | +| Logs | The server's log file, followed as it grows, coloured by level and filterable. See [Following the log](#following-the-log). | ## Keys | Key | Action | | --- | --- | | `q`, `Esc`, `Ctrl-C`, `Ctrl-D` | quit | -| `1`–`5`, `Tab`, `←`/`→`, `h`/`l` | switch pane | +| `1`–`6`, `Tab`, `←`/`→`, `h`/`l` | switch pane | | `a` | per-compiler instead of per-language counts | | `r` | poll now, even while paused | | `p`, `Space` | pause / resume polling | @@ -60,6 +61,43 @@ server being stopped and restarted underneath it. | `z` `z` | zero the server's statistics; the second `z` confirms, any other key cancels | | `?`, `F1` | help | +In the Logs pane: + +| Key | Action | +| --- | --- | +| `↑`/`↓`, `k`/`j`, `PgUp`/`PgDn` | scroll, which stops following the tail | +| `Home` / `End` | oldest line held / back to following the tail | +| `f` | follow the tail, or stop | +| `e` | cycle the level filter: everything, then `DEBUG`, `INFO`, `WARN`, `ERROR` and worse | + +## Following the log + +The server has no logging RPC: it logs by having its stderr redirected to the +file named by `SCCACHE_ERROR_LOG`, with `SCCACHE_LOG` setting the verbosity (see +[Debugging](../README.md#debugging)). So start the server with a log and point +the monitor at it: + +``` +SCCACHE_ERROR_LOG=/tmp/sccache.log SCCACHE_LOG=debug sccache --start-server +sccache --monitor --monitor-log /tmp/sccache.log +``` + +`--monitor-log` defaults to `$SCCACHE_ERROR_LOG`, so exporting that variable in +the shell you run the monitor from is enough. Without either, the Logs pane +explains this rather than sitting empty. + +The file is followed the way `tail -f` does: the monitor reads the last 64 KiB +at startup and appends what arrives after that, keeping the most recent 10,000 +lines to scroll back through. It does not need the file to exist yet — it will +pick it up when it appears — and if the file is truncated or replaced it starts +over rather than going quiet. Lines are coloured by level, and a line with no +level of its own, such as the middle of a panic backtrace, keeps the colour of +the line above it. + +Note that the monitor silences *its own* logging while the dashboard is up. +Otherwise a `SCCACHE_LOG` exported for the whole shell would have this process +writing log lines onto the terminal it is drawing the dashboard on. + ## How the numbers are derived The server only exposes cumulative counters, so per-second rates are computed diff --git a/scripts/try-monitor.sh b/scripts/try-monitor.sh index d81df8de3..85a089d70 100755 --- a/scripts/try-monitor.sh +++ b/scripts/try-monitor.sh @@ -47,6 +47,9 @@ export SCCACHE_SERVER_PORT="$PORT" export SCCACHE_CACHE_SIZE=200M # Keep the server around even while the load generator is idle. export SCCACHE_IDLE_TIMEOUT=0 +# Give the Logs pane something to follow. +export SCCACHE_ERROR_LOG="$WORK/sccache.log" +export SCCACHE_LOG=${SCCACHE_LOG:-debug} unset SCCACHE_SERVER_UDS LOAD_PID= @@ -71,6 +74,7 @@ if ! command -v "$CC" >/dev/null; then fi echo "==> starting a server on 127.0.0.1:$PORT with cache in $SCCACHE_DIR" +echo "==> logging to $SCCACHE_ERROR_LOG at $SCCACHE_LOG level" "$SCCACHE" --start-server # Background load: a mix of misses (fresh sources), hits (recompiling the same @@ -94,6 +98,11 @@ set -m # calls run under `wait`; none of that should take the loop down, so drop # the errexit and pipefail inherited from above. set +e +o pipefail + # Job control is on so that this subshell leads its own process group, but + # inside it the shell would announce every background compile ("[1] 1234", + # "[1]+ Done"). Those land on the terminal the dashboard is drawing on and + # corrupt it, so turn the announcements off again here. + set +m cd "$WORK" i=0 while true; do @@ -130,11 +139,14 @@ set -m # flat line at the top. sleep 2 done -) & +) >/dev/null 2>&1 & LOAD_PID=$! # Back to the default, so the monitor keeps the terminal's foreground group. set +m -echo "==> opening the dashboard (q to quit, ? for help, 1-5 for panes)" +echo "==> opening the dashboard (q to quit, ? for help, 1-6 for panes)" sleep 1 -"$SCCACHE" --monitor --monitor-interval "$INTERVAL" +# Send the monitor's own stderr to the log rather than the screen: a panic or +# a stray warning painted over the dashboard is unreadable, and this way it +# shows up in the Logs pane instead. +"$SCCACHE" --monitor --monitor-interval "$INTERVAL" 2>>"$SCCACHE_ERROR_LOG" From 05281c3f6d9247988dff6bdc366b160701c3a3dd Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 7 Aug 2026 10:46:49 +0200 Subject: [PATCH 11/14] monitor: strip the colour codes out of log lines A server started from a terminal writes escape sequences into its log file: logging is initialised at the top of main, before the daemon redirects its stderr, so env_logger sees a tty and colours its output for one. The Logs pane showed those escapes as text, every line starting with `^[[90m[^[[0m`. Strip them as the lines are read. The pane colours by level itself, and stripping before the level is parsed keeps that working on lines whose escapes would otherwise push the level past where it looks for it. This fixes the display for logs that already have the escapes in them, whoever wrote them. The escapes are still in the file, where they are just as unwelcome to `grep` and `less`; teaching the daemon not to write them is a separate change. --- docs/Monitoring.md | 4 +++- src/monitor/tail.rs | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/Monitoring.md b/docs/Monitoring.md index 6b2acd01d..3e05e6514 100644 --- a/docs/Monitoring.md +++ b/docs/Monitoring.md @@ -92,7 +92,9 @@ lines to scroll back through. It does not need the file to exist yet — it will pick it up when it appears — and if the file is truncated or replaced it starts over rather than going quiet. Lines are coloured by level, and a line with no level of its own, such as the middle of a panic backtrace, keeps the colour of -the line above it. +the line above it. Any colour codes already in the file are stripped: logging is +set up before the daemon redirects its stderr, so a server started from a +terminal writes the escapes env_logger chose for a tty into the log. Note that the monitor silences *its own* logging while the dashboard is up. Otherwise a `SCCACHE_LOG` exported for the whole shell would have this process diff --git a/src/monitor/tail.rs b/src/monitor/tail.rs index 4103c9638..aa6c82b1f 100644 --- a/src/monitor/tail.rs +++ b/src/monitor/tail.rs @@ -124,6 +124,12 @@ fn read_to_end_of_time(path: &Path, mut file: File, tx: &Sender) -> bo } let mut line = std::mem::take(&mut partial); line.push_str(buf.trim_end_matches(['\n', '\r'])); + // Logging is initialised before the daemon redirects its + // stderr, so a server started from a terminal writes the + // colour codes env_logger picked for a tty into the file. + // Drop them: the pane colours lines by level itself, and the + // escapes would otherwise show up as text. + let line = strip_ansi_escapes::strip_str(&line); if skip_partial_line { skip_partial_line = false; } else { From b2312ac90d01a04324d29561708b3d1ece7b834a Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 7 Aug 2026 19:22:54 +0200 Subject: [PATCH 12/14] monitor: redraw the whole screen on Ctrl-L MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the cells that changed are sent to the terminal, which is what keeps the dashboard cheap over ssh. The cost is that anything else writing to the same terminal leaves text in cells we believe we have already painted, and it stays there: the parts of the intruding line that happen to sit over a plot get repainted within a frame or two, and the rest sits in the middle of a pane until something else changes those cells. Add the redraw every other full-screen program binds to Ctrl-L. Document what causes this — usually a build sharing the window, since every sccache client writes its warnings to that terminal — and that a window of its own avoids it. --- docs/Monitoring.md | 15 +++++++++++++++ src/monitor.rs | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/Monitoring.md b/docs/Monitoring.md index 3e05e6514..2506015e2 100644 --- a/docs/Monitoring.md +++ b/docs/Monitoring.md @@ -59,6 +59,7 @@ server being stopped and restarted underneath it. | `p`, `Space` | pause / resume polling | | `+` / `-` | double / halve the poll interval (200 ms to 60 s) | | `z` `z` | zero the server's statistics; the second `z` confirms, any other key cancels | +| `Ctrl-L` | redraw everything | | `?`, `F1` | help | In the Logs pane: @@ -100,6 +101,20 @@ Note that the monitor silences *its own* logging while the dashboard is up. Otherwise a `SCCACHE_LOG` exported for the whole shell would have this process writing log lines onto the terminal it is drawing the dashboard on. +## Sharing a terminal + +The dashboard sends only the cells that changed, which is what keeps it cheap +over ssh, but it also means anything *else* writing to the same terminal leaves +text sitting in cells the monitor believes it has already painted. A build in +the same window is the usual culprit: every sccache client writes its warnings +to that terminal's stderr, and they land on top of the display. `Ctrl-L` +redraws everything, the same as it does in `less` or `htop`; running the monitor +in a window of its own avoids the problem altogether. + +The monitor keeps its own output off the screen: it silences this process's +logging while the dashboard is up, since `SCCACHE_LOG` is usually exported for a +whole shell rather than one command. + ## How the numbers are derived The server only exposes cumulative counters, so per-second rates are computed diff --git a/src/monitor.rs b/src/monitor.rs index ff8611c63..f2d7c226f 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -270,6 +270,8 @@ struct App { help: bool, /// `z` was pressed once; a second `z` zeroes the statistics. confirm_zero: bool, + /// Repaint every cell on the next frame, rather than just what changed. + repaint: bool, /// Latest snapshot, and the previous one used for rate computation. info: Option, prev: Option<(Instant, ServerStats)>, @@ -327,6 +329,7 @@ impl App { paused: false, help: false, confirm_zero: false, + repaint: false, info: None, prev: None, dist: None, @@ -360,6 +363,15 @@ impl App { // to keep the clocks in the status bar ticking: a full repaint at // the input poll rate is wasteful, especially over ssh. if dirty || drawn.elapsed() >= REDRAW_INTERVAL { + // Only what changed is sent to the terminal, so anything else + // that writes to the same terminal — a build running in the + // window, a stray warning — leaves text in cells we believe we + // have already painted, and it stays there. Ctrl-L clears and + // redraws the lot, the way it does in every other full-screen + // program. + if std::mem::take(&mut self.repaint) { + terminal.clear()?; + } terminal.draw(|frame| self.draw(frame))?; dirty = false; drawn = Instant::now(); @@ -409,6 +421,7 @@ impl App { match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(false), KeyCode::Char('c' | 'd') if ctrl => return Ok(false), + KeyCode::Char('l') if ctrl => self.repaint = true, KeyCode::Char('?') | KeyCode::F(1) => self.help = !self.help, KeyCode::Char('a') => self.advanced = !self.advanced, KeyCode::Char('r') => { @@ -1506,6 +1519,7 @@ fn draw_help(frame: &mut Frame<'_>, area: Rect) { Line::from(" p, Space pause/resume polling"), Line::from(" +/- double/halve the poll interval"), Line::from(" z z zero the server's statistics (twice to confirm)"), + Line::from(" Ctrl-L redraw, if something else wrote over the screen"), Line::from(" ?, F1 close this help"), Line::from(""), Line::from("In the Logs pane".to_string()).style(Style::default().fg(Color::Cyan).bold()), @@ -1521,7 +1535,7 @@ fn draw_help(frame: &mut Frame<'_>, area: Rect) { let [area] = Layout::horizontal([Constraint::Length(74)]) .flex(Flex::Center) .areas(area); - let [area] = Layout::vertical([Constraint::Length(22)]) + let [area] = Layout::vertical([Constraint::Length(23)]) .flex(Flex::Center) .areas(area); frame.render_widget(Clear, area); From 1093c1faa3d73382e9cc7b345ea83eec3c291131 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 7 Aug 2026 19:37:30 +0200 Subject: [PATCH 13/14] scripts: keep every subprocess in the demo off the terminal The dashboard sends only the cells that changed, so a single line from anything else sharing the terminal sits in the middle of a pane until something repaints over it. The load generator was already silenced, but the build and the server start were not, and neither was the daemon that `--start-server` spawns: it inherits the script's stdout and stderr until it redirects its own, and a client reporting a bad config writes there too. That is where the stray "failed to open file" lines were coming from. Send both to a setup log, shown only if the step fails, so nothing but this script's own progress lines reaches the terminal before the dashboard takes it over. Give the load generator /dev/null for stdin as well, so it cannot read from the terminal either. Install the cleanup trap before the first thing that can fail, now that the work directory is created ahead of the build, and check the binary exists before asking it to stop a server. --- scripts/try-monitor.sh | 57 +++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/scripts/try-monitor.sh b/scripts/try-monitor.sh index 85a089d70..7617cb68a 100755 --- a/scripts/try-monitor.sh +++ b/scripts/try-monitor.sh @@ -33,25 +33,8 @@ done cd "$(dirname "$0")/.." ROOT=$(pwd) -echo "==> building sccache --features monitor ($PROFILE)" -if [ "$PROFILE" = release ]; then - cargo build --release --features monitor -else - cargo build --features monitor -fi SCCACHE="$ROOT/target/$PROFILE/sccache" - WORK=$(mktemp -d "${TMPDIR:-/tmp}/sccache-monitor-demo.XXXXXX") -export SCCACHE_DIR="$WORK/cache" -export SCCACHE_SERVER_PORT="$PORT" -export SCCACHE_CACHE_SIZE=200M -# Keep the server around even while the load generator is idle. -export SCCACHE_IDLE_TIMEOUT=0 -# Give the Logs pane something to follow. -export SCCACHE_ERROR_LOG="$WORK/sccache.log" -export SCCACHE_LOG=${SCCACHE_LOG:-debug} -unset SCCACHE_SERVER_UDS - LOAD_PID= cleanup() { @@ -62,11 +45,39 @@ cleanup() { # compile it had already started running. kill -- -"$LOAD_PID" 2>/dev/null || kill "$LOAD_PID" 2>/dev/null fi - "$SCCACHE" --stop-server >/dev/null 2>&1 + [ -x "$SCCACHE" ] && "$SCCACHE" --stop-server >/dev/null 2>&1 rm -rf "$WORK" } +# Installed before anything else can fail, so a bad build does not leave the +# work directory behind. trap cleanup EXIT +# Nothing but this script writes to the terminal: the dashboard sends only the +# cells that changed, so a stray line from anything sharing the terminal sits +# in the middle of a pane until something repaints over it. Every subprocess +# therefore goes to a file, shown only if that subprocess fails. +SETUP_LOG="$WORK/setup.log" + +echo "==> building sccache --features monitor ($PROFILE)" +BUILD=(cargo build --features monitor) +if [ "$PROFILE" = release ]; then + BUILD=(cargo build --release --features monitor) +fi +if ! "${BUILD[@]}" >"$SETUP_LOG" 2>&1; then + cat "$SETUP_LOG" >&2 + exit 1 +fi + +export SCCACHE_DIR="$WORK/cache" +export SCCACHE_SERVER_PORT="$PORT" +export SCCACHE_CACHE_SIZE=200M +# Keep the server around even while the load generator is idle. +export SCCACHE_IDLE_TIMEOUT=0 +# Give the Logs pane something to follow. +export SCCACHE_ERROR_LOG="$WORK/sccache.log" +export SCCACHE_LOG=${SCCACHE_LOG:-debug} +unset SCCACHE_SERVER_UDS + CC=${CC:-cc} if ! command -v "$CC" >/dev/null; then echo "no C compiler found (set CC to override)" >&2 @@ -75,7 +86,13 @@ fi echo "==> starting a server on 127.0.0.1:$PORT with cache in $SCCACHE_DIR" echo "==> logging to $SCCACHE_ERROR_LOG at $SCCACHE_LOG level" -"$SCCACHE" --start-server +# The daemon inherits these until it redirects its own stderr, and a client +# that has to report a bad config says so on stderr, so send both to the log +# rather than the terminal the dashboard is about to take over. +if ! "$SCCACHE" --start-server >>"$SETUP_LOG" 2>&1 &2 + exit 1 +fi # Background load: a mix of misses (fresh sources), hits (recompiling the same # source), a non-cacheable call (-E) and a compile failure, so that every pane @@ -139,7 +156,7 @@ set -m # flat line at the top. sleep 2 done -) >/dev/null 2>&1 & +) >/dev/null 2>&1 Date: Tue, 11 Aug 2026 14:15:02 +0200 Subject: [PATCH 14/14] Update src/cmdline.rs Co-authored-by: Alex Overchenko --- src/cmdline.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmdline.rs b/src/cmdline.rs index b73069ff7..746ade2fc 100644 --- a/src/cmdline.rs +++ b/src/cmdline.rs @@ -151,7 +151,7 @@ fn get_clap_command() -> clap::Command { .help("watch cache statistics live in a terminal dashboard") .action(ArgAction::SetTrue), flag_infer_long("monitor-interval") - .help("polling interval of `--monitor`, in seconds (0.2 to 60)") + .help(format!("polling interval of `--monitor`, in seconds ({} to {})", MIN_MONITOR_INTERVAL, MAX_MONITOR_INTERVAL)) .value_name("SECS") .value_parser(clap::value_parser!(f64)) .default_value("1"),