diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4d5fded3f..85b889063c 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 8ac20c0609..e2e1374cf5 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 683a3bd006..c295417bcf 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/README.md b/README.md index 1e5c99df96..3239d54832 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. @@ -304,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 new file mode 100644 index 0000000000..2506015e20 --- /dev/null +++ b/docs/Monitoring.md @@ -0,0 +1,135 @@ +# 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. 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`–`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 | +| `+` / `-` | 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: + +| 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. 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 +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 +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. diff --git a/scripts/try-monitor.sh b/scripts/try-monitor.sh new file mode 100755 index 0000000000..7617cb68aa --- /dev/null +++ b/scripts/try-monitor.sh @@ -0,0 +1,169 @@ +#!/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, 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 + +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) + +SCCACHE="$ROOT/target/$PROFILE/sccache" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/sccache-monitor-demo.XXXXXX") +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 + [ -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 + exit 1 +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" +# 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 +# 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" + +# 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 + # 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 + i=$((i + 1)) + + # 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 % 5)) -eq 0 ]; then + # Failed compilation. + "$SCCACHE" "$CC" -c broken.c -o broken.o >/dev/null 2>&1 + fi + + # Idle beat, so the plots have troughs as well as peaks instead of a + # flat line at the top. + sleep 2 + done +) >/dev/null 2>&1 opening the dashboard (q to quit, ? for help, 1-6 for panes)" +sleep 1 +# 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" diff --git a/src/cmdline.rs b/src/cmdline.rs index f639396e7c..746ade2fce 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,12 @@ 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` and following `log` if there is one. + Monitor { + interval: Duration, + log: Option, + }, /// Run background server. InternalStartServer, /// Start background server as a subprocess. @@ -123,6 +135,9 @@ fn get_clap_command() -> clap::Command { "\n", " COS: ", cfg!(feature = "cos"), + "\n", + " Monitor: ", + cfg!(feature = "monitor"), "\n" )) .args(&[ @@ -132,6 +147,18 @@ 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(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"), + 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), @@ -173,6 +200,7 @@ fn get_clap_command() -> clap::Command { "dist-status", "show-stats", "show-adv-stats", + "monitor", "start-server", "stop-server", "zero-stats", @@ -271,6 +299,33 @@ 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" + ); + } + // 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) } else if matches.get_flag("debug-preprocessor-cache") { diff --git a/src/commands.rs b/src/commands.rs index 5ea8b949ce..bc696de65b 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, 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, log)?; + } + #[cfg(not(feature = "monitor"))] + { + let _ = (interval, log); + 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 d5aa6a6032..f0bb4d512d 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 0000000000..f2d7c226f9 --- /dev/null +++ b/src/monitor.rs @@ -0,0 +1,1942 @@ +// 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. + +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}; + +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, +}; +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; +use tail::LogEvent; + +/// 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; 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, 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 + // 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))?; + + 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, &log_rx); + ratatui::restore(); + log::set_max_level(log_level); + + 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, + /// 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)>, + dist: Option, + rates: Rates, + hist_requests: VecDeque, + hist_hits: VecDeque, + hist_misses: 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, + 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, log: Option) -> Self { + App { + addr, + interval, + started: Instant::now(), + tab: 0, + advanced: false, + paused: false, + help: false, + confirm_zero: false, + repaint: 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), + hist_size: VecDeque::with_capacity(HISTORY), + hist_growth: VecDeque::with_capacity(HISTORY), + trims: 0, + trimmed: 0, + updates: 0, + last_update: None, + error: None, + message: None, + logs: LogView::new(log), + } + } + + fn main_loop( + &mut self, + mut terminal: DefaultTerminal, + cmds: &Sender, + samples: &Receiver, + logs: &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 { + // 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(); + } + + // 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()? + && key.kind == KeyEventKind::Press + && !self.on_key(key, cmds)? + { + return Ok(()); + } + } + while let Ok(sample) = samples.try_recv() { + self.ingest(sample); + 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; + } + } + } + + /// 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); + // 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), + 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') => { + 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'..='6') => { + let idx = c as usize - '1' as usize; + self.select_tab(idx, cmds); + } + _ => {} + } + 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; + } + 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())); + 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((now, size)); + } + 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(); + } +} + +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 { + 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), + "Logs" => self.draw_logs(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}"); + let (scaled, ticks) = scale_to_ticks(&data, area.height.saturating_sub(2)); + frame.render_widget( + Sparkline::default() + .block(titled(&heading)) + .data(scaled) + .max(ticks) + .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, + ); + } + + /// 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_growth + .iter() + .rev() + .take(width) + .rev() + .copied() + .collect(); + 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 growth".to_string(), + }; + let (scaled, ticks) = scale_to_ticks(&data, area.height.saturating_sub(2)); + frame.render_widget( + Sparkline::default() + .block(titled(&heading)) + .data(scaled) + .max(ticks) + .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 { + 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()); + // 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 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)), + ]), + ]; + if let Some((size, max)) = usage { + lines.push(Line::from(vec![kv( + "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", + 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())])); + + 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( + [ + "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_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 { + 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-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"), + 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()), + 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."), + ]; + let [area] = Layout::horizontal([Constraint::Length(74)]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::vertical([Constraint::Length(23)]) + .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())) +} + +/// 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`. +/// +/// 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)) +} + +/// 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(), + }; + 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 + } +} + +/// 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 { + 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") + } +} + +/// 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) +} + +/// 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); + })); +} diff --git a/src/monitor/tail.rs b/src/monitor/tail.rs new file mode 100644 index 0000000000..aa6c82b1f2 --- /dev/null +++ b/src/monitor/tail.rs @@ -0,0 +1,150 @@ +// 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'])); + // 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 { + 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, + } + } +} diff --git a/src/server.rs b/src/server.rs index 2f142684af..6b592ad0c4 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() }