diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a9e7d6..b509062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,7 @@ jobs: - uses: actions/checkout@v4 - name: Configure Rust run: | - rustup toolchain install stable --profile minimal --component rustfmt --component clippy - rustup override set stable + rustup show active-toolchain - name: Formatting run: cargo fmt --all -- --check - name: Compile diff --git a/Cargo.lock b/Cargo.lock index f2c5918..1402cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,14 +3,21 @@ version = 4 [[package]] -name = "accesskit" -version = "0.25.1" +name = "ab_glyph" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" dependencies = [ - "uuid", + "ab_glyph_rasterizer", + "owned_ttf_parser", ] +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -55,6 +62,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -62,25 +78,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "arboard" -version = "3.7.0" +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" -dependencies = [ - "clipboard-win", - "image", - "log", - "objc2 0.6.5", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "parking_lot", - "percent-encoding", - "windows-sys 0.61.2", - "wl-clipboard-rs", - "x11rb", -] +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" @@ -94,6 +95,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -114,18 +124,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.9.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" @@ -140,21 +150,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] -name = "block2" -version = "0.5.1" +name = "block" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "objc2 0.5.3", + "generic-array", ] [[package]] name = "block2" -version = "0.6.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.6.5", + "objc2 0.5.3", ] [[package]] @@ -209,18 +225,42 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.9.0" @@ -246,39 +286,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "cgl" -version = "0.3.2" +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ - "libc", + "error-code", ] [[package]] -name = "clipboard-win" -version = "5.4.1" +name = "clipboard_macos" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" dependencies = [ - "error-code", + "objc2 0.5.3", + "objc2-app-kit", + "objc2-foundation 0.2.2", ] [[package]] -name = "codespan-reporting" -version = "0.13.1" +name = "clipboard_wayland" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" dependencies = [ - "unicode-width", + "smithay-clipboard", ] [[package]] -name = "color" -version = "0.3.3" +name = "clipboard_x11" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" dependencies = [ - "bytemuck", + "thiserror 2.0.21", + "x11rb", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", ] [[package]] @@ -363,7 +417,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", "foreign-types", "libc", ] @@ -379,6 +433,59 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.13.2", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -400,6 +507,38 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -447,6 +586,16 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -505,249 +654,85 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.37.0" +name = "dtor" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" -dependencies = [ - "bytemuck", - "emath", -] +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" [[package]] -name = "eframe" -version = "0.37.0" +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "ahash", - "bytemuck", - "document-features", - "egui", - "egui-wgpu", - "egui-winit", - "egui_glow", - "glow", - "glutin", - "glutin-winit", - "image", - "js-sys", - "log", - "objc2 0.6.5", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "parking_lot", - "percent-encoding", - "profiling", - "raw-window-handle", - "send_wrapper", - "static_assertions", - "wasm-bindgen", - "web-sys", - "web-time", + "libc", "windows-sys 0.61.2", - "winit", ] [[package]] -name = "egui" -version = "0.37.0" +name = "error-code" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" -dependencies = [ - "accesskit", - "ahash", - "bitflags 2.13.2", - "emath", - "epaint", - "itertools", - "log", - "nohash-hasher", - "profiling", - "smallvec", - "unicode-segmentation", - "web-sys", -] +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] -name = "egui-wgpu" -version = "0.37.0" +name = "etagere" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" dependencies = [ - "ahash", - "bytemuck", - "document-features", - "egui", - "epaint", - "log", - "profiling", - "thiserror 2.0.21", - "type-map", - "web-time", - "wgpu", - "winit", + "euclid", + "svg_fmt", ] [[package]] -name = "egui-winit" -version = "0.37.0" +name = "euclid" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ - "arboard", - "block2 0.6.2", - "bytemuck", - "egui", - "log", - "objc2 0.6.5", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "objc2-ui-kit 0.3.2", - "profiling", - "raw-window-handle", - "web-time", - "windows-sys 0.61.2", - "winit", + "num-traits", ] [[package]] -name = "egui_glow" -version = "0.37.0" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" -dependencies = [ - "bytemuck", - "egui", - "glow", - "log", - "profiling", - "winit", -] +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "either" -version = "1.19.0" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "emath" -version = "0.37.0" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" -dependencies = [ - "bytemuck", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "epaint" -version = "0.37.0" +name = "fdeflate" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" -dependencies = [ - "ahash", - "bytemuck", - "ecolor", - "emath", - "epaint_default_fonts", - "font-types", - "harfrust", - "log", - "nohash-hasher", - "parking_lot", - "profiling", - "self_cell", - "skrifa", - "smallvec", - "unicode-general-category", - "unicode-properties", - "unicode-segmentation", - "vello_cpu", -] - -[[package]] -name = "epaint_default_fonts" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" - -[[package]] -name = "equivalent" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "error-code" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] -[[package]] -name = "fearless_simd" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" - [[package]] name = "find-msvc-tools" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.10" @@ -759,6 +744,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "fnv" version = "1.0.7" @@ -777,6 +768,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.12.7" @@ -786,6 +786,29 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -822,12 +845,71 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -840,12 +922,27 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -891,26 +988,25 @@ dependencies = [ ] [[package]] -name = "glifo" -version = "0.3.0" +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glam" +version = "0.30.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" dependencies = [ "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa", - "smallvec", - "vello_common", ] [[package]] name = "glow" -version = "0.17.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" dependencies = [ "js-sys", "slotmap", @@ -919,78 +1015,73 @@ dependencies = [ ] [[package]] -name = "glutin" -version = "0.32.3" +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" dependencies = [ "bitflags 2.13.2", - "cfg_aliases", - "cgl", - "dispatch2", - "glutin_egl_sys", - "glutin_glx_sys", - "glutin_wgl_sys", - "libloading", - "objc2 0.6.5", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "raw-window-handle", - "wayland-sys", - "windows-sys 0.52.0", - "x11-dl", + "gpu-alloc-types", ] [[package]] -name = "glutin-winit" -version = "0.5.0" +name = "gpu-alloc-types" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" dependencies = [ - "cfg_aliases", - "glutin", - "raw-window-handle", - "winit", + "bitflags 2.13.2", ] [[package]] -name = "glutin_egl_sys" -version = "0.7.1" +name = "gpu-allocator" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" dependencies = [ - "gl_generator", - "windows-sys 0.52.0", + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", ] [[package]] -name = "glutin_glx_sys" -version = "0.6.1" +name = "gpu-descriptor" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "gl_generator", - "x11-dl", + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.15.5", ] [[package]] -name = "glutin_wgl_sys" -version = "0.6.1" +name = "gpu-descriptor-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "gl_generator", + "bitflags 2.13.2", ] [[package]] name = "guillotiere" -version = "0.7.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" dependencies = [ "euclid", + "svg_fmt", ] [[package]] @@ -1007,13 +1098,14 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.12.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "core_maths", + "read-fonts 0.35.0", "smallvec", ] @@ -1028,19 +1120,31 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hermit-abi" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "http" version = "1.5.0" @@ -1057,6 +1161,221 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "image", + "thiserror 2.0.21", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "glam 0.25.0", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 2.0.21", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "image", + "kamadak-exif", + "log", + "lyon_path", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.21", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.21", +] + +[[package]] +name = "iced_selector" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1442ccc30959cc48b3a624233521ef7f3bc3e06528b6fe306c3a775fbddc4fe" +dependencies = [ + "iced_core", +] + +[[package]] +name = "iced_test" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b56c26cc6e63958ce6be06a68fa3136fd5f646da6302ee12abf1a0f311dd0166" +dependencies = [ + "iced_futures", + "iced_program", + "iced_renderer", + "iced_runtime", + "iced_selector", + "nom", + "png", + "sha2", + "thiserror 2.0.21", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c267596d742714b1853cc10c3983a367762816fc4836bd3b79f76ce76787d6f8" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "cryoglyph", + "futures", + "glam 0.25.0", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "lyon", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7589888c8e951899cc688247a69933bb7a0511f0b4b2e122ac3fcd5dacb37f72" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + [[package]] name = "icu_collections" version = "2.4.0" @@ -1178,7 +1497,6 @@ dependencies = [ "moxcms", "num-traits", "png", - "tiff", ] [[package]] @@ -1191,15 +1509,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "itertools" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1285,6 +1594,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -1293,13 +1622,11 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" -version = "0.13.1" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" dependencies = [ "arrayvec", - "euclid", - "polycool", "smallvec", ] @@ -1348,6 +1675,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lilt" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +dependencies = [ + "web-time", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -1367,31 +1703,98 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "litemap" -version = "0.8.3" +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a6b9d4e8b48acd531fbf1241ad9b270df6e60b6b92bdea1469f4f40899d805" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] [[package]] -name = "litrs" -version = "1.0.1" +name = "lyon_path" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "lyon_tessellation" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "fd4872650e70dc907dd4f7ebc449d7d657a0d2658e423b2e724970870bbbf75b" dependencies = [ - "scopeguard", + "float_next_after", + "lyon_path", + "num-traits", ] [[package]] -name = "log" -version = "0.4.34" +name = "malloc_buf" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] [[package]] name = "memchr" @@ -1408,6 +1811,21 @@ dependencies = [ "libc", ] +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1455,8 +1873,12 @@ name = "moonitor-desktop" version = "0.1.0" dependencies = [ "anyhow", - "eframe", + "bytemuck", + "glam 0.30.10", + "iced", + "iced_test", "image", + "log", "moonitor-agent", "moonitor-core", "moonitor-store", @@ -1485,11 +1907,17 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "naga" -version = "30.0.1" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", @@ -1498,30 +1926,19 @@ dependencies = [ "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.17.1", + "hashbrown 0.16.1", + "hexf-parse", "indexmap", "libm", "log", - "naga-types", "num-traits", "once_cell", "rustc-hash 1.1.0", + "spirv", "thiserror 2.0.21", "unicode-ident", ] -[[package]] -name = "naga-types" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" -dependencies = [ - "hashbrown 0.17.1", - "indexmap", - "rustc-hash 1.1.0", - "thiserror 2.0.21", -] - [[package]] name = "ndk" version = "0.9.0" @@ -1552,12 +1969,6 @@ dependencies = [ "jni-sys 0.3.1", ] -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - [[package]] name = "nom" version = "8.0.0" @@ -1637,6 +2048,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -1669,27 +2089,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "libc", "objc2 0.5.3", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.2", - "block2 0.6.2", - "objc2 0.6.5", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -1699,7 +2105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", @@ -1711,7 +2117,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -1723,7 +2129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -1758,7 +2164,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal", @@ -1770,7 +2176,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", @@ -1789,7 +2195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "dispatch", "libc", "objc2 0.5.3", @@ -1802,7 +2208,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", - "block2 0.6.2", "objc2 0.6.5", "objc2-core-foundation", ] @@ -1834,9 +2239,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2 0.5.1", + "block2", "objc2 0.5.3", - "objc2-app-kit 0.2.2", + "objc2-app-kit", "objc2-foundation 0.2.2", ] @@ -1847,7 +2252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -1859,12 +2264,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-symbols" version = "0.2.2" @@ -1882,7 +2299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-cloud-kit", "objc2-core-data", @@ -1890,31 +2307,19 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", ] -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.13.2", - "objc2 0.6.5", - "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -1926,7 +2331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", - "block2 0.5.1", + "block2", "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", @@ -1949,13 +2354,21 @@ dependencies = [ ] [[package]] -name = "os_pipe" -version = "1.2.3" +name = "ordered-float" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ - "libc", - "windows-sys 0.61.2", + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", ] [[package]] @@ -1982,17 +2395,10 @@ dependencies = [ ] [[package]] -name = "peniko" -version = "0.6.1" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" -dependencies = [ - "bytemuck", - "color", - "kurbo", - "linebender_resource_handle", - "smallvec", -] +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" @@ -2000,17 +2406,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - [[package]] name = "pin-project" version = "1.1.13" @@ -2037,6 +2432,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + [[package]] name = "pkg-config" version = "0.3.34" @@ -2076,15 +2477,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "polycool" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" -dependencies = [ - "arrayvec", -] - [[package]] name = "portable-atomic" version = "1.15.0" @@ -2115,6 +2507,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2145,12 +2543,6 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - [[package]] name = "quick-xml" version = "0.41.0" @@ -2181,12 +2573,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -2194,7 +2609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.7", "once_cell", ] @@ -2231,6 +2646,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rusqlite" version = "0.40.2" @@ -2318,6 +2739,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -2330,12 +2764,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" version = "1.0.229" @@ -2388,6 +2816,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2416,6 +2855,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + [[package]] name = "skrifa" version = "0.44.0" @@ -2423,7 +2872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", ] [[package]] @@ -2454,8 +2903,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop", - "calloop-wayland-source", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -2472,6 +2921,44 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -2481,6 +2968,46 @@ dependencies = [ "serde", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.5", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2493,12 +3020,35 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "2.0.119" @@ -2532,6 +3082,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "sysinfo" version = "0.39.6" @@ -2543,7 +3102,16 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows", + "windows 0.62.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", ] [[package]] @@ -2586,20 +3154,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "time" version = "0.3.55" @@ -2630,6 +3184,44 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + [[package]] name = "tinystr" version = "0.8.5" @@ -2640,6 +3232,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.54.1" @@ -2706,41 +3304,52 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] -name = "tree_magic_mini" -version = "3.2.2" +name = "ttf-parser" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" dependencies = [ - "memchr", - "nom", - "petgraph", + "core_maths", ] [[package]] -name = "type-map" -version = "0.5.1" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" -dependencies = [ - "rustc-hash 2.1.3", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "unicode-general-category" -version = "1.1.0" +name = "unicode-bidi" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" @@ -2749,10 +3358,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] -name = "unicode-properties" -version = "0.1.4" +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" @@ -2818,45 +3433,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "uuid" -version = "1.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" - [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vello_common" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - -[[package]] -name = "vello_cpu" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" -dependencies = [ - "bytemuck", - "glifo", - "hashbrown 0.17.1", - "vello_common", -] - [[package]] name = "version_check" version = "0.9.5" @@ -2938,6 +3520,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "wayland-backend" version = "0.3.17" @@ -2998,6 +3594,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -3067,27 +3689,22 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "wgpu" -version = "30.0.1" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", "bitflags 2.13.2", - "bytemuck", "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.17.1", + "hashbrown 0.16.1", "js-sys", "log", + "naga", + "parking_lot", "portable-atomic", "profiling", "raw-window-handle", @@ -3103,9 +3720,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "30.0.1" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", "bit-set", @@ -3114,11 +3731,10 @@ dependencies = [ "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.17.1", + "hashbrown 0.16.1", "indexmap", "log", "naga", - "naga-types", "once_cell", "parking_lot", "portable-atomic", @@ -3127,68 +3743,100 @@ dependencies = [ "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", - "wgpu-naga-bridge", "wgpu-types", ] +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "30.0.1" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "30.0.1" +version = "27.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", "bitflags 2.13.2", + "block", + "bytemuck", "cfg-if", "cfg_aliases", - "hashbrown 0.17.1", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", "libloading", "log", + "metal", "naga", - "naga-types", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", "portable-atomic", "portable-atomic-util", + "profiling", + "range-alloc", "raw-window-handle", "renderdoc-sys", - "static_assertions", + "smallvec", "thiserror 2.0.21", - "wgpu-naga-bridge", - "wgpu-types", -] - -[[package]] -name = "wgpu-naga-bridge" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" -dependencies = [ - "naga", + "wasm-bindgen", + "web-sys", "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] name = "wgpu-types" -version = "30.0.1" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ "bitflags 2.13.2", "bytemuck", "js-sys", "log", - "naga-types", - "raw-window-handle", - "static_assertions", + "thiserror 2.0.21", "web-sys", ] @@ -3223,6 +3871,30 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 2.0.21", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + [[package]] name = "windows" version = "0.62.2" @@ -3230,7 +3902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -3241,7 +3913,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", ] [[package]] @@ -3250,11 +3935,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -3263,11 +3948,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3279,6 +3975,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3302,10 +4009,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3315,6 +4031,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -3434,9 +4160,9 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.13.2", - "block2 0.5.1", + "block2", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -3448,16 +4174,17 @@ dependencies = [ "memmap2", "ndk", "objc2 0.5.3", - "objc2-app-kit 0.2.2", + "objc2-app-kit", "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", + "objc2-ui-kit", "orbclient", "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", @@ -3490,24 +4217,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wl-clipboard-rs" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.5", - "thiserror 2.0.21", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "wrapcenum-derive" version = "0.4.1" @@ -3589,6 +4298,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + [[package]] name = "yoke" version = "0.8.4" @@ -3612,6 +4327,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.63" @@ -3697,18 +4418,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zune-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/README.md b/README.md index f46edaa..2120a1d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +Moonitor cratered crescent moon + # moonitor A native system monitor for a small LAN/VPN homelab, with a dedicated view of @@ -5,7 +7,7 @@ local AI workloads and their resource and energy use. ## Working first version -- Rust collectors and a native egui/eframe desktop, rendered with glow/OpenGL. +- Rust collectors and a native Iced 0.14 desktop, rendered with wgpu/Metal or Vulkan. - CPU/per-core use, available RAM, swap, top processes, disk capacity and interface traffic. - Linux PSI and available temperature sensors; macOS battery state. - NVIDIA GPU utilization, memory, temperature, clock, power and throttle reasons via NVML. @@ -18,7 +20,7 @@ local AI workloads and their resource and energy use. The MacBook, Exon, Spark and Zima have produced real live samples together in the desktop. The iPhone is inventoried and explicitly marked as not instrumented. -See [qualification](docs/qualification.md) for evidence and limitations. +See [W0 qualification](docs/moonitor-w0-qualification.md) and [W1 qualification](docs/moonitor-w1-qualification.md) for evidence and limitations. ## Run @@ -28,16 +30,18 @@ On macOS, build a local `.app` and open it: scripts/run.sh ``` -The app is created at `.local/moonitor.app`. Packaging copies the ignored -`.local/fleet.toml` into the app; edit that file and repackage for a different -fleet. The first packaging run copies `config/fleet.example.toml` as a starting -point. The native executable also accepts configuration explicitly: +The app is created at `.local/moonitor.app`. On first packaging, the ignored +`.local/fleet.toml` is copied to `~/Library/Application Support/moonitor/fleet.toml`. +Existing user configuration is preserved. Edit that installed file to change the +fleet; private configuration is never embedded in the bundle. The first packaging +run uses `config/fleet.example.toml` if there is no local file. The executable also +accepts configuration explicitly: ```sh -cargo run --release -p moonitor-desktop -- --config config/fleet.example.toml +cargo run --release --locked -p moonitor-desktop -- --config config/fleet.example.toml ``` -Without configuration, an unbundled executable monitors only its local computer. +Without an explicit or installed `fleet.toml`, the executable monitors only its local computer. Data defaults to `~/Library/Application Support/moonitor` on macOS and `$XDG_DATA_HOME/moonitor` or `~/.local/share/moonitor` on Linux. @@ -52,13 +56,38 @@ The installer keeps binaries by content hash and refuses to replace an unmanaged existing agent. The desktop starts remote agents on connection and closes its SSH children on exit. Missing agents and broken connections appear in the UI. +## Native workbench · W0 / W1 + +The design reference is the [professional upgrade blueprint](docs/moonitor-professional-upgrade-blueprint.md). +See the [verified audit, decisions and W0–W4 gates](docs/moonitor-w0-audit-and-plan.md) +and [W0 execution report](docs/moonitor-w0-qualification.md). The [W1 interface redesign](docs/moonitor-w1-interface-design.md) introduces Overview, Fleet, Workloads, Energy, Events, Explore and Settings, with resource tabs inside node detail. W2–W4 remain planned. + +The Lab Map uses exactly the configured fleet (0–32 nodes), stable IDs and actual +availability. Optional `visual_kind = "laptop" | "tower" | "compact" | "board" | "phone"` +selects reusable geometry; its default is generic. Uninstrumented nodes do not +count as monitored/offline. `--no-scene` or **Elenco** suspends the 3D scene. +Use `--theme light|dark` and `--size 1280x800` for repeatable UI checks. +The Overview combines current metrics, an inspectable five-minute resource chart, +a compact Lab Map and observed model residency. Fleet is a comparable inventory. +The node switcher preserves the current page; map and inventory clicks open node detail. +Workloads does not infer active requests from loaded models. Events explicitly explains +that historical events are not yet stored. Energy exposes GPU board scope and coverage. +Use `--section fleet|workloads|energy|events|explore|settings|device` to open a view. +Keyboard: Cmd/Ctrl+1…7 selects a primary section, Alt+Left/Right changes node, Tab/Shift+Tab moves focus. + ## Development ```sh scripts/check.sh +scripts/qa-ui.sh # graphical backend required; local renderer-specific PNG baselines cargo build --release --locked -p moonitor-agent -p moonitor-desktop ``` +Toolchain is pinned in `rust-toolchain.toml`; use the committed `Cargo.lock`. +The first visual run records candidate baselines in `.local/qa/w1/visual`; it is not +human approval. Subsequent runs compare pixels. Synthetic fixtures exist only in +`#[cfg(test)]` modules. Real screenshots use `--screenshot FILE.png --exit-after 12`. + ## Design - [Requirements](docs/requirements.md) diff --git a/assets/brand/README.md b/assets/brand/README.md new file mode 100644 index 0000000..ea370ab --- /dev/null +++ b/assets/brand/README.md @@ -0,0 +1,17 @@ +# Moonitor artwork + +`moon-original.png` is the artwork supplied by the repository owner on 23 October +2026 for the app and repository identity. The original file is preserved unchanged, +including its alpha channel. This note does not assign an artist or infer a license. + +The repository README references this original. The desktop embeds size-converted +128 px and 256 px PNG copies in `crates/moonitor-desktop/assets/`. They are decoded +once for the sidebar and window icon. Iced's `image-without-codecs` feature enables +raster rendering without enabling its default codec collection; the already-used +`image` crate supplies PNG decoding. Iced adds two small transitive dependencies, +`kamadak-exif` and `mutate_once`, recorded in Cargo.lock. + +On macOS, `scripts/build-icons.sh` uses `sips` and `iconutil` to create the standard +16–1024 px icon representations in `.local/moonitor.icns`. Packaging installs it +as `Contents/Resources/moonitor.icns` and declares `CFBundleIconFile` before signing. +No background removal, recoloring or AI regeneration is applied. diff --git a/assets/brand/moon-original.png b/assets/brand/moon-original.png new file mode 100644 index 0000000..0de0fb2 Binary files /dev/null and b/assets/brand/moon-original.png differ diff --git a/config/fleet.example.toml b/config/fleet.example.toml index 52a249f..50e50f4 100644 --- a/config/fleet.example.toml +++ b/config/fleet.example.toml @@ -1,12 +1,14 @@ # SSH aliases use your existing ~/.ssh/config. No credentials are stored here. [[nodes]] id = "macbook" +visual_kind = "laptop" label = "MacBook Pro" kind = "local" note = "Apple Silicon / memoria unificata" [[nodes]] id = "exon" +visual_kind = "tower" label = "Exon" kind = "ssh" ssh_alias = "exon-vpn" @@ -14,6 +16,7 @@ note = "Workstation / NVIDIA" [[nodes]] id = "spark" +visual_kind = "compact" label = "DGX Spark" kind = "ssh" ssh_alias = "spark-vpn" @@ -21,6 +24,7 @@ note = "GB10 / memoria unificata" [[nodes]] id = "zima" +visual_kind = "board" label = "Zima" kind = "ssh" ssh_alias = "zima-vpn" @@ -28,6 +32,7 @@ note = "Homelab node" [[nodes]] id = "iphone" +visual_kind = "phone" label = "iPhone" kind = "unavailable" note = "Dispositivo censito. Telemetria iOS non collegata." diff --git a/crates/moonitor-desktop/Cargo.toml b/crates/moonitor-desktop/Cargo.toml index fea53cc..549b7d9 100644 --- a/crates/moonitor-desktop/Cargo.toml +++ b/crates/moonitor-desktop/Cargo.toml @@ -17,5 +17,12 @@ serde.workspace = true serde_json.workspace = true anyhow.workspace = true toml = "1.1.6" -eframe = { version = "0.37.0", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] } +iced = { version = "0.14.0", default-features = false, features = ["wgpu", "image-without-codecs", "tokio", "canvas", "x11", "wayland", "crisp"] } +bytemuck = { version = "1.25", features = ["derive"] } +glam = { version = "0.30", features = ["bytemuck"] } image = { version = "0.25.10", default-features = false, features = ["png"] } + +log = "0.4" + +[dev-dependencies] +iced_test = "=0.14.0" diff --git a/crates/moonitor-desktop/assets/Inter-LICENSE.txt b/crates/moonitor-desktop/assets/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/crates/moonitor-desktop/assets/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/moonitor-desktop/assets/Inter-Medium.ttf b/crates/moonitor-desktop/assets/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/crates/moonitor-desktop/assets/Inter-Medium.ttf differ diff --git a/crates/moonitor-desktop/assets/Inter-Regular.ttf b/crates/moonitor-desktop/assets/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/crates/moonitor-desktop/assets/Inter-Regular.ttf differ diff --git a/crates/moonitor-desktop/assets/Inter-SemiBold.ttf b/crates/moonitor-desktop/assets/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/crates/moonitor-desktop/assets/Inter-SemiBold.ttf differ diff --git a/crates/moonitor-desktop/assets/moonitor-128.png b/crates/moonitor-desktop/assets/moonitor-128.png new file mode 100644 index 0000000..5f9d1a7 Binary files /dev/null and b/crates/moonitor-desktop/assets/moonitor-128.png differ diff --git a/crates/moonitor-desktop/assets/moonitor-256.png b/crates/moonitor-desktop/assets/moonitor-256.png new file mode 100644 index 0000000..3c3d1c6 Binary files /dev/null and b/crates/moonitor-desktop/assets/moonitor-256.png differ diff --git a/crates/moonitor-desktop/src/composite.wgsl b/crates/moonitor-desktop/src/composite.wgsl new file mode 100644 index 0000000..4d64ef7 --- /dev/null +++ b/crates/moonitor-desktop/src/composite.wgsl @@ -0,0 +1,7 @@ +@group(0) @binding(0) var image:texture_2d; +@group(0) @binding(1) var image_sampler:sampler; +struct Out { @builtin(position) position:vec4, @location(0) uv:vec2 } +@vertex fn vs(@builtin(vertex_index) i:u32)->Out { + let uv=vec2(f32((i<<1u)&2u),f32(i&2u));var o:Out;o.position=vec4(uv*vec2(2.0,-2.0)+vec2(-1.0,1.0),0.0,1.0);o.uv=uv;return o; +} +@fragment fn fs(v:Out)->@location(0) vec4 { return textureSample(image,image_sampler,v.uv); } diff --git a/crates/moonitor-desktop/src/config.rs b/crates/moonitor-desktop/src/config.rs index 068469f..a4185a2 100644 --- a/crates/moonitor-desktop/src/config.rs +++ b/crates/moonitor-desktop/src/config.rs @@ -10,19 +10,32 @@ pub enum NodeKind { Unavailable, } +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualKind { + Laptop, + Tower, + #[default] + Compact, + Board, + Phone, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct NodeConfig { pub id: String, pub label: String, pub kind: NodeKind, + #[serde(default)] + pub visual_kind: VisualKind, pub ssh_alias: Option, pub remote_agent: Option, #[serde(default)] pub note: String, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { pub nodes: Vec, @@ -49,6 +62,7 @@ impl Config { id: "macbook".into(), label: "This computer".into(), kind: NodeKind::Local, + visual_kind: VisualKind::default(), ssh_alias: None, remote_agent: None, note: String::new(), @@ -60,8 +74,8 @@ impl Config { } fn validate(&self) -> Result<()> { - if self.nodes.is_empty() || self.nodes.len() > 32 { - bail!("Configure between 1 and 32 nodes"); + if self.nodes.len() > 32 { + bail!("Configure at most 32 nodes"); } let mut ids = HashSet::new(); let mut locals = 0; @@ -112,6 +126,26 @@ mod tests { assert!(safe_id("spark-vpn")); } #[test] + fn empty_is_valid_but_oversized_fleets_are_not() { + assert!(Config { nodes: vec![] }.validate().is_ok()); + let nodes = crate::fixtures::fleet(32) + .into_iter() + .map(|n| n.config) + .collect::>(); + assert!( + Config { + nodes: nodes.clone() + } + .validate() + .is_ok() + ); + let mut too_many = nodes; + let mut extra = too_many[0].clone(); + extra.id = "extra".into(); + too_many.push(extra); + assert!(Config { nodes: too_many }.validate().is_err()); + } + #[test] fn duplicate_ids_are_rejected() { let c = Config { nodes: vec![ @@ -119,6 +153,7 @@ mod tests { id: "same".into(), label: "One".into(), kind: NodeKind::Local, + visual_kind: VisualKind::default(), ssh_alias: None, remote_agent: None, note: String::new() diff --git a/crates/moonitor-desktop/src/engine.rs b/crates/moonitor-desktop/src/engine.rs index a4cdd3f..a6bf59a 100644 --- a/crates/moonitor-desktop/src/engine.rs +++ b/crates/moonitor-desktop/src/engine.rs @@ -9,7 +9,7 @@ use std::{ process::{Child, Command, Stdio}, sync::{ Arc, Mutex, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, mpsc, }, thread, @@ -29,17 +29,15 @@ pub struct NodeState { impl NodeState { pub fn live(&self) -> bool { - self.sample.is_some() - && self - .last_seen - .is_some_and(|t| t.elapsed() < Duration::from_secs(4)) - && self.error.is_none() + crate::view_model::NodeStatus::of(self, Instant::now()) + == crate::view_model::NodeStatus::Online } } pub struct State { pub nodes: Vec, pub storage_error: Option, + pub dropped_events: Arc, } type ChildSlot = Arc>>; pub struct Engine { @@ -56,6 +54,7 @@ impl Engine { pub fn start(config: Config, db_path: PathBuf) -> Self { let state = Arc::new(Mutex::new(State { storage_error: None, + dropped_events: Arc::new(AtomicU64::new(0)), nodes: config .nodes .iter() @@ -68,6 +67,7 @@ impl Engine { }) .collect(), })); + let dropped = state.lock().unwrap().dropped_events.clone(); let stop = Arc::new(AtomicBool::new(false)); let (tx, rx) = mpsc::sync_channel(16); let shared = state.clone(); @@ -158,6 +158,7 @@ impl Engine { for (index, node) in nodes.into_iter().enumerate() { let stopped = stop.clone(); let sender = tx.clone(); + let dropped = dropped.clone(); match node.kind { NodeKind::Local => { thread::spawn(move || { @@ -166,12 +167,11 @@ impl Engine { while !stopped.load(Ordering::Relaxed) { let tick = Instant::now(); let sample = Arc::new(collector.sample()); - let _ = sender.try_send(Event::Snapshot( - index, - sample, - now_ms(), - Instant::now(), - )); + enqueue( + &sender, + &dropped, + Event::Snapshot(index, sample, now_ms(), Instant::now()), + ); thread::sleep(Duration::from_secs(1).saturating_sub(tick.elapsed())); } }); @@ -181,15 +181,20 @@ impl Engine { children.push(child.clone()); thread::spawn(move || { while !stopped.load(Ordering::Relaxed) { - let result = remote_stream(&node, index, &sender, &child, &stopped); + let result = + remote_stream(&node, index, &sender, &child, &stopped, &dropped); if !stopped.load(Ordering::Relaxed) { - let _ = sender.try_send(Event::Error( - index, - result - .err() - .map(|e| e.to_string()) - .unwrap_or_else(|| "SSH stream ended".into()), - )); + enqueue( + &sender, + &dropped, + Event::Error( + index, + result + .err() + .map(|e| e.to_string()) + .unwrap_or_else(|| "SSH stream ended".into()), + ), + ); for _ in 0..100 { if stopped.load(Ordering::Relaxed) { break; @@ -229,6 +234,7 @@ fn remote_stream( tx: &mpsc::SyncSender, slot: &ChildSlot, stop: &AtomicBool, + dropped: &AtomicU64, ) -> Result<()> { let agent = node .remote_agent @@ -270,12 +276,11 @@ fn remote_stream( }; let snapshot: HostSnapshot = serde_json::from_slice(&line)?; snapshot.validate(&node.id).map_err(anyhow::Error::msg)?; - let _ = tx.try_send(Event::Snapshot( - index, - Arc::new(snapshot), - now_ms(), - Instant::now(), - )); + enqueue( + tx, + dropped, + Event::Snapshot(index, Arc::new(snapshot), now_ms(), Instant::now()), + ); } Ok(()) })(); @@ -289,6 +294,12 @@ fn remote_stream( result } +fn enqueue(tx: &mpsc::SyncSender, dropped: &AtomicU64, event: Event) { + if matches!(tx.try_send(event), Err(mpsc::TrySendError::Full(_))) { + dropped.fetch_add(1, Ordering::Relaxed); + } +} + pub fn read_frame(reader: &mut impl BufRead) -> Result>> { let mut line = Vec::new(); reader @@ -304,6 +315,14 @@ pub fn read_frame(reader: &mut impl BufRead) -> Result>> { mod tests { use super::*; #[test] + fn bounded_ingress_accounts_for_dropped_events() { + let (tx, _rx) = mpsc::sync_channel(1); + let dropped = AtomicU64::new(0); + enqueue(&tx, &dropped, Event::Error(0, "first".into())); + enqueue(&tx, &dropped, Event::Error(0, "second".into())); + assert_eq!(dropped.load(Ordering::Relaxed), 1); + } + #[test] fn streaming_frames_are_bounded_even_without_a_newline() { let huge = vec![b'a'; MAX_MESSAGE_BYTES + 10]; assert!(read_frame(&mut std::io::Cursor::new(huge)).is_err()); diff --git a/crates/moonitor-desktop/src/fixtures.rs b/crates/moonitor-desktop/src/fixtures.rs new file mode 100644 index 0000000..0c0671f --- /dev/null +++ b/crates/moonitor-desktop/src/fixtures.rs @@ -0,0 +1,137 @@ +//! Explicit synthetic QA fixtures. This module is never compiled into production. +use crate::{ + config::{NodeConfig, NodeKind, VisualKind}, + engine::NodeState, +}; +use moonitor_core::*; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +pub const TIME_MS: u64 = 1_792_742_400_000; +pub fn sample(id: &str) -> HostSnapshot { + let t = TIME_MS; + HostSnapshot { + schema_version: SCHEMA_VERSION, + node_id: id.into(), + sampled_at_unix_ms: t, + hostname: "QA fixture".into(), + os: "Fixture OS".into(), + arch: "fixture".into(), + cpu_name: "Fixture CPU".into(), + uptime_seconds: 3600, + cpu_utilization_percent: Reading::reported(0.0, "QA CPU", t), + cpu_cores_percent: vec![0.0; 4], + memory: MemorySnapshot { + total_bytes: Reading::reported(24 << 30, "QA memory", t), + available_bytes: Reading::reported(12 << 30, "QA memory", t), + swap_used_bytes: Reading::reported(0, "QA memory", t), + }, + gpus: vec![GpuSnapshot { + id: "gpu-0".into(), + name: "Fixture unified GPU".into(), + memory_kind: GpuMemoryKind::Unified, + utilization_percent: Reading::reported(37.0, "QA GPU", t), + memory_total_bytes: Reading::unavailable(UnavailableReason::Unsupported), + memory_used_bytes: Reading::unavailable(UnavailableReason::Unsupported), + temperature_celsius: Reading::unavailable(UnavailableReason::PermissionDenied), + clock_mhz: Reading::unavailable(UnavailableReason::TemporarilyUnavailable), + throttling: Reading::unavailable(UnavailableReason::Unsupported), + }], + power: vec![], + processes: vec![ProcessSnapshot { + pid: 100, + name: "fixture-inference".into(), + cpu_percent: 25.0, + memory_bytes: 1 << 30, + is_ai: true, + }], + process_count: 1, + disks: vec![], + networks: vec![], + temperatures: vec![], + pressure: vec![], + ollama: RuntimeSnapshot { + models: if id == "qa-0" { + Reading::reported( + vec![LoadedModel { + name: "QA model · fixture".into(), + size_bytes: 4 << 30, + gpu_allocation_bytes: None, + context_length: Some(8192), + quantization: Some("QA Q4".into()), + }], + "QA runtime", + t, + ) + } else { + Reading::unavailable(UnavailableReason::NotConfigured) + }, + }, + battery_percent: Reading::unavailable(UnavailableReason::Unsupported), + collector_duration_ms: 1.0, + } +} +pub fn fleet(count: usize) -> Vec { + (0..count) + .map(|i| { + let id = format!("qa-{i}"); + NodeState { + config: NodeConfig { + id: id.clone(), + label: format!("QA device {i}"), + kind: if i % 5 == 4 { + NodeKind::Unavailable + } else { + NodeKind::Ssh + }, + visual_kind: [ + VisualKind::Laptop, + VisualKind::Tower, + VisualKind::Compact, + VisualKind::Board, + VisualKind::Phone, + ][i % 5], + ssh_alias: Some("fixture-only".into()), + remote_agent: None, + note: "DATI SINTETICI · SOLO TEST".into(), + }, + sample: if i % 5 < 2 { + Some(Arc::new(sample(&id))) + } else { + None + }, + last_seen: if i % 5 < 2 { + Some(Instant::now() - Duration::from_secs(if i % 5 == 1 { 10 } else { 0 })) + } else { + None + }, + error: if i % 5 == 2 { + Some("QA connection failure".into()) + } else { + None + }, + history: if i % 5 < 2 { + (0..60) + .map(|j| moonitor_store::HistoryPoint { + received_at_ms: TIME_MS - (59 - j) * 5_000, + cpu_percent: if (20..25).contains(&j) { + None + } else { + Some((j * 7 % 35) as f64) + }, + memory_percent: Some(50.0), + gpu_percent: if (20..25).contains(&j) { + None + } else { + Some((j * 11 % 70) as f64) + }, + }) + .collect() + } else { + Default::default() + }, + } + }) + .collect() +} diff --git a/crates/moonitor-desktop/src/main.rs b/crates/moonitor-desktop/src/main.rs index ede8b15..93cef90 100644 --- a/crates/moonitor-desktop/src/main.rs +++ b/crates/moonitor-desktop/src/main.rs @@ -1,21 +1,60 @@ mod config; mod engine; +#[cfg(test)] +mod fixtures; +mod scene; mod ui; +mod view_model; use anyhow::{Context, Result, bail}; use std::path::PathBuf; +struct Diagnostics; +impl log::Log for Diagnostics { + fn enabled(&self, m: &log::Metadata<'_>) -> bool { + m.level() <= log::Level::Info && m.target().starts_with("iced_wgpu") + } + fn log(&self, r: &log::Record<'_>) { + if self.enabled(r.metadata()) { + eprintln!("{}: {}", r.target(), r.args()); + } + } + fn flush(&self) {} +} fn main() -> Result<()> { + if std::env::var_os("MOONITOR_RENDER_DIAGNOSTICS").is_some() { + static DIAGNOSTICS: Diagnostics = Diagnostics; + let _ = log::set_logger(&DIAGNOSTICS); + log::set_max_level(log::LevelFilter::Info); + } let mut config_path = None; let mut data_dir = None; let mut screenshot = None; let mut exit_after = None; let mut selected_node = None; + let mut display = ui::DisplayOptions::default(); let mut tab = "overview".to_owned(); let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { let mut value = || args.next().context(format!("Missing value for {arg}")); match arg.as_str() { + "--size" => { + let size = value()?; + let (w, h) = size.split_once('x').context("Use WIDTHxHEIGHT")?; + let (w, h) = (w.parse::()?, h.parse::()?); + if !(1160..=3840).contains(&w) || !(760..=2160).contains(&h) { + bail!("Size must be 1160x760 through 3840x2160"); + } + display.size = iced::Size::new(w as f32, h as f32); + } + "--theme" => { + display.dark = match value()?.as_str() { + "light" => false, + "dark" => true, + _ => bail!("Theme must be light or dark"), + }; + } + "--no-scene" => display.scene_visible = false, "--config" => config_path = Some(PathBuf::from(value()?)), "--data-dir" => data_dir = Some(PathBuf::from(value()?)), "--screenshot" => screenshot = Some(PathBuf::from(value()?)), @@ -28,9 +67,12 @@ fn main() -> Result<()> { } "--node" => selected_node = Some(value()?), "--tab" => tab = value()?, + "--section" => { + display.section = Some(ui::Section::parse(&value()?).context("Unknown section")?) + } "--help" | "-h" => { println!( - "moonitor [--config fleet.toml] [--data-dir PATH]\n [--node ID] [--tab overview|processes|ai|disks|network|history]\n [--screenshot PNG] [--exit-after SECONDS]" + "moonitor [--config fleet.toml] [--data-dir PATH]\n [--node ID] [--tab overview|compute|sensors|processes|ai|disks|network|history]\n [--section overview|fleet|workloads|energy|events|explore|settings|device]\n [--screenshot PNG] [--exit-after SECONDS]\n [--size WIDTHxHEIGHT] [--theme light|dark] [--no-scene]" ); return Ok(()); } @@ -38,10 +80,8 @@ fn main() -> Result<()> { } } if config_path.is_none() { - let bundled = std::env::current_exe()? - .parent() - .map(|p| p.join("../Resources/fleet.toml")); - config_path = bundled.filter(|p| p.is_file()); + let installed = default_data_dir().join("fleet.toml"); + config_path = installed.is_file().then_some(installed); } let config = config::Config::load(config_path.as_deref())?; if let Some(id) = &selected_node @@ -60,33 +100,19 @@ fn main() -> Result<()> { std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?; } } - let options = eframe::NativeOptions { - viewport: eframe::egui::ViewportBuilder::default() - .with_title("moonitor") - .with_inner_size([1240.0, 840.0]) - .with_min_inner_size([980.0, 680.0]), - renderer: eframe::Renderer::Glow, - ..Default::default() - }; - eframe::run_native( - "moonitor", - options, - Box::new(move |cc| { - ui::set_style(&cc.egui_ctx); - let selected = selected_node - .as_ref() - .and_then(|id| config.nodes.iter().position(|n| &n.id == id)); - let engine = engine::Engine::start(config, data_dir.join("history.sqlite")); - Ok(Box::new(ui::App::new( - engine, - selected, - selected_tab, - screenshot, - exit_after, - ))) - }), + let selected = selected_node + .as_ref() + .and_then(|id| config.nodes.iter().position(|n| &n.id == id)) + .unwrap_or(0); + ui::run( + config, + data_dir, + selected, + selected_tab, + screenshot, + exit_after, + display, ) - .map_err(|e| anyhow::anyhow!(e.to_string())) } fn default_data_dir() -> PathBuf { diff --git a/crates/moonitor-desktop/src/scene.rs b/crates/moonitor-desktop/src/scene.rs new file mode 100644 index 0000000..e47aacf --- /dev/null +++ b/crates/moonitor-desktop/src/scene.rs @@ -0,0 +1,1281 @@ +//! A small native mesh renderer. Geometry is uploaded once; only the camera and +//! selection uniforms change. No game loop, asset service, or web runtime. +use crate::{config::VisualKind, ui::Message, view_model::DeviceSceneModel}; +use bytemuck::{Pod, Zeroable}; +use glam::{Mat4, Vec3}; +use iced::wgpu; +use iced::{Rectangle, mouse, widget::shader}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use wgpu::util::DeviceExt; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Camera { + pub yaw: f32, + pub zoom: f32, +} +impl Default for Camera { + fn default() -> Self { + Self { + yaw: 0.30, + zoom: 1.0, + } + } +} +fn camera(camera: Camera, aspect: f32, devices: &[DeviceSceneModel]) -> Mat4 { + let eye = Vec3::new(camera.yaw.sin() * 11.0, 8.3, camera.yaw.cos() * 11.0); + let view = Mat4::look_at_rh(eye, Vec3::new(0.0, 0.30, 0.0), Vec3::Y); + let mut h: f32 = 1.5; + for device in devices { + for x in [-1.05, 1.05] { + for y in [0.0, 1.9] { + for z in [-0.9, 0.9] { + let p = view + .transform_point3(Vec3::from_array(device.position) + Vec3::new(x, y, z)); + h = h.max(p.y.abs()).max(p.x.abs() / aspect.max(0.1)); + } + } + } + } + let h = h * 1.12 / camera.zoom; + Mat4::orthographic_rh(-h * aspect, h * aspect, -h, h, 0.1, 40.0) * view +} +#[derive(Debug, Clone)] +pub struct Scene { + pub selected: Option, + pub devices: Vec, + pub camera: Camera, + pub dark: bool, +} +#[derive(Default)] +pub struct Interaction { + pressed: Option, + last: Option, + dragged: bool, +} +impl shader::Program for Scene { + type State = Interaction; + type Primitive = ScenePrimitive; + fn update( + &self, + state: &mut Interaction, + event: &iced::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + match event { + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + if cursor.is_over(bounds) => + { + state.pressed = cursor.position(); + state.last = state.pressed; + state.dragged = false; + Some(shader::Action::capture()) + } + iced::Event::Mouse(mouse::Event::CursorMoved { position }) + if state.pressed.is_some() => + { + if position.distance(state.pressed.unwrap()) > 5.0 { + state.dragged = true; + } + let dx = state.last.map(|p| position.x - p.x).unwrap_or(0.0); + state.last = Some(*position); + if state.dragged { + Some(shader::Action::publish(Message::Orbit(dx * 0.007)).and_capture()) + } else { + None + } + } + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + if state.pressed.take().is_some() => + { + state.last = None; + if !state.dragged + && let Some(p) = cursor.position_in(bounds) + { + let inv = + camera(self.camera, bounds.width / bounds.height, &self.devices).inverse(); + let x = p.x / bounds.width * 2.0 - 1.0; + let y = 1.0 - p.y / bounds.height * 2.0; + let origin = inv.project_point3(Vec3::new(x, y, 0.0)); + let direction = (inv.project_point3(Vec3::new(x, y, 1.0)) - origin).normalize(); + let picked = self + .devices + .iter() + .filter_map(|d| { + let tall = d.kind == VisualKind::Tower; + let center = Vec3::from_array(d.position) + + Vec3::Y * if tall { 0.9 } else { 0.4 }; + let half = if tall { + Vec3::new(0.6, 1.0, 0.65) + } else { + Vec3::new(0.9, 0.65, 0.85) + }; + ray_box(origin, direction, center - half, center + half) + .map(|distance| (d, distance)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + return picked.map(|(d, _)| { + shader::Action::publish(Message::Select(d.id.clone())).and_capture() + }); + } + Some(shader::Action::capture()) + } + _ => None, + } + } + fn draw(&self, _: &Interaction, _: mouse::Cursor, _: Rectangle) -> ScenePrimitive { + ScenePrimitive(self.clone()) + } + fn mouse_interaction( + &self, + state: &Interaction, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if state.pressed.is_some() { + mouse::Interaction::Grabbing + } else if cursor.is_over(bounds) { + mouse::Interaction::Grab + } else { + mouse::Interaction::default() + } + } +} +fn ray_box(origin: Vec3, direction: Vec3, min: Vec3, max: Vec3) -> Option { + let a = (min - origin) / direction; + let b = (max - origin) / direction; + let near = a.min(b).max_element(); + let far = a.max(b).min_element(); + (far >= near.max(0.0)).then_some(near.max(0.0)) +} +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +struct Vertex { + position: [f32; 3], + normal: [f32; 3], + color: [f32; 4], + material: [f32; 4], +} +#[repr(C)] +#[derive(Clone, Copy, PartialEq, Pod, Zeroable)] +struct Uniform { + camera: [[f32; 4]; 4], + light: [[f32; 4]; 4], + eye: [f32; 4], + state: [f32; 4], + nodes: [[f32; 4]; 32], +} +#[derive(Debug)] +pub struct ScenePrimitive(Scene); +pub struct ScenePipeline { + mesh: wgpu::Buffer, + layout: Vec<(String, VisualKind, [f32; 3])>, + count: u32, + uniform: wgpu::Buffer, + bind: wgpu::BindGroup, + shadow_bind: wgpu::BindGroup, + render: wgpu::RenderPipeline, + shadow_pipeline: wgpu::RenderPipeline, + shadow: wgpu::TextureView, + composite: wgpu::RenderPipeline, + composite_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + targets: Option, + format: wgpu::TextureFormat, + viewport: [f32; 4], + last_uniform: Option, + dirty: AtomicBool, + shadow_ready: AtomicBool, + scene_passes: AtomicU64, + shadow_passes: AtomicU64, + compositions: AtomicU64, +} +struct Targets { + size: [u32; 2], + color: wgpu::TextureView, + msaa: wgpu::TextureView, + depth: wgpu::TextureView, + bind: wgpu::BindGroup, +} +impl shader::Pipeline for ScenePipeline { + fn new(device: &wgpu::Device, _: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { + let vertices = geometry(&[]); + let mesh = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("moonitor static device meshes"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene uniforms"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let shadow = texture( + device, + 1024, + 1024, + wgpu::TextureFormat::Depth32Float, + 1, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + ); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("shadow comparison"), + compare: Some(wgpu::CompareFunction::LessEqual), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: None, + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + count: None, + }, + ], + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&shadow), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + let module = device.create_shader_module(wgpu::include_wgsl!("scene.wgsl")); + // The shadow pass cannot sample the texture it is writing. + let shadow_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("shadow uniform only"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let shadow_bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &shadow_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }], + }); + let shadow_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[&shadow_layout], + push_constant_ranges: &[], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[&layout], + push_constant_ranges: &[], + }); + let buffers = [wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Float32x4,3=>Float32x4], + }]; + let depth = wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: Default::default(), + bias: Default::default(), + }; + let render = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("lit device meshes"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &buffers, + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(depth.clone()), + multisample: wgpu::MultisampleState { + count: 4, + ..Default::default() + }, + multiview: None, + cache: None, + }); + let shadow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("directional shadow map"), + layout: Some(&shadow_pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_shadow"), + compilation_options: Default::default(), + buffers: &buffers, + }, + fragment: None, + primitive: wgpu::PrimitiveState { + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + bias: wgpu::DepthBiasState { + constant: 2, + slope_scale: 2.0, + clamp: 0.0, + }, + ..depth + }), + multisample: Default::default(), + multiview: None, + cache: None, + }); + let composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: None, + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let composite_module = device.create_shader_module(wgpu::include_wgsl!("composite.wgsl")); + let composite_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[&composite_layout], + push_constant_ranges: &[], + }); + let composite = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene composition"), + layout: Some(&composite_pipeline_layout), + vertex: wgpu::VertexState { + module: &composite_module, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &composite_module, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(format.into())], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview: None, + cache: None, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + Self { + mesh, + layout: Vec::new(), + count: vertices.len() as u32, + uniform, + bind, + shadow_bind, + render, + shadow_pipeline, + shadow, + composite, + composite_layout, + sampler, + targets: None, + format, + viewport: [0.0; 4], + last_uniform: None, + dirty: AtomicBool::new(true), + shadow_ready: AtomicBool::new(false), + scene_passes: AtomicU64::new(0), + shadow_passes: AtomicU64::new(0), + compositions: AtomicU64::new(0), + } + } +} +impl shader::Primitive for ScenePrimitive { + type Pipeline = ScenePipeline; + fn prepare( + &self, + p: &mut ScenePipeline, + device: &wgpu::Device, + queue: &wgpu::Queue, + bounds: &Rectangle, + viewport: &shader::Viewport, + ) { + let layout = self + .0 + .devices + .iter() + .map(|d| (d.id.clone(), d.kind, d.position)) + .collect::>(); + if p.layout != layout { + let vertices = geometry(&self.0.devices); + p.mesh = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("fleet meshes"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + p.count = vertices.len() as u32; + p.layout = layout; + p.dirty.store(true, Ordering::Relaxed); + p.shadow_ready.store(false, Ordering::Relaxed); + } + let scale = viewport.scale_factor(); + let w = (bounds.width * scale).round().max(1.0) as u32; + let h = (bounds.height * scale).round().max(1.0) as u32; + // Cap retina offscreen work at 1600 px wide; UI text retains full resolution. + let ratio = (1600.0 / w as f32).min(1.0); + let size = [(w as f32 * ratio) as u32, (h as f32 * ratio) as u32]; + if p.targets.as_ref().is_none_or(|t| t.size != size) { + p.dirty.store(true, Ordering::Relaxed); + let color = texture( + device, + size[0], + size[1], + p.format, + 1, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + ); + let msaa = texture( + device, + size[0], + size[1], + p.format, + 4, + wgpu::TextureUsages::RENDER_ATTACHMENT, + ); + let depth = texture( + device, + size[0], + size[1], + wgpu::TextureFormat::Depth32Float, + 4, + wgpu::TextureUsages::RENDER_ATTACHMENT, + ); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &p.composite_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&color), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&p.sampler), + }, + ], + }); + p.targets = Some(Targets { + size, + color, + msaa, + depth, + bind, + }); + } + p.viewport = [ + bounds.x * scale, + bounds.y * scale, + bounds.width * scale, + bounds.height * scale, + ]; + let extent = extent(&self.0.devices); + let light = Mat4::orthographic_rh( + -extent * 1.5, + extent * 1.5, + -extent * 1.5, + extent * 1.5, + 0.1, + 80.0, + ) * Mat4::look_at_rh(Vec3::new(-12.0, 36.0, 20.0), Vec3::ZERO, Vec3::Y); + let mut nodes = [[0.0; 4]; 32]; + for (i, d) in self.0.devices.iter().take(32).enumerate() { + let color = d.status.color(); + nodes[i] = [ + color[0], + color[1], + color[2], + f32::from(self.0.selected.as_deref() == Some(&d.id)), + ]; + } + let c = self.0.camera; + let u = Uniform { + camera: camera(c, bounds.width / bounds.height, &self.0.devices).to_cols_array_2d(), + light: light.to_cols_array_2d(), + eye: [c.yaw.sin() * 11.0, 8.3, c.yaw.cos() * 11.0, 1.0], + state: [f32::from(self.0.dark), 0.0, 0.0, 0.0], + nodes, + }; + if p.last_uniform != Some(u) { + queue.write_buffer(&p.uniform, 0, bytemuck::bytes_of(&u)); + p.last_uniform = Some(u); + p.dirty.store(true, Ordering::Relaxed); + } + } + fn render( + &self, + p: &ScenePipeline, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + clip: &Rectangle, + ) { + let Some(t) = &p.targets else { return }; + if !p.shadow_ready.swap(true, Ordering::Relaxed) { + p.shadow_passes.fetch_add(1, Ordering::Relaxed); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("shadow"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &p.shadow, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + pass.set_pipeline(&p.shadow_pipeline); + pass.set_bind_group(0, &p.shadow_bind, &[]); + pass.set_vertex_buffer(0, p.mesh.slice(..)); + pass.draw(6..p.count, 0..1); + } + if p.dirty.swap(false, Ordering::Relaxed) { + p.scene_passes.fetch_add(1, Ordering::Relaxed); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("scene"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &t.msaa, + depth_slice: None, + resolve_target: Some(&t.color), + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.89, + g: 0.90, + b: 0.87, + a: 1.0, + }), + store: wgpu::StoreOp::Discard, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &t.depth, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + }), + ..Default::default() + }); + pass.set_pipeline(&p.render); + pass.set_bind_group(0, &p.bind, &[]); + pass.set_vertex_buffer(0, p.mesh.slice(..)); + pass.draw(0..p.count, 0..1); + } + p.compositions.fetch_add(1, Ordering::Relaxed); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("compose"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&p.composite); + pass.set_bind_group(0, &t.bind, &[]); + pass.set_viewport( + p.viewport[0], + p.viewport[1], + p.viewport[2], + p.viewport[3], + 0.0, + 1.0, + ); + pass.set_scissor_rect(clip.x, clip.y, clip.width, clip.height); + pass.draw(0..3, 0..1); + } +} +impl Drop for ScenePipeline { + fn drop(&mut self) { + if std::env::var_os("MOONITOR_RENDER_DIAGNOSTICS").is_some() { + eprintln!( + "moonitor-renderer: scene_passes={} shadow_passes={} compositions={} vertices={}", + self.scene_passes.load(Ordering::Relaxed), + self.shadow_passes.load(Ordering::Relaxed), + self.compositions.load(Ordering::Relaxed), + self.count + ); + } + } +} +fn texture( + d: &wgpu::Device, + w: u32, + h: u32, + format: wgpu::TextureFormat, + samples: u32, + usage: wgpu::TextureUsages, +) -> wgpu::TextureView { + d.create_texture(&wgpu::TextureDescriptor { + label: Some("scene attachment"), + size: wgpu::Extent3d { + width: w.max(1), + height: h.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: samples, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + .create_view(&Default::default()) +} + +struct Mesh { + vertices: Vec, + node: f32, + origin: Vec3, + rotation: Mat4, +} +impl Mesh { + fn vertex(&self, p: Vec3, n: Vec3, color: [f32; 3], rough: f32, kind: f32) -> Vertex { + Vertex { + position: (self.rotation.transform_point3(p) + self.origin).to_array(), + normal: self.rotation.transform_vector3(n).to_array(), + color: [color[0], color[1], color[2], 1.0], + material: [self.node, rough, kind, 0.0], + } + } + fn quad(&mut self, p: [Vec3; 4], n: Vec3, c: [f32; 3], rough: f32, kind: f32) { + for i in [0, 1, 2, 0, 2, 3] { + self.vertices.push(self.vertex(p[i], n, c, rough, kind)); + } + } + fn box3(&mut self, center: Vec3, size: Vec3, bevel: f32, c: [f32; 3], rough: f32, kind: f32) { + let half = size * 0.5; + let bevel = bevel.min(half.min_element()); + let inner = (half - Vec3::splat(bevel)).max(Vec3::ZERO); + for (n, u, v) in [ + (Vec3::X, -Vec3::Z, Vec3::Y), + (-Vec3::X, Vec3::Z, Vec3::Y), + (Vec3::Y, Vec3::X, -Vec3::Z), + (-Vec3::Y, Vec3::X, Vec3::Z), + (Vec3::Z, Vec3::X, Vec3::Y), + (-Vec3::Z, -Vec3::X, Vec3::Y), + ] { + let hn = n.abs().dot(half); + let hu = u.abs().dot(half); + let hv = v.abs().dot(half); + if bevel <= 0.0 { + self.quad( + [ + center + n * hn - u * hu - v * hv, + center + n * hn + u * hu - v * hv, + center + n * hn + u * hu + v * hv, + center + n * hn - u * hu + v * hv, + ], + n, + c, + rough, + kind, + ); + continue; + } + let coords = |h: f32| { + [ + -h, + -h + bevel * 0.3, + -h + bevel, + h - bevel, + h - bevel * 0.3, + h, + ] + }; + let us = coords(hu); + let vs = coords(hv); + for a in 0..5 { + for b in 0..5 { + for (i, j) in [ + (a, b), + (a + 1, b), + (a + 1, b + 1), + (a, b), + (a + 1, b + 1), + (a, b + 1), + ] { + let raw = n * hn + u * us[i] + v * vs[j]; + let clamped = raw.clamp(-inner, inner); + let normal = (raw - clamped).normalize(); + let pos = center + clamped + normal * bevel; + self.vertices.push(self.vertex(pos, normal, c, rough, kind)); + } + } + } + } + } + fn cylinder(&mut self, center: Vec3, r: f32, height: f32, c: [f32; 3], kind: f32) { + for i in 0..80 { + let a = i as f32 * std::f32::consts::TAU / 80.0; + let b = (i + 1) as f32 * std::f32::consts::TAU / 80.0; + let na = Vec3::new(a.cos(), 0.0, a.sin()); + let nb = Vec3::new(b.cos(), 0.0, b.sin()); + let pa = center + na * r; + let pb = center + nb * r; + self.quad( + [pa, pb, pb + Vec3::Y * height, pa + Vec3::Y * height], + (na + nb).normalize(), + c, + 0.8, + kind, + ); + for p in [ + center + Vec3::Y * height, + pb + Vec3::Y * height, + pa + Vec3::Y * height, + ] { + self.vertices.push(self.vertex(p, Vec3::Y, c, 0.8, kind)); + } + } + } +} +fn geometry(devices: &[DeviceSceneModel]) -> Vec { + let mut m = Mesh { + vertices: vec![], + node: -1.0, + origin: Vec3::ZERO, + rotation: Mat4::IDENTITY, + }; + m.quad( + [ + Vec3::new(-30.0, -0.015, 30.0), + Vec3::new(30.0, -0.015, 30.0), + Vec3::new(30.0, -0.015, -30.0), + Vec3::new(-30.0, -0.015, -30.0), + ], + Vec3::Y, + [0.94, 0.95, 0.93], + 1.0, + 3.0, + ); + for (id, device) in devices.iter().enumerate() { + m.node = id as f32; + m.origin = Vec3::from_array(device.position); + m.rotation = Mat4::IDENTITY; + m.cylinder( + Vec3::ZERO, + if device.kind == VisualKind::Laptop { + 1.02 + } else { + 0.83 + }, + 0.045, + [0.82, 0.85, 0.81], + 2.0, + ); + m.origin.y = 0.055; + let silver = [0.64, 0.69, 0.70]; + let black = [0.09, 0.12, 0.13]; + let glass = [0.035, 0.065, 0.07]; + let green = [0.53, 0.84, 0.64]; + match device.kind { + VisualKind::Laptop => { + // MacBook, open lid, keyboard and trackpad. + m.rotation = Mat4::from_rotation_y(-0.12); + m.box3( + Vec3::new(0.0, 0.06, 0.0), + Vec3::new(1.52, 0.10, 1.02), + 0.035, + silver, + 0.25, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.115, -0.16), + Vec3::new(1.29, 0.012, 0.42), + 0.004, + black, + 0.8, + 0.0, + ); + for row in 0..4 { + for col in 0..13 { + m.box3( + Vec3::new( + -0.58 + col as f32 * 0.096, + 0.127, + -0.31 + row as f32 * 0.095, + ), + Vec3::new(0.078, 0.009, 0.072), + 0.0, + [0.20, 0.23, 0.24], + 0.6, + 0.0, + ); + } + } + m.box3( + Vec3::new(0.0, 0.116, 0.29), + Vec3::new(0.53, 0.01, 0.26), + 0.008, + [0.58, 0.64, 0.65], + 0.4, + 0.0, + ); + let base = m.origin; + m.origin += m.rotation.transform_vector3(Vec3::new(0.0, 0.13, -0.46)); + m.rotation *= Mat4::from_rotation_x(-0.20); + m.box3( + Vec3::new(0.0, 0.48, 0.0), + Vec3::new(1.52, 0.96, 0.055), + 0.035, + silver, + 0.25, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.49, 0.031), + Vec3::new(1.39, 0.81, 0.008), + 0.012, + glass, + 0.1, + 0.0, + ); + m.box3( + Vec3::new(-0.27, 0.48, 0.039), + Vec3::new(0.63, 0.33, 0.004), + 0.0, + [0.15, 0.39, 0.37], + 0.4, + 1.0, + ); + m.box3( + Vec3::new(0.33, 0.48, 0.040), + Vec3::new(0.44, 0.33, 0.004), + 0.0, + [0.25, 0.54, 0.46], + 0.4, + 1.0, + ); + m.origin = base; + } + VisualKind::Tower => { + // Exon workstation, perforated front and side panel. + m.rotation = Mat4::from_rotation_y(0.12); + m.box3( + Vec3::new(0.0, 0.93, 0.0), + Vec3::new(0.79, 1.82, 0.98), + 0.055, + [0.12, 0.15, 0.16], + 0.55, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.94, 0.502), + Vec3::new(0.65, 1.62, 0.025), + 0.018, + [0.07, 0.10, 0.11], + 0.9, + 0.0, + ); + for row in 0..20 { + for col in 0..6 { + m.box3( + Vec3::new( + -0.245 + col as f32 * 0.098, + 0.26 + row as f32 * 0.066, + 0.522, + ), + Vec3::new(0.052, 0.018, 0.005), + 0.0, + [0.21, 0.26, 0.27], + 0.9, + 0.0, + ); + } + } + m.box3( + Vec3::new(0.405, 0.96, 0.0), + Vec3::new(0.013, 1.49, 0.78), + 0.003, + [0.16, 0.21, 0.22], + 0.2, + 0.0, + ); + m.box3( + Vec3::new(0.0, 1.74, 0.52), + Vec3::new(0.11, 0.018, 0.006), + 0.0, + green, + 0.2, + 1.0, + ); + } + VisualKind::Compact => { + // DGX Spark: compact champagne metal chassis with fins. + m.rotation = Mat4::from_rotation_y(-0.08); + m.box3( + Vec3::new(0.0, 0.25, 0.0), + Vec3::new(1.14, 0.42, 1.09), + 0.055, + [0.63, 0.58, 0.46], + 0.35, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.472, 0.0), + Vec3::new(0.95, 0.014, 0.89), + 0.007, + [0.76, 0.71, 0.59], + 0.35, + 0.0, + ); + for i in 0..17 { + m.box3( + Vec3::new(-0.46 + i as f32 * 0.057, 0.29, 0.554), + Vec3::new(0.018, 0.19, 0.01), + 0.0, + [0.19, 0.19, 0.15], + 0.9, + 0.0, + ); + } + m.box3( + Vec3::new(0.34, 0.13, 0.56), + Vec3::new(0.045, 0.012, 0.006), + 0.0, + green, + 0.2, + 1.0, + ); + m.box3( + Vec3::new(0.0, 0.484, 0.0), + Vec3::new(0.21, 0.008, 0.21), + 0.008, + [0.22, 0.25, 0.17], + 0.5, + 0.0, + ); + } + VisualKind::Board => { + // Zima board with heatsink, ports and visible circuit board. + m.rotation = Mat4::from_rotation_y(-0.20); + m.box3( + Vec3::new(0.0, 0.105, 0.0), + Vec3::new(1.18, 0.08, 0.79), + 0.025, + [0.10, 0.24, 0.20], + 0.7, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.20, -0.04), + Vec3::new(0.80, 0.17, 0.56), + 0.015, + [0.25, 0.31, 0.30], + 0.45, + 0.0, + ); + for i in 0..13 { + m.box3( + Vec3::new(-0.38 + i as f32 * 0.064, 0.33, -0.04), + Vec3::new(0.027, 0.15, 0.56), + 0.0, + [0.46, 0.53, 0.51], + 0.45, + 0.0, + ); + } + for i in 0..3 { + m.box3( + Vec3::new(-0.36 + i as f32 * 0.32, 0.21, 0.35), + Vec3::new(0.24, 0.20, 0.16), + 0.012, + silver, + 0.4, + 0.0, + ); + m.box3( + Vec3::new(-0.36 + i as f32 * 0.32, 0.21, 0.438), + Vec3::new(0.17, 0.11, 0.012), + 0.0, + black, + 0.8, + 0.0, + ); + } + } + VisualKind::Phone => { + // iPhone glass slab, elevated enough for readable silhouette. + m.rotation = Mat4::from_rotation_y(-0.25) * Mat4::from_rotation_x(-0.12); + m.box3( + Vec3::new(0.0, 0.14, 0.0), + Vec3::new(0.63, 0.095, 1.22), + 0.045, + [0.21, 0.25, 0.27], + 0.25, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.191, 0.0), + Vec3::new(0.57, 0.012, 1.14), + 0.04, + glass, + 0.08, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.200, 0.04), + Vec3::new(0.48, 0.005, 0.80), + 0.035, + [0.18, 0.29, 0.30], + 0.5, + 0.0, + ); + m.box3( + Vec3::new(0.0, 0.201, -0.47), + Vec3::new(0.22, 0.006, 0.058), + 0.022, + black, + 0.4, + 0.0, + ); + } + } + } + m.vertices +} + +fn extent(devices: &[DeviceSceneModel]) -> f32 { + devices + .iter() + .map(|d| d.position[0].abs().max(d.position[2].abs()) + 2.0) + .fold(2.4, f32::max) + * 1.25 +} + +impl iced::widget::canvas::Program for Scene { + type State = (); + fn draw( + &self, + _: &(), + renderer: &iced::Renderer, + _: &iced::Theme, + bounds: Rectangle, + _: mouse::Cursor, + ) -> Vec { + use iced::widget::canvas; + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let mut labels: Vec = Vec::new(); + let matrix = camera(self.camera, bounds.width / bounds.height, &self.devices); + for (i, d) in self.devices.iter().enumerate() { + let p = matrix.project_point3( + Vec3::from_array(d.position) + + Vec3::new( + 0.0, + if d.kind == VisualKind::Tower { + 2.0 + } else { + 1.15 + }, + 0.0, + ), + ); + let label = if self.devices.len() > 8 || bounds.width < 300.0 { + format!("{:02}", i + 1) + } else { + format!("{:02} {}", i + 1, d.label) + }; + let width = label.chars().count() as f32 * 5.7 + 14.0; + let x = ((p.x + 1.0) * bounds.width / 2.0) + .clamp(width / 2.0, (bounds.width - width / 2.0).max(width / 2.0)); + let mut y = ((1.0 - p.y) * bounds.height / 2.0 - 12.0) + .clamp(10.0, (bounds.height - 10.0).max(10.0)); + // Separate labels that project close together; never cover another name. + let desired_y = y; + for step in 0..20 { + let shift = if step % 2 == 0 { + step as f32 * 10.0 + } else { + -(step as f32 + 1.0) * 10.0 + }; + let candidate = (desired_y + shift).clamp(10.0, (bounds.height - 10.0).max(10.0)); + let rect = Rectangle { + x: x - width / 2.0 - 2.0, + y: candidate - 10.0, + width: width + 4.0, + height: 20.0, + }; + if labels.iter().all(|other| !rect.intersects(other)) { + y = candidate; + break; + } + } + labels.push(Rectangle { + x: x - width / 2.0 - 2.0, + y: y - 10.0, + width: width + 4.0, + height: 20.0, + }); + let plate = canvas::Path::rounded_rectangle( + iced::Point::new(x - width / 2.0, y - 9.0), + iced::Size::new(width, 18.0), + 4.0.into(), + ); + frame.fill( + &plate, + if self.dark { + iced::Color::from_rgb(0.078, 0.125, 0.102) + } else { + iced::Color::WHITE + }, + ); + let c = d.status.color(); + frame.stroke( + &plate, + canvas::Stroke::default() + .with_width(0.8) + .with_color(iced::Color::from_rgb(c[0], c[1], c[2])), + ); + frame.fill_text(canvas::Text { + content: label, + position: iced::Point::new(x, y), + font: iced::Font::with_name("Inter"), + align_y: iced::alignment::Vertical::Center, + color: if self.dark { + iced::Color::from_rgb(0.918, 0.949, 0.925) + } else { + iced::Color::from_rgb(0.10, 0.15, 0.14) + }, + size: 11.0.into(), + align_x: iced::alignment::Horizontal::Center.into(), + ..Default::default() + }); + } + vec![frame.into_geometry()] + } +} +#[cfg(test)] +impl Scene { + pub fn test_device_point(&self, index: usize, size: iced::Size) -> iced::Point { + let d = &self.devices[index]; + let p = camera(self.camera, size.width / size.height, &self.devices) + .project_point3(Vec3::from_array(d.position) + Vec3::Y * 0.5); + iced::Point::new( + (p.x + 1.0) * size.width / 2.0, + (1.0 - p.y) * size.height / 2.0, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view_model::NodeStatus; + #[test] + fn mesh_contains_exact_fleet_ids_and_finite_geometry() { + for count in [0, 1, 2, 5, 16, 32] { + let devices = (0..count) + .map(|i| DeviceSceneModel { + id: format!("test-{i}"), + label: format!("Test {i}"), + kind: [ + VisualKind::Laptop, + VisualKind::Tower, + VisualKind::Compact, + VisualKind::Board, + VisualKind::Phone, + ][i % 5], + status: NodeStatus::Uninstrumented, + position: [i as f32 * 2.6, 0.0, 0.0], + }) + .collect::>(); + let vertices = geometry(&devices); + let ids = vertices + .iter() + .filter(|v| v.material[0] >= 0.0) + .map(|v| v.material[0] as usize) + .collect::>(); + assert_eq!(ids, (0..count).collect()); + assert!(vertices.iter().all(|v| { + v.position + .iter() + .chain(v.normal.iter()) + .all(|n| n.is_finite()) + })); + } + } +} diff --git a/crates/moonitor-desktop/src/scene.wgsl b/crates/moonitor-desktop/src/scene.wgsl new file mode 100644 index 0000000..99a0614 --- /dev/null +++ b/crates/moonitor-desktop/src/scene.wgsl @@ -0,0 +1,35 @@ +struct Uniforms { camera: mat4x4, light: mat4x4, eye:vec4, state:vec4, nodes:array,32> } +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var shadow_map:texture_depth_2d; +@group(0) @binding(2) var shadow_sampler:sampler_comparison; +struct Input { @location(0) position:vec3, @location(1) normal:vec3, @location(2) color:vec4, @location(3) material:vec4 } +struct Output { @builtin(position) position:vec4, @location(0) world:vec3, @location(1) normal:vec3, @location(2) color:vec4, @location(3) material:vec4, @location(4) shadow:vec4 } +@vertex fn vs_main(v:Input)->Output { + var o:Output; o.world=v.position; o.position=u.camera*vec4(v.position,1.0);o.normal=v.normal;o.color=v.color;o.material=v.material;o.shadow=u.light*vec4(v.position,1.0); return o; +} +@vertex fn vs_shadow(v:Input)->@builtin(position) vec4 {return u.light*vec4(v.position,1.0);} +@fragment fn fs_main(v:Output)->@location(0) vec4 { + let n=normalize(v.normal);let l=normalize(vec3(-3.0,9.0,5.0));let eye=normalize(u.eye.xyz-v.world); + let h=normalize(l+eye);let rough=v.material.y; + let s=v.shadow.xyz/v.shadow.w;let uv=vec2(s.x*0.5+0.5,0.5-s.y*0.5); + var shade=0.0; + for(var x=-1;x<=1;x++){for(var y=-1;y<=1;y++){shade+=textureSampleCompare(shadow_map,shadow_sampler,uv+vec2(f32(x),f32(y))/1024.0,s.z-0.0015);}} + shade=shade/9.0; + let diffuse=max(dot(n,l),0.0); + let hemi=0.36+0.30*(n.y*0.5+0.5); + let spec=pow(max(dot(n,h),0.0),mix(120.0,12.0,rough))*(1.0-rough)*0.4; + var base=pow(v.color.rgb,vec3(2.2)); + if(v.material.x>=0.0) { + let node=u.nodes[min(u32(v.material.x),31u)]; + if(v.material.z==2.0 || v.material.z==1.0) { + base=pow(mix(node.rgb,vec3(0.82,0.91,0.85),node.a*0.35),vec3(2.2)); + } + } + var color=base*(hemi+diffuse*shade*0.48)+vec3(spec*shade); + if(v.material.z==1.0) { color=base*0.85; } + if(v.material.z==3.0) { + // A quiet ground plane with real device shadows, no perpetual animation. + color=pow(mix(vec3(0.95,0.956,0.938),vec3(0.09,0.14,0.11),u.state.x),vec3(2.2))*(0.89+0.11*shade); + } + return vec4(color,1.0); +} diff --git a/crates/moonitor-desktop/src/ui.rs b/crates/moonitor-desktop/src/ui.rs index db10079..560f419 100644 --- a/crates/moonitor-desktop/src/ui.rs +++ b/crates/moonitor-desktop/src/ui.rs @@ -1,27 +1,182 @@ use crate::{ - config::NodeKind, + config::Config, engine::{Engine, NodeState}, + scene::{Camera, Scene}, + view_model::{FleetViewModel, Metric, NodeStatus}, +}; +use iced::{ + Border, Color, Element, Font, + Length::{self, Fill}, + Subscription, Task, Theme, alignment, + widget::{ + self, Column, Row, Space, button, canvas, column, container, progress_bar, row, scrollable, + text, text_input, tooltip, + }, + window, }; -use eframe::egui::{self, Color32, RichText, Stroke, Vec2}; use moonitor_core::*; -use moonitor_store::{HistoryPoint, memory_percent}; +use moonitor_store::HistoryPoint; use std::{ path::PathBuf, time::{Duration, Instant}, }; -const BG: Color32 = Color32::from_rgb(11, 16, 23); -const CARD: Color32 = Color32::from_rgb(19, 27, 38); -const BORDER: Color32 = Color32::from_rgb(37, 49, 64); -const TEXT: Color32 = Color32::from_rgb(228, 236, 245); -const MUTED: Color32 = Color32::from_rgb(132, 151, 172); -const CYAN: Color32 = Color32::from_rgb(86, 211, 215); -const GREEN: Color32 = Color32::from_rgb(130, 214, 160); -const AMBER: Color32 = Color32::from_rgb(239, 187, 105); - -#[derive(Clone, Copy, PartialEq)] +const BG: Color = Color::from_rgb(0.965, 0.970, 0.955); +const PAPER: Color = Color::WHITE; +const INK: Color = Color::from_rgb(0.10, 0.15, 0.14); +const MUTED: Color = Color::from_rgb(0.45, 0.50, 0.47); +const LINE: Color = Color::from_rgb(0.87, 0.89, 0.86); +const GREEN: Color = Color::from_rgb(0.23, 0.49, 0.36); +const LIME: Color = Color::from_rgb(0.71, 0.87, 0.60); +const AMBER: Color = Color::from_rgb(0.65, 0.40, 0.18); +const NAV: Color = Color::from_rgb(0.085, 0.12, 0.11); +const FONT: Font = Font::with_name("Inter"); +const MEDIUM: Font = Font { + weight: iced::font::Weight::Medium, + ..FONT +}; +const STRONG: Font = Font { + weight: iced::font::Weight::Semibold, + ..FONT +}; +#[derive(Clone, Copy)] +pub struct DisplayOptions { + pub size: iced::Size, + pub dark: bool, + pub scene_visible: bool, + pub section: Option
, +} +impl Default for DisplayOptions { + fn default() -> Self { + Self { + size: iced::Size::new(1380.0, 940.0), + dark: false, + scene_visible: true, + section: None, + } + } +} +fn app_icon() -> Option { + let pixels = image::load_from_memory(include_bytes!("../assets/moonitor-256.png")) + .ok()? + .into_rgba8(); + window::icon::from_rgba(pixels.to_vec(), pixels.width(), pixels.height()).ok() +} +fn brand_icon() -> widget::Image { + static ICON: std::sync::OnceLock = std::sync::OnceLock::new(); + let handle = ICON.get_or_init(|| { + // RGBA handles upload synchronously: the first frame must contain the brand too. + let pixels = image::load_from_memory(include_bytes!("../assets/moonitor-128.png")) + .expect("embedded brand PNG") + .into_rgba8(); + widget::image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw()) + }); + widget::image(handle.clone()).width(34).height(34) +} +fn tone(color: Color, theme: &Theme) -> Color { + if !theme.extended_palette().is_dark { + return color; + } + if color == AMBER { + return Color::from_rgb(0.94, 0.72, 0.40); + } + if color == Color::from_rgb(0.98, 0.94, 0.86) { + return Color::from_rgb(0.20, 0.14, 0.09); + } + if color == Color::from_rgb(0.72, 0.25, 0.23) { + return Color::from_rgb(0.94, 0.57, 0.53); + } + if color == Color::from_rgb(0.70, 0.45, 0.15) { + return Color::from_rgb(0.94, 0.72, 0.40); + } + if color == Color::from_rgb(0.35, 0.46, 0.65) { + return Color::from_rgb(0.62, 0.74, 0.94); + } + if color == Color::from_rgb(0.48, 0.51, 0.50) { + return Color::from_rgb(0.66, 0.72, 0.68); + } + if color == BG { + Color::from_rgb(0.051, 0.086, 0.075) + } else if color == PAPER { + Color::from_rgb(0.078, 0.125, 0.102) + } else if color == INK { + Color::from_rgb(0.918, 0.949, 0.925) + } else if color == MUTED { + Color::from_rgb(0.64, 0.72, 0.67) + } else if color == LINE { + Color::from_rgb(0.20, 0.29, 0.24) + } else if color == GREEN { + Color::from_rgb(0.48, 0.75, 0.58) + } else if color.r > 0.8 && color.g > 0.8 && color.b > 0.7 && color != Color::WHITE { + Color::from_rgb(0.12, 0.21, 0.16) + } else { + color + } +} +trait TextTone { + fn tone(self, color: Color) -> Self; +} +impl TextTone for widget::Text<'_> { + fn tone(self, color: Color) -> Self { + self.style(move |theme| text::Style { + color: Some(if color == Color::WHITE { + color + } else { + tone(color, theme) + }), + }) + } +} +fn themed_panel(theme: &Theme, mut style: container::Style) -> container::Style { + if let Some(iced::Background::Color(c)) = style.background { + style.background = Some(tone(c, theme).into()); + } + if let Some(c) = style.text_color { + style.text_color = Some(tone(c, theme)); + } + style.border.color = tone(style.border.color, theme); + style +} +fn themed_button(theme: &Theme, mut style: button::Style) -> button::Style { + if let Some(iced::Background::Color(c)) = style.background { + style.background = Some(tone(c, theme).into()); + } + style.text_color = tone(style.text_color, theme); + style.border.color = tone(style.border.color, theme); + style +} +fn app_theme(dark: bool) -> Theme { + if dark { + Theme::custom( + "moonitor dark", + iced::theme::Palette { + background: Color::from_rgb(0.051, 0.086, 0.075), + text: Color::from_rgb(0.918, 0.949, 0.925), + primary: GREEN, + success: GREEN, + warning: AMBER, + danger: AMBER, + }, + ) + } else { + Theme::custom( + "moonitor", + iced::theme::Palette { + background: BG, + text: INK, + primary: GREEN, + success: GREEN, + warning: AMBER, + danger: AMBER, + }, + ) + } +} +#[derive(Clone, Copy, Debug, PartialEq)] pub enum Tab { Overview, + Compute, + Sensors, Processes, Ai, Disks, @@ -32,6 +187,8 @@ impl Tab { pub fn parse(s: &str) -> Option { Some(match s { "overview" => Self::Overview, + "compute" => Self::Compute, + "sensors" => Self::Sensors, "processes" => Self::Processes, "ai" => Self::Ai, "disks" => Self::Disks, @@ -40,660 +197,939 @@ impl Tab { _ => return None, }) } + fn label(self) -> &'static str { + match self { + Self::Overview => "Summary", + Self::Compute => "Compute", + Self::Sensors => "Sensori", + Self::Processes => "Processi", + Self::Ai => "AI locale", + Self::Disks => "Archiviazione", + Self::Network => "Rete", + Self::History => "Storico", + } + } } - -pub fn set_style(ctx: &egui::Context) { - let mut visuals = egui::Visuals::dark(); - visuals.panel_fill = BG; - visuals.window_fill = CARD; - visuals.override_text_color = Some(TEXT); - visuals.selection.bg_fill = Color32::from_rgb(35, 78, 88); - visuals.widgets.inactive.bg_fill = CARD; - visuals.widgets.inactive.weak_bg_fill = CARD; - visuals.widgets.hovered.bg_fill = Color32::from_rgb(31, 49, 63); - ctx.set_visuals(visuals); - ctx.global_style_mut(|s| { - s.spacing.item_spacing = Vec2::new(12.0, 10.0); - s.animation_time = 0.0; - }); +const TABS: [Tab; 8] = [ + Tab::Overview, + Tab::Compute, + Tab::Sensors, + Tab::Ai, + Tab::Processes, + Tab::Disks, + Tab::Network, + Tab::History, +]; +#[derive(Debug, Clone)] +pub enum Message { + Tick, + CaptureScreenshot, + Frame, + Select(String), + SwitchNode(String), + ToggleScene, + Focus(bool), + NextNode(i32), + Tab(Tab), + Navigate(Section), + Theme(bool), + Filter(String), + Sort(bool), + Orbit(f32), + Zoom(f32), + ResetCamera, + Window(window::Id, window::Event), + Screenshot(window::Screenshot, PathBuf), } - pub struct App { - engine: Engine, - selected: Option, + _engine: Option, + nodes: Vec, + storage_error: Option, + dropped_events: u64, + pending_samples: Vec, + selected: Option, + scene_visible: bool, + dark: bool, tab: Tab, + section: Section, filter: String, sort_memory: bool, + camera: Camera, started: Instant, + view_time_ms: u64, screenshot: Option, exit_after: Option, + window: Option, } -impl App { - pub fn new( - engine: Engine, - selected: Option, - tab: Tab, - screenshot: Option, - exit_after: Option, - ) -> Self { - Self { - engine, - selected, - tab, - filter: String::new(), - sort_memory: false, - started: Instant::now(), - screenshot, - exit_after, - } - } +pub fn run( + config: Config, + data_dir: PathBuf, + selected: usize, + tab: Tab, + screenshot: Option, + exit_after: Option, + display: DisplayOptions, +) -> anyhow::Result<()> { + iced::application( + move || { + let engine = Engine::start(config.clone(), data_dir.join("history.sqlite")); + let nodes = engine.state.lock().unwrap().nodes.clone(); + App { + _engine: Some(engine), + selected: nodes.get(selected).map(|n| n.config.id.clone()), + scene_visible: display.scene_visible, + dark: display.dark, + nodes, + storage_error: None, + dropped_events: 0, + pending_samples: Vec::new(), + tab, + section: display.section.unwrap_or(if tab == Tab::Overview { + Section::Overview + } else { + Section::Device + }), + filter: String::new(), + sort_memory: false, + camera: Camera::default(), + started: Instant::now(), + view_time_ms: now_ms(), + screenshot: screenshot.clone(), + exit_after, + window: None, + } + }, + App::update, + App::view, + ) + .title("moonitor · Compute Observatory") + .window(window::Settings { + size: display.size, + icon: app_icon(), + min_size: Some(iced::Size::new(1160.0, 760.0)), + position: window::Position::Centered, + ..Default::default() + }) + .default_font(FONT) + .font(include_bytes!("../assets/Inter-Regular.ttf").as_slice()) + .font(include_bytes!("../assets/Inter-Medium.ttf").as_slice()) + .font(include_bytes!("../assets/Inter-SemiBold.ttf").as_slice()) + .theme(|app: &App| app_theme(app.dark)) + .subscription(|app: &App| { + Subscription::batch([ + iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick), + window::events().map(|(id, e)| Message::Window(id, e)), + if app.pending_samples.is_empty() { + Subscription::none() + } else { + window::frames().map(|_| Message::Frame) + }, + iced::event::listen_with(|event, status, _| { + if status == iced::event::Status::Captured { + return None; + } + let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key, modifiers, .. + }) = event + else { + return None; + }; + use iced::keyboard::{Key, key::Named}; + match key { + Key::Named(Named::Tab) => Some(Message::Focus(modifiers.shift())), + Key::Named(Named::ArrowLeft) if modifiers.alt() => Some(Message::NextNode(-1)), + Key::Named(Named::ArrowRight) if modifiers.alt() => Some(Message::NextNode(1)), + Key::Character(ref c) if modifiers.command() => c + .parse::() + .ok() + .filter(|i| (1..=7).contains(i)) + .map(|i| Message::Navigate(SECTIONS[i - 1])), + _ => None, + } + }), + ]) + }) + .run() + .map_err(|e| anyhow::anyhow!(e.to_string())) } - -impl eframe::App for App { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - ui.ctx().request_repaint_after(Duration::from_secs(1)); - let elapsed = self.started.elapsed().as_secs_f32(); - if elapsed >= 8.0 - && let Some(path) = self.screenshot.take() - { - ui.ctx() - .send_viewport_cmd(egui::ViewportCommand::Screenshot( - egui::ScreenshotCallback::new(move |img| { - let bytes: Vec<_> = img.pixels.iter().flat_map(|c| c.to_array()).collect(); - if let Err(e) = image::save_buffer( - &path, - &bytes, - img.width() as u32, - img.height() as u32, - image::ColorType::Rgba8, - ) { - eprintln!("Screenshot failed: {e}"); - } - }), - )); - } - if self.exit_after.is_some_and(|s| elapsed >= s) { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - let (nodes, storage_error) = { - let shared = self.engine.state.lock().unwrap(); - (shared.nodes.clone(), shared.storage_error.clone()) - }; - egui::Frame::new().fill(BG).inner_margin(20).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new("moonitor").size(32.0).strong()); - ui.add_space(12.0); - ui.label( - RichText::new("HOMELAB / TELEMETRIA LOCALE") - .size(11.0) - .color(MUTED) - .monospace(), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label( - RichText::new(format!( - "{} nodi attivi", - nodes.iter().filter(|n| n.live()).count() - )) - .color(GREEN), - ); - }); - }); - ui.add_space(12.0); - ui.separator(); - ui.add_space(12.0); - egui::Panel::left("fleet") - .exact_size(190.0) - .resizable(false) - .frame(egui::Frame::new().fill(BG).inner_margin(egui::Margin { - right: 18, - ..Default::default() - })) - .show(ui, |ui| { - ui.label( - RichText::new("DISPOSITIVI") - .color(MUTED) - .size(11.0) - .monospace(), - ); - if ui - .add_sized( - [170.0, 38.0], - egui::Button::new("Tutti i nodi").selected(self.selected.is_none()), - ) - .clicked() - { - self.selected = None; - } - ui.add_space(8.0); - for (index, node) in nodes.iter().enumerate() { - let (status, color) = status(node); - egui::Frame::new() - .fill(if self.selected == Some(index) { - Color32::from_rgb(28, 49, 60) - } else { - CARD +impl App { + fn update(&mut self, message: Message) -> Task { + match message { + Message::Tick => { + if self._engine.is_some() { + self.view_time_ms = now_ms(); + } + if let Some(engine) = &self._engine { + let shared = engine.state.lock().unwrap(); + if std::env::var_os("MOONITOR_RENDER_DIAGNOSTICS").is_some() { + self.pending_samples = shared + .nodes + .iter() + .filter(|n| { + self.nodes + .iter() + .find(|old| old.config.id == n.config.id) + .is_none_or(|old| old.last_seen != n.last_seen) }) - .inner_margin(12) - .corner_radius(8) - .show(ui, |ui| { - ui.set_min_width(146.0); - if ui - .add( - egui::Button::new( - RichText::new(&node.config.label).strong(), - ) - .frame(false), - ) - .clicked() - { - self.selected = Some(index); - } - ui.label(RichText::new(status).size(11.0).color(color)); - if let Some(s) = &node.sample { - ui.label( - RichText::new(format!( - "CPU {} / RAM {}", - pct(s.cpu_utilization_percent.value().copied()), - pct(memory_percent(s)) - )) - .size(10.5) - .color(MUTED), - ); - } - }); + .filter_map(|n| n.last_seen) + .collect(); } - ui.add_space(14.0); - ui.label( - RichText::new("1 s live\n5 s storico / 7 giorni\nSSH persistente") - .size(11.0) - .color(MUTED), + self.nodes = shared.nodes.clone(); + self.storage_error = shared.storage_error.clone(); + self.dropped_events = shared + .dropped_events + .load(std::sync::atomic::Ordering::Relaxed); + } + self.selected = + FleetViewModel::new(&self.nodes, self.selected.as_deref(), Instant::now()) + .selected_id; + if self + .exit_after + .is_some_and(|s| self.started.elapsed().as_secs_f32() >= s) + { + return iced::exit(); + } + if self.started.elapsed() >= Duration::from_secs(8) + && self.window.is_some() + && self.screenshot.is_some() + { + // Capture on a separate event-loop turn: Tick replaces paragraphs. + // Capturing the previous renderer batch here leaves dangling weak text references. + return Task::perform( + async { + std::thread::sleep(Duration::from_millis(150)); + }, + |_| Message::CaptureScreenshot, ); - }); - egui::CentralPanel::default() - .frame(egui::Frame::new().fill(BG).inner_margin(egui::Margin { - left: 14, - ..Default::default() - })) - .show(ui, |ui| { - if let Some(e) = storage_error { - ui.colored_label(AMBER, format!("Storico non disponibile: {e}")); - } - egui::ScrollArea::vertical().id_salt("main").show(ui, |ui| { - if let Some(index) = self.selected { - self.device(ui, &nodes[index]); - } else { - self.fleet(ui, &nodes); - } - }); - }); - }); - } -} - -impl App { - fn fleet(&mut self, ui: &mut egui::Ui, nodes: &[NodeState]) { - ui.heading("Il tuo homelab"); - ui.label( - RichText::new("Risorse, GPU e carichi AI dei dispositivi in LAN / VPN").color(MUTED), - ); - ui.add_space(16.0); - let columns = if ui.available_width() > 760.0 { 2 } else { 1 }; - for chunk in nodes.chunks(columns) { - ui.columns(columns, |cols| { - for (col, node) in cols.iter_mut().zip(chunk) { - card(col, |ui| { - let (label, color) = status(node); - ui.horizontal(|ui| { - ui.label(RichText::new(&node.config.label).size(20.0).strong()); - ui.colored_label(color, label); - }); - if let Some(s) = &node.sample { - ui.label(RichText::new(&s.cpu_name).size(11.0).color(MUTED)); - meter(ui, "CPU", s.cpu_utilization_percent.value().copied(), CYAN); - meter(ui, "RAM", memory_percent(s), GREEN); - meter( - ui, - "GPU", - s.gpus - .iter() - .filter_map(|g| g.utilization_percent.value().copied()) - .reduce(f64::max), - AMBER, - ); - ui.label( - RichText::new(format!( - "{} / {} core / {} processi", - bytes(s.memory.total_bytes.value().copied()), - s.cpu_cores_percent.len(), - s.process_count - )) - .size(11.0) - .color(MUTED), - ); - } else { - ui.add_space(14.0); - ui.label( - RichText::new(if node.config.note.is_empty() { - "In attesa di telemetria" - } else { - &node.config.note - }) - .color(MUTED), - ); - if let Some(e) = &node.error { - ui.label(RichText::new(e).color(AMBER).size(11.0)); - } - ui.add_space(14.0); - } - if ui.button("Apri dispositivo").clicked() { - self.selected = - nodes.iter().position(|n| n.config.id == node.config.id); - } - }); } - }); - ui.add_space(8.0); + } + Message::Frame => { + for seen in self.pending_samples.drain(..) { + eprintln!( + "moonitor-ui-latency: receipt_to_redraw_ms={:.3}", + seen.elapsed().as_secs_f64() * 1000.0 + ); + } + } + Message::CaptureScreenshot => { + if let (Some(window), Some(path)) = (self.window, self.screenshot.take()) { + return window::screenshot(window) + .map(move |s| Message::Screenshot(s, path.clone())); + } + } + Message::Window(id, window::Event::Opened { .. }) => self.window = Some(id), + Message::Window(_, _) => (), + Message::Screenshot(s, path) => { + if let Err(e) = image::save_buffer( + &path, + &s.rgba, + s.size.width, + s.size.height, + image::ColorType::Rgba8, + ) { + eprintln!("Screenshot failed: {e}"); + } + } + Message::Select(id) => { + if self.nodes.iter().any(|n| n.config.id == id) { + self.selected = Some(id); + self.section = Section::Device; + } + } + Message::ToggleScene => self.scene_visible = !self.scene_visible, + Message::Focus(reverse) => { + return if reverse { + widget::operation::focus_previous() + } else { + widget::operation::focus_next() + }; + } + Message::SwitchNode(id) => { + if self.nodes.iter().any(|n| n.config.id == id) { + self.selected = Some(id); + } + } + Message::NextNode(delta) => { + if !self.nodes.is_empty() { + let current = self + .nodes + .iter() + .position(|n| Some(n.config.id.as_str()) == self.selected.as_deref()) + .unwrap_or(0); + let next = + (current as i32 + delta).rem_euclid(self.nodes.len() as i32) as usize; + self.selected = Some(self.nodes[next].config.id.clone()); + } + } + Message::Tab(tab) => { + self.tab = tab; + self.section = Section::Device; + } + Message::Navigate(section) => self.section = section, + Message::Theme(dark) => self.dark = dark, + Message::Filter(s) => self.filter = s, + Message::Sort(memory) => self.sort_memory = memory, + Message::Orbit(dx) => self.camera.yaw += dx, + Message::Zoom(delta) => self.camera.zoom = (self.camera.zoom + delta).clamp(0.75, 1.6), + Message::ResetCamera => self.camera = Camera::default(), } - ui.add_space(12.0); - ui.label(RichText::new("N/D indica una misura non disponibile. I campioni interrotti restano visibili con lo stato obsoleto.").color(MUTED).size(11.0)); + Task::none() } - - fn device(&mut self, ui: &mut egui::Ui, node: &NodeState) { - ui.horizontal(|ui| { - ui.heading(&node.config.label); - let (s, c) = status(node); - ui.colored_label(c, s); - }); - let Some(s) = &node.sample else { - ui.add_space(20.0); - ui.label(&node.config.note); - ui.label( - node.error.as_deref().unwrap_or( - "In attesa dei primi campioni. La raccolta riprova automaticamente.", - ), - ); - return; - }; - ui.label( - RichText::new(format!("{} / {} / {}", s.os, s.arch, s.cpu_name)) - .color(MUTED) - .size(12.0), - ); - if !node.live() { - ui.colored_label( - AMBER, - format!( - "Ultimo campione ricevuto {:.0} s fa. {}", - node.last_seen - .map(|t| t.elapsed().as_secs_f64()) - .unwrap_or_default(), - node.error.as_deref().unwrap_or("Dati obsoleti") - ), + fn view(&self) -> Element<'_, Message> { + self.workbench_view() + } + fn scene_list(&self) -> Element<'_, Message> { + let mut rows = Column::new().spacing(8); + for node in &self.nodes { + let (state, color) = status(node); + rows = rows.push( + button( + row![ + text(&node.config.label).size(14).width(Fill), + text(state).size(12).tone(color) + ] + .spacing(16), + ) + .on_press(Message::Select(node.config.id.clone())) + .width(Fill) + .padding(10) + .style(|theme, state| { + themed_button( + theme, + button::Style { + background: Some( + if matches!(state, button::Status::Hovered) { + BG + } else { + PAPER + } + .into(), + ), + text_color: INK, + border: Border { + color: LINE, + width: 0.7, + radius: 6.0.into(), + }, + ..Default::default() + }, + ) + }), ); } - ui.add_space(14.0); - ui.columns(4, |c| { - stat( - &mut c[0], - "CPU", - &pct(s.cpu_utilization_percent.value().copied()), - &format!("{} core logici", s.cpu_cores_percent.len()), - CYAN, - ); - let total = s.memory.total_bytes.value().copied(); - let used = total - .zip(s.memory.available_bytes.value().copied()) - .map(|(t, a)| t.saturating_sub(a)); - stat( - &mut c[1], - "MEMORIA", - &pct(memory_percent(s)), - &format!("{} / {}", bytes(used), bytes(total)), - GREEN, - ); - let gpu = s - .gpus - .iter() - .filter_map(|g| g.utilization_percent.value().copied()) - .reduce(f64::max); - stat( - &mut c[2], - "GPU", - &pct(gpu), - s.gpus - .first() - .map(|g| g.name.as_str()) - .unwrap_or("Sensore non disponibile"), - AMBER, - ); - let power = s - .power - .iter() - .find(|p| p.scope == PowerScope::GpuBoard) - .and_then(|p| p.watts.value()) - .map(|p| format!("{p:.1} W")) - .unwrap_or_else(|| "N/D".into()); - stat( - &mut c[3], - "POTENZA GPU", - &power, - "Intero dispositivo: N/D", - CYAN, + scrollable(rows).height(Fill).into() + } + fn node_heading<'a>(&self, n: &'a NodeState) -> Element<'a, Message> { + let (state, color) = status(n); + let mut c = column![ + row![ + text(&n.config.label).size(24).font(STRONG), + Space::new().width(Fill), + pill(state, color) + ] + .align_y(alignment::Vertical::Center) + ] + .spacing(10); + if let Some(s) = &n.sample { + c = c.push( + text(format!("{} / {} / {}", s.os, s.arch, s.cpu_name)) + .size(11) + .tone(MUTED), ); - }); - ui.add_space(12.0); - ui.horizontal_wrapped(|ui| { - for (tab, label) in [ - (Tab::Overview, "Panoramica"), - (Tab::Processes, "Processi"), - (Tab::Ai, "AI locale"), - (Tab::Disks, "Dischi"), - (Tab::Network, "Rete"), - (Tab::History, "Storico"), - ] { - if ui.selectable_label(self.tab == tab, label).clicked() { - self.tab = tab; - } - } - }); - ui.separator(); - ui.add_space(10.0); + } + if !n.live() && n.sample.is_some() { + c = c.push(notice(&format!( + "Campione ricevuto {} s fa. {}", + n.last_seen.map(|t| t.elapsed().as_secs()).unwrap_or(0), + n.error.as_deref().unwrap_or("Telemetria interrotta") + ))); + } + c.into() + } + fn details<'a>(&'a self, n: &'a NodeState) -> Element<'a, Message> { + let Some(s) = &n.sample else { + return card( + column![ + text(status(n).0).size(18).font(STRONG), + text(&n.config.note).tone(MUTED).size(12), + text("Questa vista richiede un agente sul dispositivo. Controlla il tipo di nodo e l'alias SSH in fleet.toml; un dispositivo senza agente resta visibile nell'inventario.").size(13), + small_button("Vai alla flotta", Message::Navigate(Section::Fleet)) + ] + .spacing(12), + ) + .into(); + }; match self.tab { Tab::Overview => { - ui.columns(2, |cols| { - chart( - &mut cols[0], - "CPU / ultimi 5 minuti", - &node.history, - |p| p.cpu_percent, - CYAN, - ); - chart( - &mut cols[1], - "GPU / ultimi 5 minuti", - &node.history, - |p| p.gpu_percent, - AMBER, - ); - }); - ui.add_space(12.0); - card(ui, |ui| { - ui.label( - RichText::new("CORE LOGICI") - .color(MUTED) - .monospace() - .size(11.0), + let mut content = column![ + row![ + text(format!("{} · attività", n.config.label)) + .size(16) + .font(STRONG), + Space::new().width(Fill), + text("ULTIMI 5 MINUTI").size(9).tone(MUTED) + ] + .align_y(alignment::Vertical::Center), + row![ + plot("CPU", &n.history, self.view_time_ms, 0, GREEN), + plot( + "Memoria", + &n.history, + self.view_time_ms, + 1, + Color::from_rgb(0.49, 0.58, 0.41) + ), + plot( + "GPU", + &n.history, + self.view_time_ms, + 2, + Color::from_rgb(0.51, 0.58, 0.64) + ) + ] + .spacing(24) + ] + .spacing(20); + let models = s.ollama.models.value(); + let ai = s.processes.iter().filter(|p| p.is_ai).count(); + let runtime = match models { + Some(m) if m.is_empty() => "Ollama pronto · nessun modello caricato".to_owned(), + Some(m) => format!("{} modelli caricati in Ollama", m.len()), + None => format!("Ollama: {}", reading_reason(&s.ollama.models)), + }; + content = content.push(widget::rule::horizontal(1)).push( + row![ + canvas(Icon("ai", GREEN)).width(20).height(20), + column![ + text("AI locale").size(12).font(STRONG), + text(runtime).size(11).tone(MUTED) + ] + .spacing(5), + Space::new().width(Fill), + text(format!("{ai} processi candidati")) + .size(11) + .tone(MUTED), + small_button("Apri AI locale →", Message::Tab(Tab::Ai)) + ] + .spacing(14) + .align_y(alignment::Vertical::Center), + ); + card(content).into() + } + Tab::Compute => { + let mut c = + column![text("CPU · carico per core").size(18).font(STRONG)].spacing(14); + for (i, value) in s.cpu_cores_percent.iter().enumerate() { + let reading = Reading::reported( + *value as f64, + "sysinfo · CPU core", + s.sampled_at_unix_ms, ); - egui::Grid::new("cores") - .num_columns(4) - .spacing([14.0, 8.0]) - .show(ui, |ui| { - for (i, value) in s.cpu_cores_percent.iter().enumerate() { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("{:02}", i)).monospace().color(MUTED), - ); - ui.add( - egui::ProgressBar::new(*value / 100.0) - .desired_width(135.0) - .fill(CYAN) - .text(format!("{value:.0}%")), - ); - }); - if i % 4 == 3 { - ui.end_row(); - } - } - }); - }); - ui.add_space(10.0); - card(ui, |ui| { - ui.label(format!( - "Swap: {} / Uptime: {:.1} ore / Raccolta: {:.2} ms", - bytes(s.memory.swap_used_bytes.value().copied()), - s.uptime_seconds as f64 / 3600.0, - s.collector_duration_ms + c = c.push(metric( + Metric::percent(&format!("Core {i}"), Some(&reading), !n.live()), + GREEN, )); - if let Some(b) = s.battery_percent.value() { - ui.label(format!("Batteria: {b:.0}%")); - } - for p in &s.pressure { - ui.label(format!( - "Pressione {} (stall avg10): {:.2}%", - p.resource, p.some_avg10_percent - )); - } - for t in s.temperatures.iter().take(8) { - ui.label(format!("{}: {:.1} °C", t.label, t.celsius)); - } - if s.temperatures.is_empty() { - ui.label(RichText::new("Temperature di sistema: N/D").color(MUTED)); - } - }); + } + for gpu in &s.gpus { + c = c.push(card( + column![ + text(&gpu.name).size(17).font(STRONG), + metric( + Metric::percent( + "Utilizzo GPU", + Some(&gpu.utilization_percent), + !n.live() + ), + GREEN + ) + ] + .spacing(12), + )); + } + c.into() + } + Tab::Sensors => { + let mut c = column![text("Sensori disponibili").size(18).font(STRONG)].spacing(14); + if s.temperatures.is_empty() + && s.pressure.is_empty() + && s.battery_percent.value().is_none() + { + c = c.push(notice( + "Questo agente non espone sensori aggiuntivi su questo nodo.", + )); + } + for t in &s.temperatures { + c = c.push(table_row( + [ + &t.label, + &format!("{:.1} °C", t.celsius), + "Temperatura", + "Sistema", + ], + false, + )); + } + for p in &s.pressure { + c = c.push(table_row( + [ + &p.resource, + &format!("{:.1}%", p.some_avg10_percent), + "Stallo PSI · avg10", + "Linux", + ], + false, + )); + } + c.push(metric( + Metric::percent("Batteria", Some(&s.battery_percent), !n.live()), + GREEN, + )) + .into() } - Tab::Processes => self.processes(ui, s, false), + Tab::Processes => self.processes(s, false), Tab::Ai => { - for gpu in &s.gpus { - card(ui, |ui| { - ui.label(RichText::new(&gpu.name).size(17.0).strong()); - ui.label(format!( - "Memoria GPU: {} / {} ({:?})", - bytes(gpu.memory_used_bytes.value().copied()), - bytes(gpu.memory_total_bytes.value().copied()), - gpu.memory_kind - )); - if gpu.memory_kind == GpuMemoryKind::Unified { - ui.label( - RichText::new( - "Memoria condivisa con la CPU: i totali non si sommano.", + let mut c = column![row![ + text("Acceleratori").size(19).font(STRONG), + Space::new().width(Fill), + text("MISURE DEL RUNTIME E DEL SISTEMA").size(9).tone(MUTED) + ]] + .spacing(18); + for g in &s.gpus { + let mut gpu = column![ + text(&g.name).size(17).font(STRONG), + row![ + metric( + Metric::percent("CARICO", Some(&g.utilization_percent), !n.live()), + GREEN + ), + mini_stat( + "MEMORIA GPU", + &format!( + "{} / {}", + bytes(g.memory_used_bytes.value().copied()), + bytes(g.memory_total_bytes.value().copied()) ) - .color(MUTED), - ); - } - ui.label(format!( - "Temperatura: {} / Clock: {}", - gpu.temperature_celsius - .value() - .map(|n| format!("{n:.0} °C")) - .unwrap_or_else(|| "N/D".into()), - gpu.clock_mhz + ), + mini_stat( + "TEMPERATURA", + &g.temperature_celsius + .value() + .map(|v| format!("{v:.0} °C")) + .unwrap_or("N/D".into()) + ), + mini_stat( + "POTENZA SCHEDA", + &watts(if s.gpus.len() == 1 { + gpu_power(s) + } else { + None + }) + ) + ] + .spacing(24) + ] + .spacing(18); + if g.memory_total_bytes.value().is_none() + || g.memory_used_bytes.value().is_none() + { + gpu = gpu.push( + text(format!( + "Memoria GPU: totale {}, allocata {}", + reading_reason(&g.memory_total_bytes), + reading_reason(&g.memory_used_bytes) + )) + .size(11) + .tone(MUTED), + ); + } + if g.memory_kind == GpuMemoryKind::Unified { + gpu=gpu.push(text("Memoria unificata: le allocazioni GPU condividono la RAM del nodo.").size(11).tone(MUTED)); + } + gpu = gpu.push( + text(format!( + "Clock: {} · Limitazioni: {}", + g.clock_mhz .value() - .map(|n| format!("{n} MHz")) - .unwrap_or_else(|| "N/D".into()) - )); - ui.label(format!( - "Limitazioni GPU: {}", - gpu.throttling.value().map(String::as_str).unwrap_or("N/D") - )); - }); + .map(|v| format!("{v} MHz")) + .unwrap_or("N/D".into()), + g.throttling.value().map(String::as_str).unwrap_or("N/D") + )) + .size(11) + .tone(MUTED), + ); + c = c.push(card(gpu)); } - ui.add_space(10.0); - ui.heading("Modelli caricati / Ollama"); + c = c.push(text("Modelli caricati · Ollama").size(17).font(STRONG)); match s.ollama.models.value() { Some(models) if models.is_empty() => { - ui.colored_label(GREEN, "Ollama disponibile / nessun modello caricato"); + c = c.push( + text("Runtime disponibile. Nessun modello caricato.") + .size(13) + .tone(GREEN), + ) } Some(models) => { - for model in models { - ui.label(format!( - "{} / {} / contesto {} / {}", - model.name, - bytes(Some(model.size_bytes)), - model - .context_length - .map(|n| n.to_string()) - .unwrap_or_else(|| "N/D".into()), - model.quantization.as_deref().unwrap_or("N/D") + for m in models { + c = c.push(card( + column![ + text(&m.name).size(16).font(STRONG), + text(format!( + "{} · contesto {} · {}", + bytes(Some(m.size_bytes)), + m.context_length + .map(|v| v.to_string()) + .unwrap_or("N/D".into()), + m.quantization.as_deref().unwrap_or("N/D") + )) + .size(12) + .tone(MUTED) + ] + .spacing(10), )); } } None => { - ui.label( - RichText::new("Ollama non raggiungibile su questo nodo.").color(MUTED), - ); + c = c.push( + text(format!("Ollama: {}", reading_reason(&s.ollama.models))) + .size(12) + .tone(MUTED), + ) } } - ui.label(RichText::new("Token/s, latenza e code: N/D senza telemetria delle richieste dal runtime.").color(MUTED).size(12.0)); - ui.add_space(12.0); - ui.heading("Processi AI riconosciuti dal nome"); - self.processes(ui, s, true); + c=c.push(text("Token/s, latenza e code: N/D senza telemetria delle richieste dal runtime.").size(11).tone(MUTED)).push(text("Processi AI riconosciuti dal nome").size(17).font(STRONG)).push(self.processes(s,true)); + c.into() } Tab::Disks => { + let mut c = Column::new().spacing(14); for d in &s.disks { - card(ui, |ui| { - ui.label(RichText::new(&d.mount).strong()); - ui.label(RichText::new(&d.name).color(MUTED).size(11.0)); - let percent = (d.total_bytes > 0).then(|| { - d.total_bytes.saturating_sub(d.available_bytes) as f64 - / d.total_bytes as f64 - * 100.0 - }); - meter(ui, "Uso", percent, CYAN); - ui.label(format!( - "{} disponibili / {} totali", - bytes(Some(d.available_bytes)), - bytes(Some(d.total_bytes)) - )); + let used = (d.total_bytes > 0).then(|| { + (d.total_bytes.saturating_sub(d.available_bytes)) as f64 + / d.total_bytes as f64 + * 100.0 }); - ui.add_space(8.0); + c = c.push(card( + column![ + text(&d.mount).size(17).font(STRONG), + text(&d.name).size(11).tone(MUTED), + metric( + Metric {label:"Spazio utilizzato".into(), value:used, detail:format!("{} disponibili / {} totali", bytes(Some(d.available_bytes)),bytes(Some(d.total_bytes))), provenance:format!("sysinfo filesystem · (totale − disponibile) / totale · {} ms Unix",d.sampled_at_unix_ms), stale:!n.live()}, + GREEN + ) + ] + .spacing(12), + )); } - ui.label(RichText::new("Capacità aggiornata ogni 5 s. Volumi APFS possono condividere lo stesso spazio.").color(MUTED)); + c.push(text("Capacità aggiornata ogni 5 s. I volumi APFS possono condividere lo stesso spazio.").size(11).tone(MUTED)).into() } Tab::Network => { - egui::Grid::new("networks") - .striped(true) - .spacing([26.0, 12.0]) - .show(ui, |ui| { - for label in ["INTERFACCIA", "RICEZIONE", "INVIO", "ERRORI TOTALI"] { - ui.label(RichText::new(label).color(MUTED).size(11.0)); - } - ui.end_row(); - for n in &s.networks { - ui.monospace(&n.name); - ui.monospace(format!( - "{}/s", - bytes(Some(n.received_bytes_per_second as u64)) - )); - ui.monospace(format!( - "{}/s", - bytes(Some(n.transmitted_bytes_per_second as u64)) - )); - ui.monospace(n.errors.to_string()); - ui.end_row(); - } - }); - ui.add_space(16.0); - ui.label(RichText::new("Interfacce fisiche, VPN e virtuali possono riportare lo stesso traffico: non vengono sommate.").color(MUTED)); + let mut c = column![table_row( + ["INTERFACCIA", "RICEZIONE", "INVIO", "ERRORI"], + true + )] + .spacing(12); + for net in &s.networks { + c = c.push(table_row( + [ + &net.name, + &format!("{}/s", bytes(Some(net.received_bytes_per_second as u64))), + &format!("{}/s", bytes(Some(net.transmitted_bytes_per_second as u64))), + &net.errors.to_string(), + ], + false, + )); + } + c=c.push(text("Interfacce fisiche, VPN e virtuali possono riportare lo stesso traffico: non vengono sommate.").size(11).tone(MUTED)); + card(c).into() } Tab::History => { - chart( - ui, - "CPU / ultimi 5 minuti", - &node.history, - |p| p.cpu_percent, - CYAN, - ); - chart( - ui, - "Memoria / ultimi 5 minuti", - &node.history, - |p| p.memory_percent, - GREEN, + let plots = card( + column![ + text("Utilizzo · ultimi 5 minuti").size(18).font(STRONG), + plot("CPU", &n.history, self.view_time_ms, 0, GREEN), + plot( + "Memoria", + &n.history, + self.view_time_ms, + 1, + Color::from_rgb(0.49, 0.58, 0.41) + ), + plot( + "GPU", + &n.history, + self.view_time_ms, + 2, + Color::from_rgb(0.51, 0.58, 0.64) + ), + text("SQLite locale · riepilogo ogni 5 s · conservazione 7 giorni") + .size(11) + .tone(MUTED) + ] + .spacing(20), ); - chart( - ui, - "GPU / ultimi 5 minuti", - &node.history, - |p| p.gpu_percent, - AMBER, - ); - ui.label(RichText::new("Storico SQLite: riepilogo ogni 5 s, conservazione 7 giorni. Vista attuale: ultimi 5 minuti.").color(MUTED)); + let mut health = column![ + text("Sistema").size(18).font(STRONG), + text(format!( + "Swap {} · uptime {:.1} h · raccolta {:.2} ms", + bytes(s.memory.swap_used_bytes.value().copied()), + s.uptime_seconds as f64 / 3600.0, + s.collector_duration_ms + )) + .size(12) + .tone(MUTED) + ] + .spacing(14); + for p in &s.pressure { + health = health.push( + text(format!( + "Pressione {} (stall avg10): {:.2}%", + p.resource, p.some_avg10_percent + )) + .size(12), + ); + } + for t in s.temperatures.iter().take(12) { + health = + health.push(text(format!("{}: {:.1} °C", t.label, t.celsius)).size(12)); + } + let mut cores = Row::new().spacing(8); + for (i, v) in s.cpu_cores_percent.iter().take(32).enumerate() { + cores = cores.push( + column![ + text(i.to_string()).size(8).tone(MUTED), + text(format!("{v:.0}")).size(10).font(MEDIUM) + ] + .spacing(6), + ); + } + health = health + .push(eyebrow("CARICO PER CORE · %")) + .push(cores.wrap()); + if let Some(b) = s.battery_percent.value() { + health = health.push(text(format!("Batteria {b:.0}%")).size(12)); + } + column![plots, card(health)].spacing(16).into() } } } - - fn processes(&mut self, ui: &mut egui::Ui, s: &HostSnapshot, only_ai: bool) { - ui.horizontal(|ui| { - ui.label("Filtro"); - ui.text_edit_singleline(&mut self.filter); - ui.selectable_value(&mut self.sort_memory, false, "CPU"); - ui.selectable_value(&mut self.sort_memory, true, "RAM"); - }); - ui.label(RichText::new(format!("{} processi sul nodo / sottoinsieme dei più attivi e candidati AI. CPU: 100% = un core.", s.process_count)).color(MUTED).size(11.0)); + fn processes<'a>(&'a self, s: &'a HostSnapshot, ai_only: bool) -> Element<'a, Message> { + let controls = row![ + text_input("Cerca processo…", &self.filter) + .id("process-filter") + .on_input(Message::Filter) + .size(12) + .width(260), + Space::new().width(Fill), + small_button( + if self.sort_memory { "CPU" } else { "CPU ↓" }, + Message::Sort(false) + ), + small_button( + if self.sort_memory { "RAM ↓" } else { "RAM" }, + Message::Sort(true) + ) + ] + .spacing(8) + .align_y(alignment::Vertical::Center); + let mut c=column![controls,text(format!("{} processi sul nodo · sottoinsieme più attivo e candidati AI · 100% CPU = un core",s.process_count)).size(10).tone(MUTED),table_row(["PROCESSO","PID","CPU","MEMORIA"],true)].spacing(14); let filter = self.filter.to_lowercase(); - let mut processes: Vec<_> = s + let mut ps: Vec<_> = s .processes .iter() - .filter(|p| (!only_ai || p.is_ai) && p.name.to_lowercase().contains(&filter)) + .filter(|p| (!ai_only || p.is_ai) && p.name.to_lowercase().contains(&filter)) .collect(); if self.sort_memory { - processes.sort_by_key(|p| std::cmp::Reverse(p.memory_bytes)); + ps.sort_by_key(|p| std::cmp::Reverse(p.memory_bytes)); + } else { + ps.sort_by(|a, b| b.cpu_percent.total_cmp(&a.cpu_percent)); } - egui::Grid::new(if only_ai { "ai-procs" } else { "processes" }) - .striped(true) - .spacing([18.0, 8.0]) - .show(ui, |ui| { - for label in ["PID", "PROCESSO", "CPU", "RAM"] { - ui.label(RichText::new(label).color(MUTED).size(11.0)); - } - ui.end_row(); - for p in processes { - ui.monospace(p.pid.to_string()); - ui.label(if p.name.len() > 70 { - p.name.chars().take(65).collect::() - } else { - p.name.clone() - }); - ui.monospace(format!("{:.1}%", p.cpu_percent)); - ui.monospace(bytes(Some(p.memory_bytes))); - ui.end_row(); - } - }); + if ps.is_empty() { + c = c.push( + text("Nessun processo corrispondente nel campione.") + .size(12) + .tone(MUTED), + ); + } + for p in ps { + c = c.push(table_row( + [ + &p.name, + &p.pid.to_string(), + &format!("{:.1}%", p.cpu_percent), + &bytes(Some(p.memory_bytes)), + ], + false, + )); + } + card(c).into() } } - -fn status(n: &NodeState) -> (&'static str, Color32) { - if matches!(n.config.kind, NodeKind::Unavailable) { - ("CENSITO / N/D", MUTED) - } else if n.live() { - ("LIVE", GREEN) - } else if n.sample.is_some() { - ("OBSOLETO", AMBER) - } else if n.error.is_some() { - ("NON RAGGIUNGIBILE", AMBER) +fn reading_reason(reading: &Reading) -> &str { + match reading { + Reading::Available { .. } => "disponibile", + Reading::Unavailable { reason } => crate::view_model::unavailable(*reason), + } +} +fn status(n: &NodeState) -> (&'static str, Color) { + let status = NodeStatus::of(n, Instant::now()); + let c = status.color(); + (status.label(), Color::from_rgb(c[0], c[1], c[2])) +} +fn panel(theme: &Theme) -> container::Style { + themed_panel( + theme, + container::Style { + background: Some(PAPER.into()), + border: Border { + color: LINE, + width: 1.0, + radius: 12.into(), + }, + ..Default::default() + }, + ) +} +fn card<'a>(content: impl Into>) -> widget::Container<'a, Message> { + container(content).padding(18).width(Fill).style(panel) +} +fn eyebrow(s: &str) -> widget::Text<'_> { + text(s).size(9).font(MEDIUM).tone(MUTED) +} +fn pill(s: &str, c: Color) -> Element<'static, Message> { + container(text(s.to_owned()).size(10).font(MEDIUM).tone(c)) + .padding([7, 10]) + .style(move |theme| { + themed_panel( + theme, + container::Style { + background: Some(Color { a: 0.08, ..c }.into()), + border: Border { + radius: 20.into(), + ..Default::default() + }, + ..Default::default() + }, + ) + }) + .into() +} +fn small_button(s: &str, message: Message) -> Element<'static, Message> { + button(text(s.to_owned()).size(11).font(MEDIUM)) + .on_press(message) + .padding([7, 10]) + .style(|theme, state| { + themed_button( + theme, + button::Style { + text_color: INK, + background: Some( + if matches!(state, button::Status::Hovered) { + LINE + } else { + Color::TRANSPARENT + } + .into(), + ), + border: Border { + radius: 7.into(), + ..Default::default() + }, + ..Default::default() + }, + ) + }) + .into() +} +fn notice(s: &str) -> Element<'static, Message> { + container(text(s.to_owned()).size(11).tone(AMBER)) + .padding(12) + .width(Fill) + .style(|theme| { + themed_panel( + theme, + container::Style { + background: Some(Color::from_rgb(0.98, 0.94, 0.86).into()), + border: Border { + radius: 8.into(), + ..Default::default() + }, + ..Default::default() + }, + ) + }) + .into() +} +fn metric<'a>(metric: Metric, c: Color) -> Element<'a, Message> { + let explanation = metric.explanation(); + let mut content = column![ + row![ + text(metric.label.clone()).size(12).font(MEDIUM), + Space::new().width(Fill), + text(metric.text()) + .size(19) + .font(STRONG) + .wrapping(text::Wrapping::None) + ] + .align_y(alignment::Vertical::Center), + ] + .spacing(7); + if let Some(value) = metric.value { + content = content.push(progress_bar(0.0..=100.0, value as f32).girth(4).style( + move |theme| progress_bar::Style { + background: tone(LINE, theme).into(), + bar: c.into(), + border: Border { + radius: 2.into(), + ..Default::default() + }, + }, + )); } else { - ("CONNESSIONE", MUTED) + content = content.push(text(metric.provenance.clone()).size(11).tone(MUTED)); } + if !metric.detail.is_empty() { + content = content.push(text(metric.detail).size(10).tone(MUTED)); + } + if metric.stale { + content = content.push(text("Ultima lettura · obsoleta").size(10).tone(AMBER)); + } + tooltip( + content.width(Fill), + container(text(explanation).size(12)) + .padding(12) + .style(panel), + tooltip::Position::Bottom, + ) + .into() } - -fn card(ui: &mut egui::Ui, body: impl FnOnce(&mut egui::Ui)) { - egui::Frame::new() - .fill(CARD) - .stroke(Stroke::new(1.0, BORDER)) - .corner_radius(10) - .inner_margin(16) - .show(ui, |ui| { - ui.set_min_width((ui.available_width() - 1.0).max(0.0)); - body(ui); - }); +fn mini_stat<'a>(label: &str, value: &str) -> Element<'a, Message> { + column![ + text(label.to_owned()).size(9).tone(MUTED), + text(value.to_owned()).size(19).font(MEDIUM) + ] + .spacing(10) + .width(Fill) + .into() } -fn stat(ui: &mut egui::Ui, title: &str, value: &str, foot: &str, color: Color32) { - card(ui, |ui| { - ui.label(RichText::new(title).color(MUTED).monospace().size(10.0)); - ui.label(RichText::new(value).size(28.0).color(color).strong()); - ui.label(RichText::new(foot).color(MUTED).size(10.5)); - }); +fn table_row(values: [&str; 4], heading: bool) -> Element<'static, Message> { + let mut r = Row::new().spacing(20).width(Fill); + for (i, v) in values.iter().enumerate() { + r = r.push( + text(v.to_string()) + .size(if heading { 9 } else { 12 }) + .tone(if heading { MUTED } else { INK }) + .width(if i == 0 { Length::FillPortion(3) } else { Fill }), + ); + } + container(r).padding([8, 0]).width(Fill).into() +} +fn pct(v: Option) -> String { + v.map(|v| format!("{v:.0}%")).unwrap_or("N/D".into()) } -fn pct(value: Option) -> String { - value - .map(|v| format!("{v:.0}%")) - .unwrap_or_else(|| "N/D".into()) +fn watts(v: Option) -> String { + v.map(|v| format!("{v:.1} W")).unwrap_or("N/D".into()) } -fn bytes(value: Option) -> String { - let Some(v) = value else { - return "N/D".into(); - }; +fn bytes(v: Option) -> String { + let Some(v) = v else { return "N/D".into() }; for (scale, unit) in [ (1u64 << 40, "TiB"), (1 << 30, "GiB"), @@ -706,70 +1142,231 @@ fn bytes(value: Option) -> String { } format!("{v} B") } -fn meter(ui: &mut egui::Ui, label: &str, value: Option, color: Color32) { - ui.horizontal(|ui| { - ui.label(RichText::new(label).size(11.0).color(MUTED)); - if let Some(value) = value { - ui.add( - egui::ProgressBar::new(value as f32 / 100.0) - .desired_width((ui.available_width() - 12.0).max(40.0)) - .fill(color) - .text(pct(Some(value))), - ); - } else { - ui.label(RichText::new("N/D").color(MUTED)); - } - }); +fn gpu_power(s: &HostSnapshot) -> Option { + let readings = s + .power + .iter() + .filter(|p| p.scope == PowerScope::GpuBoard) + .map(|p| p.watts.value().copied()) + .collect::>>()?; + (!readings.is_empty()).then(|| readings.iter().sum()) } -fn chart( - ui: &mut egui::Ui, +struct Trend { + points: Vec<(f32, Option)>, + color: Color, +} +fn plot<'a>( label: &str, points: &std::collections::VecDeque, - value: impl Fn(&HistoryPoint) -> Option, - color: Color32, -) { - card(ui, |ui| { - ui.label(RichText::new(label).color(MUTED).size(11.0)); - let (response, painter) = - ui.allocate_painter(Vec2::new(ui.available_width(), 100.0), egui::Sense::hover()); - let rect = response.rect.shrink(4.0); - for fraction in [0.0, 0.5, 1.0] { - let y = rect.bottom() - rect.height() * fraction; - painter.line_segment( - [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], - Stroke::new(1.0, BORDER), + end_ms: u64, + field: u8, + color: Color, +) -> Element<'a, Message> { + let since = end_ms.saturating_sub(300_000); + let data: Vec<_> = points + .iter() + .filter(|p| p.received_at_ms >= since) + .map(|p| { + ( + ((p.received_at_ms - since) as f32 / 300_000.0), + match field { + 0 => p.cpu_percent, + 1 => p.memory_percent, + _ => p.gpu_percent, + }, + ) + }) + .collect(); + let last = data.last().and_then(|p| p.1); + column![ + row![ + text(label.to_owned()).size(11).tone(MUTED), + Space::new().width(Fill), + text(pct(last)) + .size(15) + .font(MEDIUM) + .wrapping(text::Wrapping::None) + ], + canvas(Trend { + points: data, + color + }) + .width(Fill) + .height(65) + ] + .spacing(10) + .width(Fill) + .into() +} +impl canvas::Program for Trend { + type State = (); + fn draw( + &self, + _: &(), + renderer: &iced::Renderer, + theme: &Theme, + bounds: iced::Rectangle, + _: iced::mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + for y in [0.0, 0.5, 1.0] { + frame.stroke( + &canvas::Path::line( + iced::Point::new(0.0, y * bounds.height), + iced::Point::new(bounds.width, y * bounds.height), + ), + canvas::Stroke::default() + .with_color(tone(LINE, theme)) + .with_width(0.5), ); } - let now = now_ms(); - let since = now.saturating_sub(300_000); - let mut previous = None; - for point in points.iter().filter(|p| p.received_at_ms >= since) { - if let Some(v) = value(point) { - let pos = egui::pos2( - rect.left() - + rect.width() - * ((point.received_at_ms.saturating_sub(since)) as f32 / 300_000.0) - .clamp(0.0, 1.0), - rect.bottom() - rect.height() * (v as f32 / 100.0).clamp(0.0, 1.0), + let mut previous: Option = None; + for (x, v) in &self.points { + if let Some(v) = v { + let p = iced::Point::new( + *x * bounds.width, + (1.0 - (*v as f32 / 100.0).clamp(0.0, 1.0)) * bounds.height, ); - if let Some((old_ts, old_pos)) = previous - && point.received_at_ms.saturating_sub(old_ts) < 15_000 + if let Some(prev) = previous + && p.x > prev.x + && p.x - prev.x < bounds.width * 0.025 { - painter.line_segment([old_pos, pos], Stroke::new(1.8, color)); + frame.stroke( + &canvas::Path::line(prev, p), + canvas::Stroke::default() + .with_color(self.color) + .with_width(1.8), + ); } - previous = Some((point.received_at_ms, pos)); + previous = Some(p); } else { previous = None; } } - if points.iter().all(|p| value(p).is_none()) { - painter.text( - rect.center(), - egui::Align2::CENTER_CENTER, - "Nessuna misura disponibile", - egui::FontId::proportional(12.0), - MUTED, + vec![frame.into_geometry()] + } +} +struct Icon(&'static str, Color); +impl canvas::Program for Icon { + type State = (); + fn draw( + &self, + _: &(), + renderer: &iced::Renderer, + theme: &Theme, + bounds: iced::Rectangle, + _: iced::mouse::Cursor, + ) -> Vec { + let mut f = canvas::Frame::new(renderer, bounds.size()); + let s = bounds.width / 24.0; + let stroke = canvas::Stroke::default() + .with_width(1.5 * s) + .with_color(tone(self.1, theme)); + let p = canvas::Path::new(|p| { + let mut line = |a: (f32, f32), b: (f32, f32)| { + p.move_to(iced::Point::new(a.0 * s, a.1 * s)); + p.line_to(iced::Point::new(b.0 * s, b.1 * s)); + }; + match self.0 { + "overview" => { + line((3., 4.), (9., 4.)); + line((3., 4.), (3., 10.)); + line((3., 10.), (9., 10.)); + line((9., 4.), (9., 10.)); + line((15., 4.), (21., 4.)); + line((15., 4.), (15., 16.)); + line((15., 16.), (21., 16.)); + line((21., 4.), (21., 16.)); + line((3., 16.), (9., 16.)); + line((3., 16.), (3., 21.)); + line((3., 21.), (9., 21.)); + line((9., 16.), (9., 21.)); + } + "ai" => { + line((12., 2.), (15., 9.)); + line((15., 9.), (22., 12.)); + line((22., 12.), (15., 15.)); + line((15., 15.), (12., 22.)); + line((12., 22.), (9., 15.)); + line((9., 15.), (2., 12.)); + line((2., 12.), (9., 9.)); + line((9., 9.), (12., 2.)); + } + "process" => { + line((3., 18.), (7., 18.)); + line((7., 18.), (10., 5.)); + line((10., 5.), (14., 20.)); + line((14., 20.), (17., 11.)); + line((17., 11.), (21., 11.)); + } + "network" => { + line((12., 4.), (12., 12.)); + line((4., 12.), (20., 12.)); + line((4., 12.), (4., 20.)); + line((20., 12.), (20., 20.)); + line((12., 12.), (12., 20.)); + } + "disk" => { + line((4., 5.), (20., 5.)); + line((20., 5.), (20., 20.)); + line((20., 20.), (4., 20.)); + line((4., 20.), (4., 5.)); + line((4., 15.), (20., 15.)); + line((15., 18.), (17., 18.)); + } + "energy" => { + line((14., 2.), (5., 14.)); + line((5., 14.), (11., 14.)); + line((11., 14.), (10., 22.)); + line((10., 22.), (20., 10.)); + line((20., 10.), (13., 10.)); + line((13., 10.), (14., 2.)); + } + "events" => { + line((4., 5.), (20., 5.)); + line((20., 5.), (20., 18.)); + line((20., 18.), (4., 18.)); + line((4., 18.), (4., 5.)); + line((8., 9.), (16., 9.)); + line((8., 13.), (13., 13.)); + line((8., 21.), (16., 21.)); + } + "settings" => { + line((3., 6.), (21., 6.)); + line((3., 12.), (21., 12.)); + line((3., 18.), (21., 18.)); + line((8., 3.), (8., 9.)); + line((16., 9.), (16., 15.)); + line((10., 15.), (10., 21.)); + } + "history" => { + line((3., 18.), (7., 14.)); + line((7., 14.), (11., 16.)); + line((11., 16.), (15., 6.)); + line((15., 6.), (21., 9.)); + } + _ => (), + } + }); + if self.0 == "moon" { + f.fill( + &canvas::Path::circle(iced::Point::new(12. * s, 12. * s), 10. * s), + self.1, + ); + f.fill( + &canvas::Path::circle(iced::Point::new(17. * s, 8. * s), 8. * s), + NAV, ); + } else { + f.stroke(&p, stroke); } - }); + vec![f.into_geometry()] + } } + +mod chart; +mod workbench; +use workbench::SECTIONS; +pub use workbench::Section; +#[cfg(test)] +mod qa; diff --git a/crates/moonitor-desktop/src/ui/chart.rs b/crates/moonitor-desktop/src/ui/chart.rs new file mode 100644 index 0000000..7705d36 --- /dev/null +++ b/crates/moonitor-desktop/src/ui/chart.rs @@ -0,0 +1,238 @@ +//! Shared resource chart. Receipt time, bounded window, explicit gaps and inspection. +use super::*; +use iced::{Point, Rectangle, mouse}; +const WINDOW: u64 = 300_000; +const GAP: u64 = 7_500; +pub(super) const SERIES: [Color; 3] = [ + GREEN, + Color::from_rgb(0.62, 0.48, 0.31), + Color::from_rgb(0.35, 0.55, 0.71), +]; +pub(super) struct ResourceChart { + points: Vec, + end: u64, +} +impl ResourceChart { + pub(super) fn new(points: &std::collections::VecDeque, end: u64) -> Self { + Self { + points: points + .iter() + .filter(|p| { + p.received_at_ms >= end.saturating_sub(WINDOW) && p.received_at_ms <= end + }) + .cloned() + .collect(), + end, + } + } + fn nearest(&self, at: u64) -> Option<&HistoryPoint> { + self.points + .iter() + .min_by_key(|p| p.received_at_ms.abs_diff(at)) + .filter(|p| p.received_at_ms.abs_diff(at) <= GAP / 2) + } +} +fn value(p: &HistoryPoint, field: usize) -> Option { + [p.cpu_percent, p.memory_percent, p.gpu_percent][field] + .filter(|v| v.is_finite() && (0.0..=100.0).contains(v)) +} +fn connected(a: &HistoryPoint, b: &HistoryPoint, field: usize) -> bool { + b.received_at_ms > a.received_at_ms + && b.received_at_ms - a.received_at_ms <= GAP + && value(a, field).is_some() + && value(b, field).is_some() +} +impl canvas::Program for ResourceChart { + type State = Option; + fn update( + &self, + state: &mut Self::State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + if matches!( + event, + iced::Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft) + ) { + let next = cursor.position_in(bounds); + if *state != next { + *state = next; + return Some(widget::Action::request_redraw()); + } + } + None + } + fn draw( + &self, + state: &Self::State, + renderer: &iced::Renderer, + theme: &Theme, + bounds: Rectangle, + _: mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let left = 33.0; + let top = 9.0; + let width = (bounds.width - left - 8.0).max(1.0); + let height = (bounds.height - top - 24.0).max(1.0); + let start = self.end.saturating_sub(WINDOW); + for v in [0.0, 50.0, 100.0] { + let y = top + height * (1.0 - v / 100.0); + frame.stroke( + &canvas::Path::line(Point::new(left, y), Point::new(left + width, y)), + canvas::Stroke::default() + .with_width(0.5) + .with_color(tone(LINE, theme)), + ); + frame.fill_text(canvas::Text { + content: format!("{v:.0}"), + position: Point::new(left - 8.0, y), + color: tone(MUTED, theme), + size: 10.0.into(), + font: FONT, + align_x: alignment::Horizontal::Right.into(), + align_y: alignment::Vertical::Center, + ..Default::default() + }); + } + for (fraction, label, align) in [ + (0.0, "−5 min", alignment::Horizontal::Left), + (0.5, "−2 min 30 s", alignment::Horizontal::Center), + (1.0, "Ora", alignment::Horizontal::Right), + ] { + frame.fill_text(canvas::Text { + content: label.into(), + position: Point::new(left + width * fraction, top + height + 9.0), + color: tone(MUTED, theme), + size: 10.0.into(), + font: FONT, + align_x: align.into(), + ..Default::default() + }); + } + for (field, color) in SERIES.into_iter().enumerate() { + let point = |p: &HistoryPoint| { + Point::new( + left + width * p.received_at_ms.saturating_sub(start) as f32 / WINDOW as f32, + top + height * (1.0 - value(p, field).unwrap_or(0.0) as f32 / 100.0), + ) + }; + for pair in self.points.windows(2) { + if connected(&pair[0], &pair[1], field) { + frame.stroke( + &canvas::Path::line(point(&pair[0]), point(&pair[1])), + canvas::Stroke::default().with_width(1.8).with_color(color), + ); + } + } + if let Some(last) = self.points.last().filter(|p| value(p, field).is_some()) { + frame.fill(&canvas::Path::circle(point(last), 2.0), color); + } + } + if self.points.is_empty() { + frame.fill_text(canvas::Text { + content: "In attesa di campioni nella finestra".into(), + position: Point::new(left + width / 2.0, top + height / 2.0), + color: tone(MUTED, theme), + size: 11.0.into(), + font: FONT, + align_x: alignment::Horizontal::Center.into(), + ..Default::default() + }); + } + if let Some(cursor) = state + .filter(|p| p.x >= left && p.x <= left + width && p.y >= top && p.y <= top + height) + { + let at = start + (((cursor.x - left) / width) * WINDOW as f32) as u64; + frame.stroke( + &canvas::Path::line( + Point::new(cursor.x, top), + Point::new(cursor.x, top + height), + ), + canvas::Stroke::default() + .with_width(1.0) + .with_color(tone(MUTED, theme)), + ); + let label = if let Some(p) = self.nearest(at) { + let secs = p.received_at_ms / 1000; + format!( + "{:02}:{:02}:{:02} UTC · CPU {} RAM {} GPU {}", + secs / 3600 % 24, + secs / 60 % 60, + secs % 60, + pct(p.cpu_percent), + pct(p.memory_percent), + pct(p.gpu_percent) + ) + } else { + "Nessun campione in questo intervallo".into() + }; + let box_width = (label.chars().count() as f32 * 5.3 + 16.0).min(bounds.width); + let x = (cursor.x - box_width / 2.0).clamp(0.0, (bounds.width - box_width).max(0.0)); + let y = top + 3.0; + frame.fill_rectangle( + Point::new(x, y), + iced::Size::new(box_width, 24.0), + tone(PAPER, theme), + ); + frame.fill_text(canvas::Text { + content: label, + position: Point::new(x + 8.0, y + 6.0), + color: tone(INK, theme), + size: 10.0.into(), + font: FONT, + ..Default::default() + }); + } + vec![frame.into_geometry()] + } + fn mouse_interaction( + &self, + _: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Crosshair + } else { + mouse::Interaction::default() + } + } +} +#[cfg(test)] +mod tests { + use super::*; + fn point(t: u64, v: Option) -> HistoryPoint { + HistoryPoint { + received_at_ms: t, + cpu_percent: v, + memory_percent: v, + gpu_percent: v, + } + } + #[test] + fn gaps_missing_values_and_reversed_time_are_never_connected() { + let zero = point(10_000, Some(0.0)); + assert!(connected(&zero, &point(15_000, Some(10.0)), 0)); + for other in [ + point(15_000, None), + point(20_000, Some(0.0)), + point(5_000, Some(1.0)), + point(10_000, Some(1.0)), + point(15_000, Some(f64::NAN)), + ] { + assert!(!connected(&zero, &other, 0)); + } + } + #[test] + fn inspection_does_not_fill_gaps_or_include_future_values() { + let chart = ResourceChart::new( + &[point(10_000, Some(0.0)), point(30_000, Some(100.0))].into(), + 20_000, + ); + assert_eq!(chart.nearest(10_000).unwrap().cpu_percent, Some(0.0)); + assert!(chart.nearest(20_000).is_none()); + assert_eq!(chart.points.len(), 1); + } +} diff --git a/crates/moonitor-desktop/src/ui/qa.rs b/crates/moonitor-desktop/src/ui/qa.rs new file mode 100644 index 0000000..865834b --- /dev/null +++ b/crates/moonitor-desktop/src/ui/qa.rs @@ -0,0 +1,374 @@ +use super::*; +use iced_test::Simulator; +fn app(count: usize, tab: Tab) -> App { + App { + _engine: None, + nodes: crate::fixtures::fleet(count), + storage_error: None, + dropped_events: 0, + pending_samples: Vec::new(), + selected: (count > 0).then(|| "qa-0".into()), + scene_visible: true, + dark: false, + tab, + section: if tab == Tab::Overview { + Section::Overview + } else { + Section::Device + }, + filter: String::new(), + sort_memory: false, + camera: Camera::default(), + started: Instant::now(), + view_time_ms: crate::fixtures::TIME_MS, + screenshot: None, + exit_after: None, + window: None, + } +} +fn settings() -> iced::Settings { + iced::Settings { + default_font: FONT, + fonts: vec![ + include_bytes!("../../assets/Inter-Regular.ttf") + .as_slice() + .into(), + include_bytes!("../../assets/Inter-Medium.ttf") + .as_slice() + .into(), + include_bytes!("../../assets/Inter-SemiBold.ttf") + .as_slice() + .into(), + ], + ..Default::default() + } +} +fn theme() -> Theme { + app_theme(false) +} +#[test] +fn selection_messages_do_not_follow_indices() { + let mut app = app(5, Tab::Overview); + let _ = app.update(Message::Select("qa-3".into())); + app.nodes.reverse(); + let _ = app.update(Message::Tick); + assert_eq!(app.selected.as_deref(), Some("qa-3")); + app.nodes.truncate(1); + let _ = app.update(Message::Tick); + assert_eq!(app.selected.as_deref(), Some("qa-4")); + let _ = app.update(Message::Select("missing".into())); + assert_eq!(app.selected.as_deref(), Some("qa-4")); + app.nodes.clear(); + let _ = app.update(Message::Tick); + assert!(app.selected.is_none()); +} +#[test] +fn switching_context_keeps_the_page_and_absent_data_is_not_stale() { + let mut app = app(5, Tab::Overview); + let _ = app.update(Message::SwitchNode("qa-4".into())); + assert_eq!(app.section, Section::Overview); + assert_eq!(app.selected.as_deref(), Some("qa-4")); + assert!(!Metric::percent("CPU", None, true).stale); + assert!(Metric::memory(&crate::fixtures::sample("qa-0"), true).stale); +} +#[test] +#[ignore = "Requires a graphical backend. Run scripts/qa-ui.sh."] +fn visual_matrix_and_interactions() { + let dir = + std::env::var("MOONITOR_QA_DIR").expect("Explicit ignored artifact directory required"); + let dir = std::path::Path::new(&dir); + std::fs::create_dir_all(dir).unwrap(); + let case = std::env::var("MOONITOR_QA_CASE").ok(); + let mut covered = 0; + for (width, height) in [(1280, 800), (1440, 900), (3840, 2160)] { + for (name, section, tab) in [ + ("overview", Section::Overview, Tab::Overview), + ("fleet", Section::Fleet, Tab::Overview), + ("workloads", Section::Workloads, Tab::Overview), + ("energy", Section::Energy, Tab::Overview), + ("events", Section::Events, Tab::Overview), + ("explore", Section::Explore, Tab::Overview), + ("settings", Section::Settings, Tab::Overview), + ("ai", Section::Device, Tab::Ai), + ("history", Section::Device, Tab::History), + ("processes", Section::Device, Tab::Processes), + ] { + for dark in [false, true] { + let case_name = format!( + "{name}-{width}x{height}-{}", + if dark { "dark" } else { "light" } + ); + if case.as_ref().is_some_and(|c| c != &case_name) { + continue; + } + covered += 1; + let mut app = app(5, tab); + app.dark = dark; + app.section = section; + let mut ui = + Simulator::with_size(settings(), (width as f32, height as f32), app.view()); + let target = ui.find(section.label()).unwrap(); + assert!(target.visible_bounds().is_some()); + if section == Section::Overview { + for label in [ + "Resource timeline", + "Lab Map", + "Modelli osservati", + "0%", + "37%", + ] { + assert!( + ui.find(label).unwrap().visible_bounds().is_some(), + "{label} below fold in {case_name}" + ); + } + } + if section == Section::Fleet { + let header_x = ui.find("STATO").unwrap().visible_bounds().unwrap().x; + let value_x = ui.find("Online").unwrap().visible_bounds().unwrap().x; + assert!( + (header_x - value_x).abs() <= 12.0, + "Fleet columns misaligned: {header_x} vs {value_x}" + ); + } + let snapshot = ui.snapshot(&app_theme(dark)).unwrap(); + let path = dir.join(format!( + "fixture-{name}-{width}x{height}-{}", + if dark { "dark" } else { "light" } + )); + assert!( + snapshot.matches_image(&path).unwrap(), + "Visual diff: {}", + path.display() + ); + // A second render must be identical with frozen observations. + assert!( + ui.snapshot(&app_theme(dark)) + .unwrap() + .matches_image(&path) + .unwrap() + ); + } + } + } + if let Some(case) = case + && case != "fleet" + { + assert_eq!(covered, 1, "Unknown QA case {case}"); + return; + } + for count in [0, 1, 2, 16, 32] { + let app = app(count, Tab::Overview); + let mut ui = Simulator::with_size(settings(), (1440.0, 900.0), app.view()); + assert!( + ui.snapshot(&theme()) + .unwrap() + .matches_image(dir.join(format!("fixture-fleet-{count}"))) + .unwrap() + ); + } + let mut app = app(5, Tab::Overview); + let mut ui = Simulator::with_size(settings(), (1440.0, 900.0), app.view()); + ui.click("Fleet").unwrap(); + for message in ui.into_messages() { + let _ = app.update(message); + } + assert_eq!(app.section, Section::Fleet); + let mut ui = Simulator::with_size(settings(), (1440.0, 900.0), app.view()); + ui.click("QA device 4").unwrap(); + for message in ui.into_messages() { + let _ = app.update(message); + } + assert_eq!(app.selected.as_deref(), Some("qa-4")); + assert_eq!(app.section, Section::Device); + let mut ui = Simulator::with_size(settings(), (1440.0, 900.0), app.view()); + ui.click("AI locale").unwrap(); + for message in ui.into_messages() { + let _ = app.update(message); + } + assert_eq!(app.tab, Tab::Ai); + let mut ui = Simulator::with_size(settings(), (1440.0, 900.0), app.view()); + assert!(ui.find("Non strumentato").is_ok()); + ui.click("Vai alla flotta").unwrap(); + for message in ui.into_messages() { + let _ = app.update(message); + } + assert_eq!(app.section, Section::Fleet); + for section in SECTIONS { + let mut ui = Simulator::with_size(settings(), (1440.0, 900.0), app.view()); + ui.click(section.label()).unwrap(); + for message in ui.into_messages() { + let _ = app.update(message); + } + assert_eq!(app.section, section); + } +} +#[test] +#[ignore = "Requires a graphical backend. Run scripts/qa-ui.sh."] +fn metric_rendering() { + let dir = std::env::var("MOONITOR_QA_DIR").unwrap(); + for value in [0.0, 1.0, 7.0, 36.0, 37.0, 99.0, 100.0] { + let reading = Reading::reported(value, "fixture", 100); + let m = Metric::percent("CPU", Some(&reading), false); + let mut ui = Simulator::with_size( + settings(), + (286.0, 100.0), + container(metric(m, GREEN)).padding(22).style(panel), + ); + let label = format!("{value:.0}%"); + let value_bounds = ui.find(label.as_str()).unwrap(); + assert!(value_bounds.visible_bounds().is_some()); + assert!( + ui.snapshot(&theme()) + .unwrap() + .matches_image(std::path::Path::new(&dir).join(format!("metric-{value}"))) + .unwrap() + ); + } +} + +#[test] +#[ignore = "Requires a graphical backend. Run scripts/qa-ui.sh."] +fn changing_metrics_keep_visible_glyphs() { + use iced_test::core::renderer::Headless; + use iced_test::runtime::{UserInterface, user_interface}; + for font in settings().fonts { + iced_test::renderer::graphics::text::font_system() + .write() + .unwrap() + .load_font(font); + } + let mut renderer = iced_test::futures::futures::executor::block_on( + ::new(FONT, 16.0.into(), None), + ) + .unwrap(); + let mut cache = user_interface::Cache::default(); + let dir = std::path::PathBuf::from(std::env::var("MOONITOR_QA_DIR").unwrap()); + std::fs::create_dir_all(&dir).unwrap(); + for (i, value) in [0.0, 37.0, 7.0, 0.0, 100.0, 1.0, 99.0, 8.0, 10.0, 50.0] + .into_iter() + .enumerate() + { + let reading = Reading::reported(value, "fixture", 100 + i as u64); + let element = container(metric(Metric::percent("CPU", Some(&reading), false), GREEN)) + .padding(22) + .style(panel); + let mut ui = + UserInterface::build(element, iced::Size::new(286.0, 100.0), cache, &mut renderer); + ui.draw( + &mut renderer, + &theme(), + &iced_test::core::renderer::Style { text_color: INK }, + iced::mouse::Cursor::Unavailable, + ); + let rgba = Headless::screenshot(&mut renderer, iced::Size::new(572, 200), 2.0, BG); + // Inspect the numeric value region, independently of widget selection or bar pixels. + let ink = (400..535) + .flat_map(|x| (44..98).map(move |y| (x, y))) + .filter(|(x, y)| { + let at = (y * 572 + x) * 4; + rgba[at] < 100 && rgba[at + 1] < 120 && rgba[at + 2] < 120 + }) + .count(); + image::save_buffer( + dir.join(format!("dynamic-{i}-{value}.png")), + &rgba, + 572, + 200, + image::ColorType::Rgba8, + ) + .unwrap(); + assert!( + ink > 30, + "Value {value} lost visible glyphs after update {i}: {ink} ink pixels" + ); + cache = ui.into_cache(); + } +} + +#[test] +#[ignore = "Requires a graphical backend. Run scripts/qa-ui.sh."] +fn scene_picking_filter_keyboard_and_scroll() { + let nodes = crate::fixtures::fleet(2); + let fleet = FleetViewModel::new(&nodes, None, Instant::now()); + let scene = Scene { + selected: None, + devices: fleet.devices, + camera: Camera::default(), + dark: false, + }; + let size = iced::Size::new(800.0, 400.0); + for index in 0..2 { + let mut ui: Simulator<'_, Message> = Simulator::with_size( + settings(), + size, + widget::shader(scene.clone()).width(Fill).height(Fill), + ); + ui.point_at(scene.test_device_point(index, size)); + ui.simulate(iced_test::simulator::click()); + assert!( + ui.into_messages() + .any(|m| matches!(m,Message::Select(id) if id==format!("qa-{index}"))) + ); + } + let mut app = app(5, Tab::Processes); + let mut ui = Simulator::with_size(settings(), (1280.0, 800.0), app.view()); + ui.click(widget::Id::new("process-filter")).unwrap(); + ui.typewrite("missing"); + ui.tap_key(iced::keyboard::key::Named::Backspace); + for m in ui.into_messages() { + let _ = app.update(m); + } + assert_eq!(app.filter, "missin"); + let mut ui = Simulator::with_size(settings(), (1280.0, 800.0), app.view()); + assert!( + ui.find("Nessun processo corrispondente nel campione.") + .is_ok() + ); + ui.point_at((900.0, 600.0)); + ui.simulate([iced::Event::Mouse(iced::mouse::Event::WheelScrolled { + delta: iced::mouse::ScrollDelta::Lines { x: 0.0, y: -10.0 }, + })]); + assert!(ui.snapshot(&theme()).is_ok()); + drop(ui); + let _ = app.update(Message::NextNode(1)); + assert_eq!(app.selected.as_deref(), Some("qa-1")); + let _ = app.update(Message::NextNode(-1)); + assert_eq!(app.selected.as_deref(), Some("qa-0")); +} + +#[test] +#[ignore = "Requires a graphical backend. Run scripts/qa-ui.sh."] +fn list_alternative_selection_and_theme() { + let dir = std::path::PathBuf::from(std::env::var("MOONITOR_QA_DIR").unwrap()); + std::fs::create_dir_all(&dir).unwrap(); + for dark in [false, true] { + let mut app = app(5, Tab::Overview); + app.dark = dark; + app.scene_visible = false; + let mut ui = Simulator::with_size(settings(), (1280.0, 800.0), app.view()); + let path = dir.join(if dark { + "fixture-list-dark" + } else { + "fixture-list-light" + }); + assert!( + ui.snapshot(&app_theme(dark)) + .unwrap() + .matches_image(&path) + .unwrap() + ); + assert!( + ui.snapshot(&app_theme(dark)) + .unwrap() + .matches_image(&path) + .unwrap() + ); + ui.click("QA device 4").unwrap(); + for message in ui.into_messages() { + let _ = app.update(message); + } + assert_eq!(app.selected.as_deref(), Some("qa-4")); + assert_eq!(app.section, Section::Device); + } +} diff --git a/crates/moonitor-desktop/src/ui/workbench.rs b/crates/moonitor-desktop/src/ui/workbench.rs new file mode 100644 index 0000000..a864430 --- /dev/null +++ b/crates/moonitor-desktop/src/ui/workbench.rs @@ -0,0 +1,760 @@ +//! W1 shell: operational questions, shared node state, no synthetic production data. +use super::*; +use iced::widget::column; +use iced::widget::pick_list; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Section { + Overview, + Fleet, + Workloads, + Energy, + Events, + Explore, + Settings, + Device, +} +pub(super) const SECTIONS: [Section; 7] = [ + Section::Overview, + Section::Fleet, + Section::Workloads, + Section::Energy, + Section::Events, + Section::Explore, + Section::Settings, +]; +impl Section { + pub fn parse(value: &str) -> Option { + SECTIONS.into_iter().chain([Self::Device]).find(|s| { + s.label().eq_ignore_ascii_case(value) || (*s == Self::Device && value == "device") + }) + } + pub(super) fn label(self) -> &'static str { + match self { + Self::Overview => "Overview", + Self::Fleet => "Fleet", + Self::Workloads => "Workloads", + Self::Energy => "Energy", + Self::Events => "Events", + Self::Explore => "Explore", + Self::Settings => "Settings", + Self::Device => "Dispositivo", + } + } + fn icon(self) -> &'static str { + match self { + Self::Overview => "overview", + Self::Fleet => "network", + Self::Workloads => "ai", + Self::Energy => "energy", + Self::Events => "events", + Self::Explore => "history", + Self::Settings => "settings", + Self::Device => "overview", + } + } + fn subtitle(self) -> &'static str { + match self { + Self::Overview => "Il laboratorio, adesso.", + Self::Fleet => "Nodi, capacità e disponibilità.", + Self::Workloads => "Runtime e modelli osservati nel laboratorio.", + Self::Energy => "Potenza misurata, con confini espliciti.", + Self::Events => "Cambiamenti e qualità delle sorgenti.", + Self::Explore => "Andamento recente delle risorse.", + Self::Settings => "Sorgenti e preferenze di visualizzazione.", + Self::Device => "Telemetria e risorse del nodo selezionato.", + } + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +struct NodeChoice { + id: String, + label: String, +} +impl std::fmt::Display for NodeChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.label) + } +} +impl App { + pub(super) fn workbench_view(&self) -> Element<'_, Message> { + let fleet = FleetViewModel::new(&self.nodes, self.selected.as_deref(), Instant::now()); + let node = self + .nodes + .iter() + .find(|n| Some(n.config.id.as_str()) == fleet.selected_id.as_deref()); + let choices = self + .nodes + .iter() + .map(|n| NodeChoice { + id: n.config.id.clone(), + label: n.config.label.clone(), + }) + .collect::>(); + let selected = choices + .iter() + .find(|n| Some(n.id.as_str()) == fleet.selected_id.as_deref()) + .cloned(); + let switcher = pick_list(choices, selected, |n| Message::SwitchNode(n.id)) + .placeholder("Seleziona nodo") + .text_size(12) + .padding([9, 12]) + .width(190) + .style(|theme, state| { + let mut style = pick_list::default(theme, state); + style.background = tone(PAPER, theme).into(); + style.border.color = tone(LINE, theme); + style.border.radius = 7.0.into(); + style + }); + let top = row![ + column![ + text(self.section.label()).size(26).font(STRONG), + text(self.section.subtitle()).size(12).tone(MUTED) + ] + .spacing(5), + Space::new().width(Fill), + pill("LIVE · 1 s", GREEN), + switcher + ] + .spacing(16) + .align_y(alignment::Vertical::Center); + let content = match self.section { + Section::Overview => self.overview(&fleet, node), + Section::Fleet => self.fleet(&fleet), + Section::Workloads => self.workloads(), + Section::Energy => self.energy(), + Section::Events => self.events(), + Section::Explore => node.map(|n| self.explore(n)).unwrap_or_else(empty_fleet), + Section::Settings => self.settings(), + Section::Device => node.map(|n| self.device(n)).unwrap_or_else(empty_fleet), + }; + let mut body = column![content].spacing(18); + if let Some(error) = &self.storage_error { + body = body.push(notice(&format!("Storico: {error}"))); + } + if self.dropped_events > 0 { + body = body.push(notice(&format!( + "Acquisizione: {} aggiornamenti scartati per coda piena", + self.dropped_events + ))); + } + container(row![ + self.workbench_sidebar(), + column![ + container(top).padding([24, 26]), + scrollable( + container(body) + .padding(iced::Padding { + top: 0.0, + right: 26.0, + bottom: 24.0, + left: 26.0 + }) + .width(Fill) + ) + .height(Fill) + ] + .height(Fill) + .width(Fill) + ]) + .width(Fill) + .height(Fill) + .style(|theme| { + themed_panel( + theme, + container::Style { + background: Some(BG.into()), + text_color: Some(INK), + ..Default::default() + }, + ) + }) + .into() + } + fn workbench_sidebar(&self) -> Element<'_, Message> { + let brand = row![ + brand_icon(), + text("moonitor").size(22).font(STRONG).color(Color::WHITE) + ] + .spacing(9) + .align_y(alignment::Vertical::Center); + let mut nav = column![ + brand, + text("COMPUTE OBSERVATORY") + .size(9) + .color(Color::from_rgb(0.58, 0.68, 0.61)), + Space::new().height(26) + ] + .spacing(14); + for section in SECTIONS { + let selected = self.section == section + || (self.section == Section::Device && section == Section::Fleet); + let color = if selected { + LIME + } else { + Color::from_rgb(0.72, 0.79, 0.74) + }; + nav = nav.push( + button( + row![ + canvas(Icon(section.icon(), color)).width(18).height(18), + text(section.label()).size(13).font(MEDIUM) + ] + .spacing(12) + .align_y(alignment::Vertical::Center), + ) + .width(Fill) + .padding([12, 13]) + .on_press(Message::Navigate(section)) + .style(move |_, state| button::Style { + background: Some( + if selected { + Color::from_rgb(0.17, 0.25, 0.19) + } else if matches!(state, button::Status::Hovered) { + Color::from_rgb(0.12, 0.18, 0.14) + } else { + Color::TRANSPARENT + } + .into(), + ), + text_color: color, + border: Border { + radius: 9.into(), + ..Default::default() + }, + ..Default::default() + }), + ); + } + let monitored = self + .nodes + .iter() + .filter(|n| !matches!(n.config.kind, crate::config::NodeKind::Unavailable)) + .count(); + let footer = column![ + text("RACCOLTA LOCALE") + .size(9) + .color(Color::from_rgb(0.55, 0.65, 0.58)), + text(format!("{monitored} nodi strumentati")) + .size(12) + .color(Color::from_rgb(0.80, 0.86, 0.81)), + text("SSH · archivio sul dispositivo") + .size(10) + .color(Color::from_rgb(0.55, 0.65, 0.58)), + text("Moonitor / Native workbench") + .size(9) + .color(Color::from_rgb(0.45, 0.55, 0.48)) + ] + .spacing(9); + container(column![nav, Space::new().height(Fill), footer].spacing(18)) + .padding([28, 20]) + .width(220) + .height(Fill) + .style(|_| container::Style { + background: Some(NAV.into()), + ..Default::default() + }) + .into() + } + fn overview<'a>( + &'a self, + fleet: &FleetViewModel, + node: Option<&'a NodeState>, + ) -> Element<'a, Message> { + let observed_runtime = self + .nodes + .iter() + .filter(|n| n.live()) + .filter_map(|n| n.sample.as_ref()) + .filter(|s| s.ollama.models.value().is_some()) + .count(); + let models = self + .nodes + .iter() + .filter(|n| n.live()) + .filter_map(|n| n.sample.as_ref()) + .filter_map(|s| s.ollama.models.value()) + .map(Vec::len) + .sum::(); + let gpu_values = self + .nodes + .iter() + .filter(|n| n.live()) + .filter_map(|n| n.sample.as_ref()) + .flat_map(|s| s.gpus.iter()) + .filter_map(|g| g.utilization_percent.value().copied()) + .collect::>(); + let power = self.measured_power(); + let stats = row![ + stat( + "NODI MONITORATI", + &format!("{} / {}", fleet.online, fleet.monitored), + &format!( + "{} censiti · {} non strumentati", + fleet.devices.len(), + fleet.devices.len() - fleet.monitored + ) + ), + stat( + "MODELLI RESIDENTI", + &if observed_runtime > 0 { + models.to_string() + } else { + "N/D".into() + }, + &format!("Ollama osservato su {observed_runtime} nodi") + ), + stat( + "GPU · MASSIMO OSSERVATO", + &pct(gpu_values.iter().copied().reduce(f64::max)), + &format!("{} acceleratori con misura", gpu_values.len()) + ), + stat( + "POTENZA GPU MISURATA", + &watts(power.0), + &format!("{} nodi con lettura · board", power.1) + ) + ] + .spacing(12); + let timeline = if let Some(n) = node { + self.resource_timeline(n) + } else { + empty_fleet() + }; + let middle = row![ + container(timeline).width(Length::FillPortion(3)), + container(self.lab_map(fleet, 220.0)).width(Length::FillPortion(2)) + ] + .spacing(16); + column![stats, middle, self.model_table(true), self.source_summary()] + .spacing(18) + .into() + } + fn resource_timeline<'a>(&self, n: &'a NodeState) -> Element<'a, Message> { + let s = n.sample.as_ref(); + let title = row![ + column![ + text("Resource timeline").size(16).font(STRONG), + text(format!("{} · ultimi 5 minuti", n.config.label)) + .size(11) + .tone(MUTED) + ] + .spacing(4), + Space::new().width(Fill), + small_button("Apri nodo →", Message::Select(n.config.id.clone())) + ] + .align_y(alignment::Vertical::Center); + let metrics = row![ + metric( + Metric::percent("CPU", s.map(|s| &s.cpu_utilization_percent), !n.live()), + chart::SERIES[0] + ), + metric( + s.map(|s| Metric::memory(s, !n.live())) + .unwrap_or_else(|| Metric::percent("RAM", None, false)), + chart::SERIES[1] + ), + metric( + s.map(|s| Metric::gpu(s, !n.live())) + .unwrap_or_else(|| Metric::percent("GPU", None, false)), + chart::SERIES[2] + ) + ] + .spacing(16); + card( + column![ + title, + metrics, + canvas(chart::ResourceChart::new(&n.history, self.view_time_ms)) + .width(Fill) + .height(135), + text("Utilizzo % · passa sul grafico per ispezionare") + .size(10) + .tone(MUTED) + ] + .spacing(12), + ) + .height(320) + .into() + } + fn lab_map<'a>(&'a self, fleet: &FleetViewModel, height: f32) -> Element<'a, Message> { + let scene = Scene { + selected: fleet.selected_id.clone(), + devices: fleet.devices.clone(), + camera: self.camera, + dark: self.dark, + }; + let content: Element<'a, Message> = if fleet.devices.is_empty() { + empty_fleet() + } else if self.scene_visible { + widget::stack![ + widget::shader(scene.clone()).width(Fill).height(height), + canvas(scene).width(Fill).height(height) + ] + .into() + } else { + container(self.scene_list()).height(height).into() + }; + card( + column![ + row![ + text("Lab Map").size(16).font(STRONG), + Space::new().width(Fill), + small_button( + if self.scene_visible { "Elenco" } else { "3D" }, + Message::ToggleScene + ), + small_button("−", Message::Zoom(-0.1)), + small_button("+", Message::Zoom(0.1)), + small_button("↺", Message::ResetCamera) + ] + .align_y(alignment::Vertical::Center), + content, + text("Seleziona un nodo per aprire il dettaglio") + .size(10) + .tone(MUTED) + ] + .spacing(10), + ) + .height(height + 100.0) + .into() + } + fn fleet<'a>(&'a self, fleet: &FleetViewModel) -> Element<'a, Message> { + let header = row![ + text(format!("{} nodi censiti", fleet.devices.len())) + .size(17) + .font(STRONG), + Space::new().width(Fill), + text(format!( + "{} online / {} monitorati", + fleet.online, fleet.monitored + )) + .size(12) + .tone(MUTED) + ]; + let mut rows = column![ + header, + table_row(["DISPOSITIVO", "STATO", "CPU / RAM", "GPU"], true) + ] + .spacing(10); + for n in &self.nodes { + let cpu = n + .sample + .as_ref() + .and_then(|s| s.cpu_utilization_percent.value().copied()); + let memory = n + .sample + .as_ref() + .and_then(|s| moonitor_store::memory_percent(s)); + let gpu = n + .sample + .as_ref() + .and_then(|s| Metric::gpu(s, !n.live()).value); + rows = rows.push( + button( + column![ + table_row( + [ + &n.config.label, + status(n).0, + &format!("{} / {}", pct(cpu), pct(memory)), + &pct(gpu) + ], + false + ), + text( + n.sample + .as_ref() + .map(|s| format!("{} · {}", s.cpu_name, s.os)) + .unwrap_or_else(|| n.config.note.clone()) + ) + .size(11) + .tone(MUTED) + ] + .spacing(3), + ) + .width(Fill) + .padding(10) + .on_press(Message::Select(n.config.id.clone())) + .style(|theme, state| { + themed_button( + theme, + button::Style { + background: Some( + if matches!(state, button::Status::Hovered) { + BG + } else { + PAPER + } + .into(), + ), + text_color: INK, + border: Border { + color: LINE, + width: 0.7, + radius: 6.0.into(), + }, + ..Default::default() + }, + ) + }), + ); + } + column![card(rows), self.lab_map(fleet, 300.0)] + .spacing(18) + .into() + } + fn device<'a>(&'a self, n: &'a NodeState) -> Element<'a, Message> { + let tabs = Row::with_children(TABS.iter().map(|tab| { + button(text(tab.label()).size(12)) + .on_press(Message::Tab(*tab)) + .padding([8, 12]) + .style(move |theme, state| { + themed_button( + theme, + button::Style { + background: Some( + if self.tab == *tab { + Color::from_rgb(0.90, 0.94, 0.86) + } else if matches!(state, button::Status::Hovered) { + LINE + } else { + Color::TRANSPARENT + } + .into(), + ), + text_color: if self.tab == *tab { GREEN } else { MUTED }, + border: Border { + radius: 7.0.into(), + ..Default::default() + }, + ..Default::default() + }, + ) + }) + .into() + })) + .spacing(6); + let mut body = column![self.node_heading(n), tabs].spacing(20); + if self.tab == Tab::Overview && n.sample.is_some() { + let s = n.sample.as_ref(); + body = body.push( + row![ + card(metric( + Metric::percent("CPU", s.map(|s| &s.cpu_utilization_percent), !n.live()), + GREEN + )), + card(metric( + s.map(|s| Metric::memory(s, !n.live())) + .unwrap_or_else(|| Metric::percent("RAM", None, false)), + GREEN + )), + card(metric( + s.map(|s| Metric::gpu(s, !n.live())) + .unwrap_or_else(|| Metric::percent("GPU", None, false)), + GREEN + )) + ] + .spacing(12), + ); + } + body.push(self.details(n)).into() + } + fn model_table(&self, compact: bool) -> Element<'_, Message> { + let mut rows = column![ + row![ + text("Modelli osservati").size(16).font(STRONG), + Space::new().width(Fill), + small_button("Workloads →", Message::Navigate(Section::Workloads)) + ] + .align_y(alignment::Vertical::Center), + table_row(["MODELLO", "NODO", "RUNTIME", "MEMORIA MODELLO"], true) + ] + .spacing(8); + let mut count = 0; + for n in &self.nodes { + if let Some(s) = &n.sample + && let Some(models) = s.ollama.models.value() + { + for m in models { + if !compact || count < 3 { + rows = rows.push(table_row( + [ + &m.name, + &n.config.label, + if n.live() { + "Ollama · residente" + } else { + "Ollama · obsoleto" + }, + &bytes(Some(m.size_bytes)), + ], + false, + )); + } + count += 1; + } + } + } + if count == 0 { + rows=rows.push(text("Nessun modello residente osservato. La disponibilità dei runtime è indicata nelle sorgenti.").size(12).tone(MUTED)); + } + if !compact { + rows=rows.push(text("Residenza del modello ≠ inferenza attiva. Token/s, TTFT e code richiedono metriche delle richieste.").size(11).tone(MUTED)); + } + card(rows).into() + } + fn workloads(&self) -> Element<'_, Message> { + let mut runtime = column![ + text("Runtime · copertura delle sorgenti") + .size(17) + .font(STRONG) + ] + .spacing(12); + for n in &self.nodes { + let state = n + .sample + .as_ref() + .map(|s| reading_reason(&s.ollama.models)) + .unwrap_or("Nessuna osservazione"); + runtime = runtime.push(table_row( + [&n.config.label, "Ollama", state, status(n).0], + false, + )); + } + column![self.model_table(false),card(runtime),notice("Questa vista osserva lo stato dei modelli. Tempi e risorse delle singole richieste non sono ancora esposti dalle sorgenti collegate.")].spacing(18).into() + } + fn measured_power(&self) -> (Option, usize) { + let values = self + .nodes + .iter() + .filter(|n| n.live()) + .filter_map(|n| n.sample.as_ref()) + .filter_map(|s| gpu_power(s)) + .collect::>(); + ( + (!values.is_empty()).then(|| values.iter().sum()), + values.len(), + ) + } + fn energy(&self) -> Element<'_, Message> { + let power = self.measured_power(); + let mut rows = column![ + text("Misure di potenza GPU").size(17).font(STRONG), + table_row(["NODO", "POTENZA", "CONFINE", "STATO"], true) + ] + .spacing(12); + for n in &self.nodes { + rows = rows.push(table_row( + [ + &n.config.label, + &watts(n.sample.as_ref().and_then(|s| gpu_power(s))), + "GPU board", + status(n).0, + ], + false, + )); + } + column![row![stat("POTENZA GPU MISURATA",&watts(power.0),&format!("{} nodi con lettura corrente",power.1)),stat("INTERO LABORATORIO","N/D","Nessun totale whole-device disponibile"),stat("ENERGIA","N/D","Intervalli e copertura non disponibili")].spacing(12),card(rows),notice("La potenza GPU non rappresenta il consumo totale del dispositivo. La memoria unificata non aggiunge capacità alla RAM del nodo.")].spacing(18).into() + } + fn source_summary(&self) -> Element<'_, Message> { + let mut row = Row::new().spacing(18); + for n in &self.nodes { + let (state, color) = status(n); + row = row.push( + button( + column![ + text(&n.config.label).size(12).font(MEDIUM), + text(state).size(11).tone(color) + ] + .spacing(5), + ) + .on_press(Message::Select(n.config.id.clone())) + .style(button::text), + ); + } + card( + column![ + text("Sorgenti").size(14).font(STRONG), + scrollable(row).direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default() + )) + ] + .spacing(10), + ) + .into() + } + fn events(&self) -> Element<'_, Message> { + let mut events = + column![text("Diagnostica della raccolta").size(17).font(STRONG)].spacing(12); + let mut count = 0; + for n in &self.nodes { + if let Some(error) = &n.error { + events = events.push(notice(&format!("{} · {error}", n.config.label))); + count += 1; + } + } + if let Some(error) = &self.storage_error { + events = events.push(notice(error)); + count += 1; + } + if count == 0 { + events = events.push( + text("Nessun errore di raccolta segnalato.") + .size(13) + .tone(GREEN), + ); + } + column![card(events),card(column![text("Timeline eventi non disponibile").size(16).font(STRONG),text("L'archivio corrente conserva riepiloghi delle metriche. Non contiene ancora eventi storici di connessione o esecuzione dei workload.").size(13).tone(MUTED),small_button("Controlla la flotta →",Message::Navigate(Section::Fleet))].spacing(12)),self.source_summary()].spacing(18).into() + } + fn explore<'a>(&self, n: &'a NodeState) -> Element<'a, Message> { + card( + column![ + text(format!("{} · storico recente", n.config.label)) + .size(18) + .font(STRONG), + text("Ultimi 5 minuti · dati ricevuti · campioni persistiti ogni 5 s") + .size(12) + .tone(MUTED), + plot("CPU", &n.history, self.view_time_ms, 0, GREEN), + plot( + "RAM", + &n.history, + self.view_time_ms, + 1, + Color::from_rgb(0.48, 0.58, 0.40) + ), + plot( + "GPU · massimo disponibile", + &n.history, + self.view_time_ms, + 2, + Color::from_rgb(0.40, 0.56, 0.66) + ), + text("0–100% · le interruzioni delle misure restano visibili come lacune.") + .size(11) + .tone(MUTED) + ] + .spacing(20), + ) + .into() + } + fn settings(&self) -> Element<'_, Message> { + column![card(column![text("Aspetto · sessione corrente").size(18).font(STRONG),row![small_button("Chiaro",Message::Theme(false)),small_button("Scuro",Message::Theme(true)),small_button(if self.scene_visible{"Disattiva 3D"}else{"Attiva 3D"},Message::ToggleScene)].spacing(12),text("Il rendering 3D si aggiorna su dati e interazioni. Nessuna animazione continua.").size(12).tone(MUTED)].spacing(16)),card(column![text("Collegamento delle sorgenti").size(18).font(STRONG),text("I nodi e gli alias SSH sono configurati in fleet.toml. Le credenziali restano nella configurazione SSH del sistema. Le modifiche alla flotta richiedono il riavvio dell'app.").size(13).tone(MUTED),text("Archivio locale SQLite · riepiloghi ogni 5 s · conservazione 7 giorni. La raccolta si ferma chiudendo il desktop.").size(13).tone(MUTED),text("Tastiera: Cmd/Ctrl+1…7 navigazione · Alt+←/→ nodo · Tab e Shift+Tab focus.").size(12).tone(MUTED)].spacing(14)),self.source_summary()].spacing(18).into() + } +} +fn stat<'a>(label: &str, value: &str, detail: &str) -> Element<'a, Message> { + card( + column![ + text(label.to_owned()).size(9).font(MEDIUM).tone(MUTED), + text(value.to_owned()).size(28).font(STRONG), + text(detail.to_owned()).size(10).tone(MUTED) + ] + .spacing(8), + ) + .into() +} +fn empty_fleet<'a>() -> Element<'a, Message> { + card(column![text("Nessun dispositivo configurato").size(18).font(STRONG),text("Aggiungi un nodo local o ssh in fleet.toml per iniziare la raccolta. Puoi censire dispositivi senza agente come unavailable.").size(13).tone(MUTED)].spacing(12)).into() +} diff --git a/crates/moonitor-desktop/src/view_model.rs b/crates/moonitor-desktop/src/view_model.rs new file mode 100644 index 0000000..6b76a58 --- /dev/null +++ b/crates/moonitor-desktop/src/view_model.rs @@ -0,0 +1,356 @@ +//! Presentation contracts: no collectors, storage or fabricated observations. +use crate::{ + config::{NodeKind, VisualKind}, + engine::NodeState, +}; +use moonitor_core::{Reading, UnavailableReason}; +use std::time::{Duration, Instant}; + +pub const FRESH_FOR: Duration = Duration::from_secs(4); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeStatus { + Online, + Stale, + Unreachable, + Uninstrumented, + Connecting, +} +impl NodeStatus { + pub fn of(node: &NodeState, now: Instant) -> Self { + if matches!(node.config.kind, NodeKind::Unavailable) { + Self::Uninstrumented + } else if node.sample.is_some() { + if node.error.is_none() + && node + .last_seen + .is_some_and(|t| now.saturating_duration_since(t) < FRESH_FOR) + { + Self::Online + } else { + Self::Stale + } + } else if node.error.is_some() { + Self::Unreachable + } else { + Self::Connecting + } + } + pub fn label(self) -> &'static str { + match self { + Self::Online => "Online", + Self::Stale => "Obsoleto", + Self::Unreachable => "Non raggiungibile", + Self::Uninstrumented => "Non strumentato", + Self::Connecting => "In connessione", + } + } + pub fn color(self) -> [f32; 3] { + match self { + Self::Online => [0.23, 0.49, 0.36], + Self::Stale => [0.70, 0.45, 0.15], + Self::Unreachable => [0.72, 0.25, 0.23], + Self::Uninstrumented => [0.48, 0.51, 0.50], + Self::Connecting => [0.35, 0.46, 0.65], + } + } +} +#[derive(Debug, Clone, PartialEq)] +pub struct DeviceSceneModel { + pub id: String, + pub label: String, + pub kind: VisualKind, + pub status: NodeStatus, + pub position: [f32; 3], +} +pub struct FleetViewModel { + pub devices: Vec, + pub selected_id: Option, + pub monitored: usize, + pub online: usize, +} +impl FleetViewModel { + pub fn new(nodes: &[NodeState], selected: Option<&str>, now: Instant) -> Self { + let columns = (nodes.len() as f32).sqrt().ceil().max(1.0) as usize; + let rows = nodes.len().div_ceil(columns); + let devices = nodes + .iter() + .enumerate() + .map(|(i, n)| DeviceSceneModel { + id: n.config.id.clone(), + label: n.config.label.clone(), + kind: n.config.visual_kind, + status: NodeStatus::of(n, now), + position: [ + ((i % columns) as f32 - (columns - 1) as f32 / 2.0) * 2.6, + 0.0, + ((i / columns) as f32 - rows.saturating_sub(1) as f32 / 2.0) * 2.6, + ], + }) + .collect::>(); + let selected_id = selected + .filter(|id| devices.iter().any(|d| d.id == *id)) + .map(str::to_owned) + .or_else(|| devices.first().map(|d| d.id.clone())); + Self { + monitored: devices + .iter() + .filter(|d| d.status != NodeStatus::Uninstrumented) + .count(), + online: devices + .iter() + .filter(|d| d.status == NodeStatus::Online) + .count(), + devices, + selected_id, + } + } +} + +#[derive(Debug, Clone)] +pub struct Metric { + pub label: String, + pub value: Option, + pub detail: String, + pub provenance: String, + pub stale: bool, +} +impl Metric { + pub fn percent(label: &str, reading: Option<&Reading>, stale: bool) -> Self { + let (value, provenance) = match reading { + Some(Reading::Available { + value, + source, + quality, + sampled_at_unix_ms, + }) if value.is_finite() && (0.0..=100.0).contains(value) => ( + Some(*value), + format!( + "Fonte: {source} · qualità: {quality:?} · campionato: {sampled_at_unix_ms} ms Unix" + ), + ), + Some(Reading::Available { .. }) => { + (None, "Valore fuori intervallo: atteso 0–100%".into()) + } + Some(Reading::Unavailable { reason }) => (None, unavailable(*reason).into()), + None => (None, "Nessuna osservazione ricevuta".into()), + }; + Self { + label: label.into(), + value, + detail: String::new(), + provenance, + stale: stale && reading.is_some(), + } + } + pub fn memory(snapshot: &moonitor_core::HostSnapshot, stale: bool) -> Self { + let mut m = Self::percent("Memoria", None, stale); + m.stale = stale; + match ( + &snapshot.memory.total_bytes, + &snapshot.memory.available_bytes, + ) { + ( + Reading::Available { + value: total, + source, + sampled_at_unix_ms, + .. + }, + Reading::Available { + value: available, + source: available_source, + sampled_at_unix_ms: available_at, + .. + }, + ) if *total > 0 && available <= total => { + m.value = Some((*total - *available) as f64 / *total as f64 * 100.0); + m.provenance = format!( + "Derivata: (totale − disponibile) / totale · fonti {source}, {available_source} · timestamp {sampled_at_unix_ms}, {available_at} ms Unix" + ); + } + (Reading::Unavailable { reason }, _) | (_, Reading::Unavailable { reason }) => { + m.provenance = unavailable(*reason).into() + } + _ => m.provenance = "Memoria: contatori non validi".into(), + } + m + } + pub fn gpu(snapshot: &moonitor_core::HostSnapshot, stale: bool) -> Self { + let reading = snapshot + .gpus + .iter() + .filter(|g| { + g.utilization_percent + .value() + .is_some_and(|v| v.is_finite() && (0.0..=100.0).contains(v)) + }) + .max_by(|a, b| { + a.utilization_percent + .value() + .unwrap() + .total_cmp(b.utilization_percent.value().unwrap()) + }) + .or_else(|| snapshot.gpus.first()); + let mut m = Self::percent( + if snapshot.gpus.len() > 1 { + "GPU · massimo disponibile" + } else { + "GPU" + }, + reading.map(|g| &g.utilization_percent), + stale, + ); + if let Some(g) = reading { + m.detail = g.name.clone(); + } + if snapshot.gpus.len() > 1 { + m.provenance.push_str(&format!( + " · massimo fra {} GPU censite; vedere AI locale per ogni GPU", + snapshot.gpus.len() + )); + } + m + } + pub fn text(&self) -> String { + self.value + .map(|v| format!("{v:.0}%")) + .unwrap_or_else(|| "N/D".into()) + } + pub fn explanation(&self) -> String { + let state = if self.stale { + "Ultima lettura conservata · obsoleta" + } else if self.value.is_none() { + "Dato non disponibile" + } else { + "Campione corrente" + }; + format!( + "{} · {}\n{}\n{}", + self.label, + self.text(), + self.provenance, + state + ) + } +} +pub fn unavailable(reason: UnavailableReason) -> &'static str { + match reason { + UnavailableReason::Unsupported => "Non supportato dal sensore", + UnavailableReason::PermissionDenied => "Permesso negato", + UnavailableReason::NotConfigured => "Sorgente non configurata", + UnavailableReason::TemporarilyUnavailable => "Temporaneamente non disponibile", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::NodeConfig; + pub fn nodes(count: usize) -> Vec { + (0..count) + .map(|i| NodeState { + config: NodeConfig { + id: format!("node-{i}"), + label: format!("Node {i}"), + kind: NodeKind::Unavailable, + visual_kind: VisualKind::Compact, + ssh_alias: None, + remote_agent: None, + note: String::new(), + }, + sample: None, + last_seen: None, + error: None, + history: Default::default(), + }) + .collect() + } + #[test] + fn fleet_counts_selection_and_layout_follow_identity() { + for count in [0, 1, 2, 5, 16, 32] { + let nodes = nodes(count); + let vm = FleetViewModel::new(&nodes, Some("missing"), Instant::now()); + assert_eq!(vm.devices.len(), count); + assert_eq!(vm.monitored, 0); + assert_eq!(vm.online, 0); + assert_eq!(vm.selected_id.is_some(), count > 0); + for (i, a) in vm.devices.iter().enumerate() { + for b in &vm.devices[i + 1..] { + assert_ne!(a.position, b.position); + } + } + } + let mut nodes = nodes(5); + nodes.reverse(); + assert_eq!( + FleetViewModel::new(&nodes, Some("node-1"), Instant::now()) + .selected_id + .as_deref(), + Some("node-1") + ); + nodes.truncate(1); + let vm = FleetViewModel::new(&nodes, Some("node-1"), Instant::now()); + assert_eq!(vm.devices.len(), 1); + assert_eq!(vm.selected_id.as_deref(), Some("node-4")); + } + #[test] + fn freshness_and_instrumentation_are_distinct() { + let nodes = crate::fixtures::fleet(5); + let now = Instant::now(); + let vm = FleetViewModel::new(&nodes, None, now); + assert_eq!(vm.monitored, 4); + assert_eq!(vm.online, 1); + assert_eq!( + vm.devices.iter().map(|d| d.status).collect::>(), + vec![ + NodeStatus::Online, + NodeStatus::Stale, + NodeStatus::Unreachable, + NodeStatus::Connecting, + NodeStatus::Uninstrumented + ] + ); + assert_eq!( + NodeStatus::of(&nodes[0], now + FRESH_FOR), + NodeStatus::Stale + ); + } + #[test] + fn unified_memory_is_not_added_and_gpu_max_is_named() { + let mut s = crate::fixtures::sample("qa"); + assert_eq!(Metric::memory(&s, false).value, Some(50.0)); + assert!(s.gpus[0].memory_total_bytes.value().is_none()); + let mut gpu = s.gpus[0].clone(); + gpu.id = "gpu-1".into(); + gpu.utilization_percent = Reading::reported(80.0, "QA GPU 1", 10); + s.gpus.push(gpu); + let m = Metric::gpu(&s, false); + assert_eq!(m.value, Some(80.0)); + assert!(m.label.contains("massimo")); + assert_eq!(s.gpus.len(), 2); + } + #[test] + fn zero_and_absence_have_different_values_and_all_reasons_survive() { + let zero = Reading::Available { + value: 0.0, + source: "fixture".into(), + quality: moonitor_core::Quality::Reported, + sampled_at_unix_ms: 10, + }; + assert_eq!(Metric::percent("CPU", Some(&zero), false).text(), "0%"); + let mut reasons = std::collections::HashSet::new(); + for reason in [ + UnavailableReason::Unsupported, + UnavailableReason::PermissionDenied, + UnavailableReason::NotConfigured, + UnavailableReason::TemporarilyUnavailable, + ] { + let m = Metric::percent("GPU", Some(&Reading::Unavailable { reason }), false); + assert_eq!(m.text(), "N/D"); + assert!(m.value.is_none()); + reasons.insert(m.provenance); + } + assert_eq!(reasons.len(), 4); + assert!(Metric::percent("CPU", None, false).value.is_none()); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 118a645..93da070 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ | `moonitor-core` | Shared telemetry types and measurement semantics | Implemented | | `moonitor-agent` | Local platform collection and Ollama adapter | Implemented | | `moonitor-store` | SQLite summaries, seven-day retention and recent history | Implemented | -| `moonitor-desktop` | Native Rust egui/eframe fleet and device UI | Implemented | +| `moonitor-desktop` | Native Rust Iced/wgpu fleet and device UI | Implemented | The headless agent must not depend on the desktop rendering stack. @@ -54,13 +54,27 @@ seconds. Snapshot transmission and basic counters run at one-second intervals. The initial process list contains bounded CPU-heavy processes and AI name matches, not an exhaustive process census. -The UI has fleet, overview, process, AI, disk, network and recent-history views. -Rendering uses glow/OpenGL and one-second repaint scheduling, coalescing samples -from different nodes. Collection, SSH and SQLite run outside the UI thread. +The primary shell has Overview, Fleet, Workloads, Energy, Events, Explore and Settings. +Processes, compute, sensors, AI, storage, network and history live in node detail. +`ui/workbench.rs` owns the screen composition; `ui/chart.rs` renders receipt-time +series with explicit gaps and nearest-sample inspection; `ui.rs` retains application +state, reusable metric components and resource views. The chart window uses the +UI polling timestamp, frozen in tests for reproducibility. W1 introduces no new +collector API. The existing Iced image feature is enabled for the supplied brand +asset; its two additional transitive packages are documented in assets/brand/README.md. +Rendering uses Iced 0.14 and wgpu, with one-second state polling. Collection, SSH +and SQLite run outside the UI thread. `view_model.rs` defines availability, +ID selection, scene entities and typed percent metrics. `scene.rs` caches uploaded +meshes until fleet identity/geometry changes; camera, selection and state invalidate +the color pass, geometry invalidates shadows. There is no continuous animation. +The list alternative removes the shader widget. GPU-free startup is not yet qualified. +Ingress drops are counted and shown; schema-1 history still only stores summaries. + +See [W0 audit and target module boundaries](moonitor-w0-audit-and-plan.md). ## References -- [egui repaint scheduling](https://docs.rs/egui/latest/egui/struct.Context.html) +- [Iced 0.14](https://docs.rs/iced/0.14.0/iced/) - [NVIDIA Management Library](https://developer.nvidia.com/management-library-nvml) - [Spark memory reporting](https://nvidia.custhelp.com/app/answers/detail/a_id/5775/) - [Ollama loaded models](https://docs.ollama.com/api/ps) diff --git a/docs/moonitor-professional-upgrade-blueprint.md b/docs/moonitor-professional-upgrade-blueprint.md new file mode 100644 index 0000000..aa70cad --- /dev/null +++ b/docs/moonitor-professional-upgrade-blueprint.md @@ -0,0 +1,265 @@ +# Moonitor · Professional Upgrade Blueprint + +Data review: 23 ottobre 2026 +Target: Moonitor 2.0, desktop-native local-first observability for personal compute and AI labs +Repository: mothx9/moonitor (privato) +Branch remoto analizzato: main, commit ba91ef433e83721ea52aad6fbea0d32a30bccf84 (22 ottobre 2026) +Materiali UI: screenshot e registrazione forniti dall'utente, relativi alla build locale Iced/3D non pubblicata nel branch esaminato. +Tipo di lavoro: review e target design, nessuna modifica al repository. + +## 0. Decisione di prodotto + +Moonitor non sarà soltanto un aggregatore di metriche CPU/RAM/GPU per cinque computer. Diventerà un observability workbench locale capace di rispondere a quattro domande: + +1. Che cosa sta succedendo nella flotta, adesso? +2. Quale workload/modello/processo causa un cambiamento e come si spiega? +3. Quali prestazioni e consumi sono misurabili, con quale confidenza? +4. Quale azione conviene valutare, senza sacrificare sicurezza e controllo dell'utente? + +North-star: Understand, compare and diagnose the behavior of your personal compute lab, especially local AI workloads, without a cloud requirement. + +Differenziazione: correlazione fra hardware eterogeneo, inferenza locale, energia e timeline degli eventi. Non competere sul mero inventario host con Netdata, Beszel o Grafana. Non trasformarlo prematuramente in un orchestratore di workload né in un agente che esegue comandi sui nodi. + +## 1. Ambito e limiti della verifica + +Nel repository main sono presenti quattro crate (moonitor-core, moonitor-agent, moonitor-store, moonitor-desktop). Il desktop pubblicato è egui/eframe + glow/OpenGL. La UI Iced + wgpu + 3D visibile nella registrazione risulta solo locale; non è possibile revisionarne la sorgente scene.rs o gli shader dal repository remoto. Le note tecniche locali fornite dall'utente indicano che tale versione disegna cinque oggetti indipendentemente dalla flotta configurata e non riflette lo stato online/offline nello shader: considerare questi problemi da verificare nel codice locale, non problemi confermati nel branch GitHub. + +### Elementi riutilizzabili + +- moonitor-agent: raccolta local/SSH di CPU, RAM, processi, filesystem, reti, Linux PSI, NVIDIA NVML e Apple IOKit; polling a frequenze differenti. +- moonitor-core: tipi con distinzione esplicita fra Reading::Available e Reading::Unavailable; indicazione della sorgente e qualità. +- moonitor-desktop/engine.rs: SSH persistente con known-hosts e batch mode, payload di dimensione limitata, canali bounded e reconnect. +- moonitor-store: SQLite WAL, campionamento di riepiloghi, retention e test null-vs-zero. +- config: validazione di ID, alias SSH e percorsi agent. +- Script e note di qualification già presenti. + +### Debiti verificati + +| Tema | Prova nel codice pubblicato | Implicazione | +| --- | --- | --- | +| Store nel processo desktop | Engine::start apre history.sqlite; README precisa che il desktop deve restare attivo | Lo storico si interrompe quando l'app viene chiusa | +| Modello AI centrato su Ollama | HostSnapshot.ollama: RuntimeSnapshot; collect_ollama() legge /api/ps | Non esiste un'ontologia generale di runtime e inferenze | +| Storico povero | samples(node, received_ms, source_ms, cpu, memory_percent, gpu, gpu_board_watts) | Non conserva GPU individuali, runtime, eventi, sorgenti e granularità utile all'analisi | +| GPU sintetizzate col massimo | store e engine calcolano max() delle utilization dei GPU | Perdita della differenza fra acceleratori multipli | +| Processi parziali | Top 64 CPU + 32 candidati AI per nome, non un censimento esaustivo | UI non deve promettere un process manager completo | +| Test limitati | Sette test riportati in qualification; CI Rust static/unit | Nessuna verifica adeguata della nuova UI, screenshot o interazioni | +| Distribuzione locale | .app costruita da script, firmata ad-hoc, con .local/fleet.toml incluso nel bundle | Non è una release distribuita/notarizzata; configurazione locale da separare dal bundle | +| Qualità delle letture | Reading è un buon inizio; validazione HostSnapshot ancora parziale | Estendere controlli di range, clock, fonti, stale e copertura | + +## 2. Audit dell'interfaccia Iced/3D (screenshot e video) + +### Criticità P0/P1 + +P0 · Metri coerenti e visibili: le percentuali devono sempre essere leggibili, non soltanto la progress bar; ogni numero deve avere etichetta, unità, definizione, fonte, stato, timestamp. Fallimento di visualizzazione = bug funzionale. + +P0 · Data-driven scene: la scena contiene esattamente i nodi del modello di flotta e li aggiorna con aggiunta/rimozione, scelta dispositivo, online, stale, unreachable, uninstrumented. Nessun oggetto hard-coded. Un nodo non monitorabile non entra nel denominatore “nodi online”. + +P1 · Information architecture: la sidebar ripete dispositivi che appaiono nuovamente in scena e card; la navigazione privilegia categorie di risorse (processi, dischi, rete) più che casi d'uso (flotta, workload, energia, problemi). + +P1 · Home data-first: la grande scena isometrica domina la vista; gli andamento e le attività importanti finiscono sotto il fold. Nessuna diagnosi sintetica o spiegazione di cambiamenti. + +P1 · Pagine vuote: selezionare iPhone in AI locale produce una pagina quasi tutta vuota con “Telemetria non disponibile”; l'assenza del sensore va trattata in un componente riutilizzabile che spiega perché e cosa è possibile configurare, non con chilometri di superficie vuota. + +P1 · Charts deboli: linee sottilissime in assenza di tempo/scala leggibili, nessuno zoom/brush/crosshair, nessuna sovrapposizione di eventi, nessuna comparazione multi-nodo; il range è sempre 5 minuti. + +P1 · 3D come illustrazione: oggetti procedurali generici, scala e illuminazione da mockup, nessuna topologia autentica, connessione o flusso rappresentato. Rendering senza semantica. + +### Conservare dalla direzione visiva + +Nome Moonitor, simbolo lunare, palette verde/salvia, pulizia e ampio spazio di lavoro, sensazione di desktop tool e non di sito web. Il problema non è il minimalismo: è l'insufficiente priorità alle informazioni e l'assenza di un sistema visuale/di interazione rigoroso. + +## 3. Information architecture v2 + +### Navigazione primaria + +| Sezione | Domanda / azione principale | Contenuto minimo | +| --- | --- | --- | +| Overview | La flotta sta bene? Che cosa richiede attenzione? | Stato reale, carichi AI, GPU, energia misurata, anomalie, eventi recenti | +| Fleet | Quali nodi ci sono e come si relazionano? | Inventario, stato, capacità, connettività, gruppi, topologia; drilldown | +| Workloads | Che cosa sta girando, dove, con quali prestazioni? | Runtime, modelli, esecuzioni, code, token/s, TTFT, tempi, risorse | +| Energy | Quale potenza misurabile, quanta energia, quale efficienza? | Scopes, fonti, W/Wh, copertura, confronti con finestre equivalenti | +| Events | Che cosa è cambiato? Quando e perché? | Timeline unificata di eventi, alert, disconnessioni, inizi/fini job | +| Explore | Confronta dettagli storici e correlazioni | Filtri per metrica/nodo/run, timeframe, overlay e annotazioni | +| Settings | Come sono collegate fonti e dati? | Onboarding, agenti, connessioni, sensori, storage, accessibilità, privacy | + +Processi, storage, rete, sensori e GPU specifiche vivono dentro il dettaglio del nodo, non come sei sezioni globali equivalenti. Si può aggiungere un'area Diagnostics mirata dopo il primo release candidate. + +### Pagina Overview, viewport desktop di riferimento + +Top bar: logo compatto, Overview, breadcrumb di contesto, selettore finestra temporale, freshness, comando ricerca globale e stato della raccolta. + +Riga 1: quattro KPI interpretabili: (1) nodi monitorati vs censiti, (2) workload AI attivi e in attesa, (3) GPU occupate/capacità, (4) potenza totale solo se interamente e non ambiguamente misurata. Se non misurabile, mostra “copertura energetica” o “potenza GPU misurata”, non inventare watt totali. + +Riga 2, 65/35: a sinistra vista timeline delle risorse/workload con selettore CPU/GPU/RAM; a destra “Lab Map” compatta in 2D/3D con device state e collegamenti realmente conosciuti. La Lab Map può diventare full-screen in modalità immersiva. + +Riga 3: tabella Workloads attivi, con nodo, modello, runtime, tokens/s, TTFT, CPU/GPU attribuibili, ultime attività; empty-state valido se i runtime non espongono dati. + +Riga 4: Events/Alerts sintetici e stato/qualità delle sorgenti. I grafici non devono apparire soltanto dopo uno scroll profondo. + +### Dettaglio dispositivo + +Header persistente: nome, alias, chipset, OS, stato + ultimo contatto + sorgente. Tab interni: Summary / Compute / Processes / Storage / Network / AI / Sensors / History. Non duplicare le card dispositivo in ogni pagina: usare un device switcher unico, accessibile da top bar, command palette e Fleet. + +## 4. Design system (proposta da approvare con mock e screenshot) + +Identità: instrument-grade, quiet premium, editorial clarity. Non glassmorphism gratuito, non dashboard piena di chip e ombre, non cartone 3D. + +- Palette: canvas chiaro caldo #F6F8F5, surface #FFFFFF, testo primario #17251F, testo secondario #65756C, bordi #DDE5DD; dark canvas #0D1613, surface #14201A, testo #EAF2EC. Accento salvia/verde solo per enfasi e selezione; rischio non usa lo stesso colore del brand. +- Tipografia: SF Pro/System oppure Inter per UI; mono tabulare (SF Mono/Geist Mono) per timestamp, valori e identificatori. Headline 28–32, page headings 19–22, testo 13–14, metadati 11–12, KPI 24–30. +- Layout: scala 4/8pt, card con padding 16–24, raggio coerente 12–14, bordi fini; sidebar 208–224 px collassabile; barra superiore 56–64; ritmo verticale ripetibile; densità default e compact. +- Color semantics: Healthy, Busy, Warn, Critical, Stale, Uninstrumented, Unreachable, Unknown. Mai affidare lo stato esclusivamente al colore. +- Data vis: gridline discrete, assi e tooltip precisi, unità esplicite, marker per assenza/stale e per eventi. No linee continue attraverso missing-data. Time range comuni: live, 15m, 1h, 24h, 7d (solo se storage supporta). +- Motion: 3D e micro-animazioni on-demand, reduce-motion supportato. Focus/keyboard/contrast prima di spettacolo. +- Locale: italiano/inglese attraverso message catalog, unità coerenti SI/IEC (MiB vs MB) e formati locali. + +### 3D: decisione architetturale + +Non rimuovere la scena, ma cambiare il suo ruolo. Lab Map è un componente di osservabilità, non un'immagine di copertina. Ogni asset (glTF ottimizzato o geometria parametrica curata) ha device ID, stato, label, health ring e popover. La scena deve ridimensionarsi 0–32 nodi, degradare bene senza GPU supportata, e consentire list-view alternativa. Implementare il rendering solo quando l'utente interagisce o lo stato visibile cambia; idle non deve causare animazione continua. Per grandi flotte raggruppare i device, evitare la pretesa di modelli hardware fotorealistici per ogni variante. + +Scelta UI tech: mantenere Iced + wgpu provvisoriamente. Non cambiare framework per gusto estetico prima di misurare input latency, resa tipografica, accessibilità, native behavior, screenshot deterministici e rendering su macOS/Arch. Effettuare un ADR solo se emerge un limite riproducibile. + +## 5. Data model 2.0 + +Il crate moonitor-core deve distinguere entity, observation, measurement, event, runtime, workload, source: + +- Node: identità, piattaforma, capability discovery, connettività, ultima ricezione. +- DeviceComponent: CPU package/core, GPU accelerator, SoC, disk, network interface, energy meter, memory domain. +- MetricDescriptor: key canonica, unità, aggregabilità, scope, qualità, tipo (gauge/counter/histogram). +- Observation: node/component/workload IDs, sampled_at, received_at, value, availability reason, source, source version, quality, freshness. +- RuntimeInstance: engine (Ollama/vLLM/YVEX/MLX/altro), host, port/path opzionale, capability set, status. +- ModelInstance: model ID, quantization, context, residency, memory accounting e origin runtime. +- WorkloadRun: run ID/correlation ID, runtime, model, host, state, start/end, batches/requests/tokens latencies quando strumentate. +- Event: device connect/disconnect, runtime up/down, model loaded/unloaded, run lifecycle, metrics threshold, storage error, performance anomaly. +- EnergyObservation: boundary (GpuBoard, CpuPackage, SoC, WholeDevice), meter ID, W, intervalli validi, coverage, provenienza. + +Invarianti: assente ≠ zero; stale ≠ offline; fonte nominale ≠ valore garantito; GPU unified ≠ VRAM aggiuntiva; non sommare sensor boundary sovrapposte. Timestamp della sorgente e della ricezione separati; metriche del runtime non inferite da percentuali GPU. Per il valore totale energia, esigere sorgente esplicita e copertura misurata; integrazione Wh ammessa soltanto su intervalli validi, con lacune annotate. Non attribuire energia a un run quando più workload condividono la stessa GPU senza una metodologia verificabile. + +## 6. Architettura tecnica target + +```mermaid +flowchart LR + subgraph Devices + A[Mac/Linux platform collectors] + B[GPU/vendor collectors] + C[AI runtime adapters] + D[Optional energy meters] + end + A --> I[Bounded ingestion + schema validation] + B --> I + C --> I + D --> I + I --> M[Normalization + source quality] + M --> H[Hub optional / SQLite WAL & rollups] + H --> Q[Query + correlation + event rules] + Q --> UI[Native Iced desktop / view models] + UI --> SC[Semantic Lab Map 2D/3D] + UI --> CH[Charts / workload explorer / alerts] +``` + +Deployment phase 1: agent read-only e SSH persistente come ora; desktop locale operativo e fallback offline. Phase 2: moonitor-hub opzionale headless always-on su Zima, riceve/coordina campioni dai nodi e persiste storico senza desktop aperto, con spool/replay e deduplication. I nodi più sensibili non devono necessariamente esporre un listener: preferire SSH fidato o canale outbound autenticato, scegliendo esplicitamente transport policy. Autorizzazioni di rete minimali. + +Persistence: partire da SQLite e migrazioni forward-only versionate. Tabelle per entities, metric descriptors, timestamped samples (con status), events, runtime/workload runs, alert states, source diagnostics. Rollups (1s/10s/1m/5m) e retention configurabile; nessun ingresso illimitato di labels/cardinalità. La dashboard non interroga continuamente snapshots completi. Non introdurre Prometheus/ClickHouse senza un benchmark che lo giustifichi; supportare import/export OpenTelemetry e Prometheus come adapters, non come obbligo di infrastruttura. + +AI observability adapters prioritari: YVEX locale (strumentazione esplicita e consensuale), Ollama (stato modello e dati per richiesta solo dalle API/integrazioni che li espongono), vLLM (Prometheus /metrics, richieste se abilitate), in seguito llama.cpp/MLX. Per ogni adapter dichiarare capability matrix: model_state, queue, tokens, TTFT, ITL, KV-cache, memory, power, request_traces. Non millantare attributi assenti. + +Alerts: rule engine locale read-only, threshold+durata, dedup e quiet period, provenance, ack, event timeline; prima avvisi in-app, poi integrazioni configurabili. Nessun blocco o kill di processo da alert automatico. + +## 7. Roadmap e acceptance gates + +### Wave 0 — Consolidare e qualificare il lavoro locale (P0) + +1. Salvare senza perdite la versione Iced/3D in un branch separato; versionare sorgenti, shader e test, non .local/ con configurazioni private. Conservare un baseline tag del vecchio desktop. +2. Riprodurre e correggere le metriche illeggibili con una single source of truth per text/bar/tooltip, fallback esplicito N/D, zero visualizzato come 0%. +3. Eliminare nodi hardcoded, rendere la scena derivata dalla config e da NodeState, usare veramente stati availability/freshness nel rendering; test con 0 (empty UX), 1, 2, 5, 16, 32 nodi (distinguendo le config non valide). +4. Test UI con screenshot di Overview/AI/History/Processi in 1280x800, 1440x900, 4K, light/dark, Mac e Linux; click/keyboard/scroll/modal/empty/stale. +5. A/B baseline dei collector con inferenza AI su Exon e Spark; screenshot e video della build verificata, non solo cargo test. + +Gate W0: una persona diversa da chi sviluppa riconosce stato dei dispositivi e valori CPU/GPU senza ambiguità; visual diff e interazioni riproducibili; repo/bundle/local allineati. + +### Wave 1 — Product design e nuovo shell desktop (P1) + +1. Definire screen map con Overview, Fleet, Workloads, Energy, Events, Explore, Settings e device detail; wireframe prima del coding. +2. Tokens visivi, griglia, responsive states, data-viz, empty/error/loading, keyboard, reduced-motion, unit e locale. +3. Costruire UI component library isolata: MetricCard, StatusBadge, NodeSwitcher, TimeRange, MetricChart, EventsTable, UnavailableState, SourceTooltip. +4. Overview semantic data-first; Lab Map sempre sincronizzata col modello dati, non più dominante. + +Gate W1: niente valori fuori viewport senza ragione; ogni view deve rispondere a un quesito operativo; accessibilità minima e screenshot approvati. + +### Wave 2 — Observability core e timeline (P1) + +1. Normalizzare serie/capability/source + schema evolvibile; migrazioni DB e golden fixtures. +2. Conservare i singoli componenti e serie importanti, eventi temporali e stati runtime. +3. Time ranges multipli, downsampling, zoom, inspect, comparazione per nodo e correlazione di eventi. +4. Separare ingestion, query e view models; errori e dropped samples visibili. +5. Valutare hub always-on su Zima solo dopo lo storage locale stabile. + +Gate W2: recuperare sessioni storiche con desktop chiuso (quando hub attivo), grafici non collegano gap, unavailable/stale/source verificati su scenari sintetici. + +### Wave 3 — AI workloads, potenza ed efficienza (P1/P2) + +1. Integrare almeno un runtime realmente in esecuzione con richieste misurabili; test end-to-end: start→request→stop, GPU e timeline. +2. Metriche runtime reali: throughput, queue, TTFT/ITL, context, KV-cache, token counts, p50/p95 quando disponibili; normalizzazione di unità/definizioni. +3. Potenza GPU NVIDIA e altri sensori dichiarando lo scope; external wattmeter opzionale per whole device. +4. Energia Wh con coverage; comparatore performance/watt solo per misure comparabili. +5. Eventi correlati, alert e report di sessione esportabile con provenance. + +Gate W3: rispondere per almeno un lavoro AI reale “chi, dove, quanto, da quando, cosa è cambiato?” con link ai campioni originali e dichiarazione dei limiti. + +### Wave 4 — Release engineering e operatività (P1) + +1. Package macOS pulito con config in Application Support, senza file .local/fleet.toml dentro release distribuite; signing/notarization/distribution per release esterne. +2. Install/update agent via SSH con protocol/schema negotiation, rollout/rollback e fail-safe. +3. CI multi-platform, snapshot/golden tests, soak ≥24h, reconnect network/sleep-wake, benchmark overhead inference on/off. +4. Backups/migrations, import/export redatti, error reporting locale, log rotation, diagnostics bundle privacy-safe. +5. Versionamento coerente binary/schema/desktop e release notes. + +Gate W4: clean install e upgrade senza perdita di dati, senza disclosure di configurazioni private e senza monitor disconnesso che passa per “online”. + +## 8. Casi di test non negoziabili + +- Metriche 0, None, Unsupported, PermissionDenied, NotConfigured, TemporarilyUnavailable mostrate distintamente. +- Cambiamento 5→1 nodo produce 1 asset 3D, 1 card e 1 item inventario; i nodi non monitorati non contano come offline. +- Un GPU Apple Unified non aggiunge 24 GB al RAM totale del nodo; Spark NVML memory Unsupported non appare 0 GiB. +- Due GPU sullo stesso nodo rimangono due entità; il valore sintetico (max/sum) reca etichetta e significato. +- Un gap SSH interrompe la linea storica e marca stale; retry e recupero non creano duplicati o contatori negativi. +- Una visita ad AI locale con dispositivo non supportato non genera pagina bianca, ma empty state informativo e navigazione valida. +- Storico 7d, clock drift, sequenze fuori ordine, database riavviato, migrazioni e corruzione leggera hanno percorsi di recovery testati. +- Bench di inferenza senza/col collector su carico riproducibile, con almeno 5 repliche e statistica su throughput, TTFT, GPU/CPU power, collector overhead e intervalli di confidenza. +- Nessuna raccolta di prompt, output, variabili d'ambiente, token segreti o command line integrali per default. + +## 9. Metriche di accettazione (budget preliminari, da confermare con misure) + +- Idle desktop+collector: obiettivo indicativo <2% di un core logico e <150 MiB RSS endpoint sul Mac target; qualificare separatamente con scena visibile/nascosta e background/foreground. Non presentare come già garantito. +- Rendering in quiet state: nessuna animazione continua e refresh di scena solo su dati/interazione; GPU overhead ridotto al minimo durante inferenza. +- Telemetry latency: p95 da ricezione campione a UI aggiornata <1 s, con stato freshness evidente, nelle condizioni hardware previste. +- Inference impact: obiettivo <2% regressione mediana throughput in confronto paired on/off e nessuna regressione sostanziale di TTFT p95; misurare anziché promettere. +- Reliability: no data loss silenziosa; backlog e drop conteggiati e visibili. +- UI: no clipping/overlap per viewport di test, tastiera, reduced-motion, contrasti, nessuna selezione desincronizzata, eventuali stati stale distinguibili. + +## 10. Ordine di lavoro per Codex + +Prima PR consigliata, strettamente limitata: W0 - checkpoint Iced UI + correctness + deterministic QA. + +- Commit il lavoro Iced/3D su branch feature/native-workbench senza .local/segreti; allineare dipendenze/doc. +- Estrarre FleetViewModel e DeviceSceneModel derivati dall'unico stato ingestito; rimuovere ogni array hard-coded. +- Test automatici su flotta variabile e availability state. +- Rendere metric label/value/bar/tooltips un'unica componente tipizzata con unavailable supportato. +- Snapshot/interaction tests per schermate e dimensioni, con artifacts registrati in .local/qa ignorata. +- Benchmark A/B su Mac con scena idle e caricata; Exon/Spark con carico AI rappresentativo. +- Niente nuove sezioni decorative o librerie prima che i gate P0 siano superati. + +Seconda PR: W1 - design system / information architecture scaffold con fixture canoniche e schermate Overview, Fleet e Workloads; 3D ridotta e semanticamente utile. + +Terza PR: W2 - normalized timeseries + event model con migrazione SQLite e view models per query. + +Non creare 20 schermate e non introdurre un hub distribuito prima che il primo taglio verticale (una workload AI osservabile su un nodo, una timeline e un insight verificabile) funzioni davvero. + +## 11. Risorse di riferimento + +Fonti repo: README, architecture, requirements, qualification, core, agent, engine, store, packaging. + +Standards/ecosistema: Beszel, NVIDIA DCGM exporter, vLLM metrics, OpenTelemetry GenAI semantics, Netdata anomaly detection. + +## Regola del progetto + +Ogni schermo deve rispondere a una domanda; ogni numero deve spiegare la propria origine; ogni animazione deve rappresentare qualcosa; ogni nuova feature deve passare test con dati reali o fixture esplicite. diff --git a/docs/moonitor-w0-audit-and-plan.md b/docs/moonitor-w0-audit-and-plan.md new file mode 100644 index 0000000..8427998 --- /dev/null +++ b/docs/moonitor-w0-audit-and-plan.md @@ -0,0 +1,87 @@ +# Moonitor — audit operativo e piano di qualificazione + +Data: 23 ottobre 2026. Riferimento progettuale: [blueprint integrale](moonitor-professional-upgrade-blueprint.md). Questo documento descrive evidenze e gate; il blueprint resta un documento di indirizzo, non una attestazione di funzionalità. + +## Provenienza e conservazione + +- Main locale e remoto verificati con fetch: `ba91ef433e83721ea52aad6fbea0d32a30bccf84`, desktop egui/eframe+glow. +- Migrazione locale preesistente salvata senza modifiche in `f75afc0`, branch `feature/native-workbench`. Tag locale `baseline/egui-2026-10-22` sul main precedente. +- Copia aggiuntiva di diff binario, file sorgenti non tracciati e stato Git in `.local/checkpoints/20261023-124305-before-w0/`. Configurazioni, SQLite, screenshot e dati reali restano ignorati in `.local/`. +- Il bundle avviato sul Mac prima della W0 è già Iced/wgpu: la divergenza è fra main remoto e sorgenti/bundle locali, non fra un bundle egui e sorgenti Iced. Non è una release notarizzata. + +## Audit del checkpoint (prima delle correzioni W0) + +| Area | Evidenza nel codice | Giudizio / confine | +|---|---|---| +| Workspace | Quattro crate, edition 2024, MSRV 1.96 | Separazione collector/core/store valida; desktop troppo monolitico | +| UI | Iced 0.14, wgpu, Inter locale; `ui.rs` | Migrazione effettiva, non pubblicata su main. Sei tab, selezione per indice, colori chiari fissi | +| Rendering | `scene.rs`, `scene.wgsl`, `composite.wgsl` | Mesh procedurali, MSAA 4, shadow map 1024, cap offscreen 1600; cache evita render 3D continuo | +| Scena | `ANCHORS[5]`, `DEVICE_IDS[5]`, mesh `OnceLock` | Cinque asset anche con altra configurazione; shader ignora bitset live; selezione ignota ricade sul primo asset | +| Metriche | `metric(Option)`, `pct`, `unwrap_or(0)` | Perdita di motivo/fonte/qualità/timestamp; barra assente equivalente a zero; numeri mancanti negli screenshot reali | +| Stato | `NodeState::live`, ricezione monotona <4s | Base valida; denominatore online include nodi senza agente; campioni conservati devono restare esplicitamente obsoleti | +| Processi | top 64 CPU + 32 candidati AI per nome | Non è un censimento esaustivo; nessuna prova che candidato AI significhi inferenza attiva | +| GPU | NVML, IOKit; memoria unified | Buona distinzione memoria. Aggregato max perde identità nello storico; UI attribuisce la prima misura board a ogni GPU: errato su multi-GPU | +| Runtime | Ollama `/api/ps`, timeout 300ms, polling lento | Modelli residenti, non richieste/throughput/TTFT. YVEX e vLLM non implementati | +| Transport | SSH persistente, known hosts strict, batch mode, frame max 512KiB, reconnect | Riutilizzare; canale bounded 16 scarta silenziosamente su `try_send` | +| Schema | `HostSnapshot::validate` | Valida versione, ID, cardinalità, CPU; non tutte le grandezze, clock o correlazione GPU/power | +| Store | SQLite WAL, campione 5s, retention 7d, query 720 punti | NULL distinto da zero. Solo riepiloghi; nessuna migrazione versionata, nessun hub; si interrompe chiudendo il desktop | +| History | UI 5 minuti, gap >7.5s | Nessuno zoom/assi/crosshair; gap e freschezza hanno soglie diverse; dati stored 5s non equivalenti al live 1s | +| Packaging | script `.app` ad-hoc | Copia `.local/fleet.toml` nel bundle: non adatto a distribuzione. Versione statica; nessuna attestazione commit/binary | +| Test | sette test di unità/protocollo/SQLite | Nessuna prova UI, screenshot o interazioni deterministiche pre-W0 | +| CI | Ubuntu/macOS, Rust stable non pin | Run main Linux passa; Mac cancellato dal runner, non evidenza di errore sorgente. Iced locale non ancora qualificato in CI | +| Benchmark | script 32s CPU/RSS, diagnostics render | Misure esplorative, non confronto paired inference; nessun TTFT, intervallo confidenza, scene/list/background controllato | + +## Architettura target e confini + +Si conservano i quattro crate. Non si introduce un hub, una nuova UI library o una nuova infrastruttura in W0. + +- **core**: contratti di dominio/serializzazione e invarianti. W2 aggiunge entità, observation, metric descriptor, capability, event e identità dei componenti; W3 runtime/workload/energy boundary. Nessun widget. +- **agent**: adattatori read-only per piattaforma, GPU e runtime, frequenze differenziate e limiti di payload. Nessuna raccolta predefinita di prompt/output/env/command line completa. Telemetria runtime soltanto se esposta/strumentata. +- **store**: persistenza locale e query; W2 migrazioni forward-only, sample/status/source per componente, eventi, retention/downsampling. DB fallito deve essere visibile. Hub opzionale solo dopo stabilità dello storage. +- **desktop/engine**: acquisizione bounded e stato ricevuto, shutdown/reconnect; UI non esegue SSH direttamente. W2 separa ingestion e query più nettamente. +- **desktop/view_model** (W0): identità selezionata, classificazione stato, conteggi monitorati/censiti, modello scena e metrica tipizzata. Nessun collector o valore inventato. +- **desktop/scene**: geometria parametrica e rendering derivati da device ID/kind/state. Non decide disponibilità, non deduce topologia. Cache invalidata su geometria/stato/camera; elenco accessibile come alternativa. +- **desktop/ui**: componenti di presentazione; W1 introduce shell, tokens, catalogo locale e device detail dopo i gate P0. + +### ADR W0-001 — conservare Iced 0.14 + wgpu + +Decisione: conservare framework e geometrie valide. Bloccare toolchain e lockfile; aggiungere `iced_test` solo come dipendenza di sviluppo per rendering e interazioni headless. Test unitari puri non dipendono da GPU. Test visuali separati richiedono backend grafico e font dichiarati. Un cambio framework richiede un limite riproducibile con evidenze e ADR separato. + +### ADR W0-002 — identità e assenza + +Selezione per ID, mai per posizione; fallback al primo nodo soltanto se quello selezionato è stato rimosso. Flotta vuota è configurazione valida e produce empty state; >32, ID duplicati o alias non sicuri restano invalidi. Forma opzionale in config (`visual_kind`) con default generico, nessuna associazione fra ID privato e geometria nel codice. Un nodo `unavailable` è censito ma escluso dai monitorati e dagli offline. Stale conserva ultimo valore e motivo, non implica spegnimento fisico. + +### ADR W0-003 — disponibilità e potenza + +Text/bar/tooltip derivano dalla stessa metrica; zero ha barra zero e testo `0%`; assenza ha `N/D`, motivo e nessuna barra numerica. Aggregati GPU etichettati come massimo disponibile. La potenza per GPU non è attribuibile su nodi multi-GPU con schema 1: mostrare assenza anziché replicare il primo sensore. Nessun totale whole-device o Wh inventato. + +## Milestone e dipendenze + +| Milestone | Dipende da | Interventi | Gate verificabile | +|---|---|---|---| +| W0 | checkpoint immutabile | Build pin, correctness metriche/stati, scena 0–32, selezione ID, test e bench | Tutti i gate sotto; test Rust da soli non completano W0 | +| W1 | P0 W0 + review umana screenshot | Wireframe, screen map, tokens/componenti, Overview data-first, device detail | Schermate rispondono a quesito operativo, no clipping, tastiera/contrasto, screenshot approvati | +| W2 | W0 stabile, contratti W1 | Observation/capability/source, migrazioni SQLite, serie componenti/eventi, query/time ranges | Gap non collegati, migrazioni/recovery/clock verificati; desktop chiuso solo se hub esplicitamente attivo | +| W3 | W2 + runtime reale disponibile | Taglio verticale run→request→stop, token/latency reali, energy scopes/coverage, alert read-only | Chi/dove/quanto/da quando/cambiamenti con campioni originali; metriche non esposte restano assenti | +| W4 | W0–W3 qualificati | Package pulito, agent negotiation/rollback, CI multi-platform, soak≥24h, backup/privacy/export | Clean install/upgrade senza perdita, nessuna config privata nel bundle, reconnect/sleep-wake | + +## Matrice di accettazione W0 + +Stati: **PASS** solo con evidenza registrata, **FAIL** difetto riprodotto, **PENDING** non misurato. Il report di esecuzione separato aggiorna gli esiti, senza riscrivere la baseline. + +1. Checkpoint/tag e verifica nessun file `.local` tracciato; build `--locked` con toolchain pin; manifest sorgente/binary/bundle. +2. Valori 0/100/None e quattro UnavailableReason distinti; text/bar/tooltip concordi. Campioni stale dichiarati. Screenshot reale senza numeri scomparsi. +3. Flotte 0/1/2/5/16/32; >32/duplicate invalidi; 5→1 e reorder conservano identità e numero asset; click stesso ID in scena/inventario/dettaglio. Nessun live finto per iPhone. +4. Overview/AI/History/Processi a 1280×800, 1440×900 e 3840×2160 logici, light/dark, Mac/Linux; dichiarare scale factor fisico, renderer/font. Click, keyboard, scroll, tooltip, empty, stale. Nessuna auto-approvazione di un nuovo golden: baseline nuova richiede review. +5. Confronto idle scena visibile/elenco e foreground/background, CPU di un core, RSS endpoint, contatori pass render. La soglia preliminare <2%/<150MiB non è una promessa. Latenza p95 ricezione→presentazione <1s va strumentata, non dedotta dal timer 1s. +6. Exon e Spark: paired inference collector off/on, ≥5 repliche, input/model/runtime/version/hash, warmup, throughput e TTFT/p95, potenza con scope, statistiche e intervalli confidenza. Nessun carico concorrente modificato; se non isolabile dichiarare confondenti. +7. Persona diversa dallo sviluppatore identifica CPU/GPU/stato dagli screenshot e dalla build. Gate umano esplicitamente aperto finché non c'è riscontro. + +## Vincoli e rischi residui + +- Linux GUI e dark theme non diventano qualificati perché Mac compila. Il tema attuale contiene colori fissi; la matrice dark è un requisito aperto finché non implementato e ispezionato. +- Il canvas 3D richiede wgpu; un elenco senza scena riduce lavoro grafico, ma non prova fallback su macchina priva di backend GPU. Accessibilità assistiva e native behavior richiedono test reali. +- Exon è un host condiviso con carico elevato e swap già utilizzato al controllo iniziale. Build e benchmark devono avere limiti di memoria/concorrenza; non arrestare workload altrui. Spark non aveva runtime in PATH né modello piccolo già qualificato alla prima ricognizione; trovare/qualificare un runtime è una dipendenza del benchmark, non una licenza per inventarne i risultati. +- Le misure di ottobre 22 in `.local/qa` sono baseline esplorative pre-W0; non sono risultati delle nuove modifiche. +- Screenshot con dati reali, config, IP/alias e processi restano locali. Fixture QA hanno nomi generici e vengono compilate solo nei test. +- Non si promette un process manager completo, autonomia always-on, throughput AI, energia whole-device, release distribuita o approvazione visiva prima dei rispettivi gate. diff --git a/docs/moonitor-w0-qualification.md b/docs/moonitor-w0-qualification.md new file mode 100644 index 0000000..c6255bc --- /dev/null +++ b/docs/moonitor-w0-qualification.md @@ -0,0 +1,95 @@ +# W0 — implementazione e qualificazione + +23 ottobre 2026. Riferimento: [audit e gate](moonitor-w0-audit-and-plan.md). +La W0 consolida la migrazione; non equivale al redesign completo W1. + +## Interventi + +- Checkpoint della migrazione Iced `f75afc0`, branch separato e tag baseline egui. +- Blueprint integrale versionato; Rust 1.96.0 e Cargo.lock riproducibili. +- `FleetViewModel`, `DeviceSceneModel`, metriche tipizzate e selezione per ID. +- Flotta 0–32, forme configurabili, mesh/picking/stato/shader sincronizzati; nessun + array di cinque nodi nel renderer. Conteggio online esclude nodi non strumentati. +- `0%` distinto da `N/D`; barra assente quando non c'è misura; motivi e provenance + nei tooltip. Stato obsoleto esplicito; aggregato GPU etichettato massimo disponibile. +- Nessuna attribuzione della prima potenza board a tutte le GPU di un nodo. +- Drop di ingresso contati e visibili; separazione dati reali/fixture compilate solo nei test. +- Tema scuro, elenco alternativo, scorciatoie, screenshot e viewport da CLI. +- Bundle senza fleet privata: config esterna in Application Support, copia precedente + preservata, manifest con hash sorgenti e binari, firma ad-hoc verificabile. + +## Difetto di cattura riprodotto + +Lo screenshot richiesto nello stesso `Tick` che sostituisce i paragrafi catturava +un batch del renderer precedente con riferimenti deboli a testi ormai sostituiti. +La prova locale `live/dark-before-final.png` perde CPU/GPU mentre conserva una +memoria rimasta stabile. La cattura differita su un altro turno dell'event loop +(`live/dark-deferred.png`) contiene tutti i valori. La finestra reale è stata +ispezionata tramite Computer Use: numeri presenti e click su AI locale funzionante. +Il test `changing_metrics_keep_visible_glyphs` controlla i pixel dei numeri dopo +successivi aggiornamenti, oltre alla presenza del widget nel tree. Questa evidenza +spiega le catture riprodotte; non riclassifica automaticamente ogni screenshot o +video storico dell'utente come semplice problema di acquisizione. + +## Test eseguiti + +- macOS: fmt, check, clippy `-D warnings`, unit/protocol/storage tests. +- Fixture 0/1/2/5/16/32, ID invalidi/duplicati/>32, reorder e rimozione della selezione, + stati online/stale/unreachable/connecting/uninstrumented, zero e quattro motivi + unavailable, RAM unified e massimo GPU esplicito, mesh finite/identità esatta. +- UI: Overview/AI/History/Processes × 1280×800/1440×900/3840×2160 × light/dark, + snapshot doppi identici; click navigazione, picking con ID generici, input tastiera, + filtro, scroll, flotta vuota e nodo senza agente. Scala headless 2: 4K logico + produce 7680×4320 fisici. I PNG sono specifici del renderer/piattaforma. +- Linux Arch/Exon: test Rust e matrice grafica headless wgpu. Il primo run con + molti renderer nel medesimo processo falliva creando un device dopo due + screenshot 4K; isolando ogni caso in un processo, la matrice termina. Cgroup + massimo 4 GiB e due core, nessun workload preesistente arrestato. +- Mac nativo: cattura CLI e finestra reale; AX espone solo la finestra e i controlli + di sistema, non i widget. Questo è un limite di accessibilità ancora aperto. + +## Misure + +Artefatti privati in `.local/qa/w0/`. I risultati del 22 ottobre restano storici, +non vengono riutilizzati come attestazioni di questa build. + +- Exon, Qwen2.5-Coder-7B Q4_K_M già presente, llama.cpp 7156, 166 token di prompt + senza cache e 512 generati, warmup, cinque coppie off/on con ordine alternato: + mediana variazione throughput **+0,865%**, bootstrap CI95 **[−0,942%; +2,465%]**. + TTFT p95 esplorativo: **80,35 ms off / 81,87 ms on**. Nessuna regressione + mediana >2% in questo carico. Cinque richieste per condizione non qualificano + la coda di latenza sotto traffico reale. Potenza GPU board campionata a 200 ms; + CPU package e whole-device restano non misurati. +- Mac, scena visibile con diagnostica di latenza attiva: **2,40% di un core**, + RSS osservato massimo **116,83 MiB** su 30,4 s. Elenco con stessa diagnostica: + **2,11%**, **116,97 MiB**. Il budget CPU preliminare 2% non è rispettato da queste + misure strumentate; la modalità normale viene misurata separatamente. +- Contatori render: la sessione diagnostica di 240 s produce 2 passaggi scena e + 1 shadow pass; le composizioni UI continuano sui dati/interazioni, non la geometria. +- Ricezione→richiesta di redraw strumentata separatamente; non è una misura della + presentazione fisica sul display. Risultati dettagliati nel JSON di latenza. +- Spark GB10, stesso modello (entrambi gli shard SHA-256 verificati), llama.cpp 9529, + 166 token di prompt senza cache e 512 generati, cinque coppie: mediana throughput + **−0,077%**, CI95 **[−0,482%; +0,774%]**; TTFT p95 esplorativo **91,57 / 91,62 ms**. + Entrambi i benchmark raggiungono il budget mediano preliminare per questo carico; + non sono un confronto diretto fra host, avendo versioni runtime diverse. +- Ricezione→richiesta di redraw, 231 osservazioni: mediana **378,89 ms**, p95 + **962,25 ms**, massimo **1001,32 ms**. La misura non include il tempo fino ai pixel + effettivamente presentati dal display. +- Modalità normale, cinque nodi, finestra visibile, 30,36 s: **3,39% di un core**, + RSS massimo osservato **124,28 MiB**. Budget memoria rispettato, CPU **non rispettato**. + Una misura precedente è stata invalidata dall'uscita programmata del processo; + non viene utilizzata. L'ottimizzazione del desktop resta da qualificare con la W1. + +## Gate ancora aperti + +- Riconoscimento indipendente di stato/CPU/GPU e approvazione visiva da parte dell'utente. +- Accessibilità assistiva, fallback su macchina priva di backend GPU, presentazione + display p95 strumentata end-to-end e registrazione video della build. +- Soak 24 h, sleep/wake, packaging notarizzato, migrazioni/clock/history 7d e + osservabilità delle richieste in produzione appartengono alle wave successive. +- I benchmark sono su host condivisi; nessuna dichiarazione generale di impatto + inferenza <2% per tutti i runtime/modelli. Sorgenti, hash e campioni sono negli artifact. + +La nuova richiesta dell'utente di vedere il redesign avvia il [lavoro W1](moonitor-w1-interface-design.md), +con i gate esterni sopra ancora dichiarati aperti. Non si dichiara W0 universalmente qualificata. diff --git a/docs/moonitor-w1-interface-design.md b/docs/moonitor-w1-interface-design.md new file mode 100644 index 0000000..094a4c7 --- /dev/null +++ b/docs/moonitor-w1-interface-design.md @@ -0,0 +1,70 @@ +# W1 — redesign della shell e delle viste operative + +23 ottobre 2026. Dopo la consolidazione W0, l'utente ha chiarito che si aspettava +un redesign completo dell'interfaccia. Questa wave cambia struttura e gerarchia +visiva, mantenendo collector, transport, SQLite e rendering Iced/wgpu. +La qualificazione indipendente della W0 resta un gate esplicito; non viene implicata +dall'avvio del lavoro di design. + +## Screen map e contratti + +- **Overview**: situazione della flotta. KPI osservabili, andamento del nodo scelto, + Lab Map compatta, modelli residenti osservati e problemi di raccolta. +- **Fleet**: inventario di tutti i nodi con stato, capacità, contatto e accesso al dettaglio. + La mappa è una seconda rappresentazione dello stesso modello, con alternativa elenco. +- **Workloads**: runtime osservati, modelli residenti e copertura. Un modello caricato + non è dichiarato un'inferenza attiva; token/s, TTFT e code restano non disponibili. +- **Energy**: potenza GPU board realmente disponibile e sua copertura; nessuna + stima whole-device, somma di domini sovrapposti o integrazione Wh implicita. +- **Events**: capacità e diagnostica corrente. Senza persistenza eventi non si + inventa una timeline. L'empty state spiega il limite e conduce alla Fleet. +- **Explore**: storico recente già disponibile, con selettore nodo e finestre + realmente supportate. Niente selettore 7d che restituisce sempre cinque minuti. +- **Settings**: stato delle fonti/configurazione e preferenze della sessione. +- **Device detail**: switcher unico, stato e ultimo contatto, tab Summary / Compute / + Processes / Storage / Network / AI / Sensors / History; le risorse appartengono al nodo. + +## Wireframe prima dell'implementazione + +```text +┌──────────────────┬─────────────────────────────────────────────────────────────┐ +│ moonitor │ Overview Live · ultime letture [selettore nodo] │ +│ Compute lab ├─────────────────────────────────────────────────────────────┤ +│ │ Monitorati Modelli residenti GPU Potenza GPU │ +│ Overview ├─────────────────────────────────────┬───────────────────────┤ +│ Fleet │ Resource timeline │ Lab Map │ +│ Workloads │ CPU / Memoria / GPU │ 3D compatto o elenco │ +│ Energy │ percentuali + scala + tempo │ stato + selezione │ +│ Events ├─────────────────────────────────────┴───────────────────────┤ +│ Explore │ Modelli osservati: runtime / nodo / modello / memoria │ +│ ├─────────────────────────────────────────────────────────────┤ +│ Settings │ Stato delle sorgenti e problemi di raccolta │ +│ │ │ +│ Raccolta / errori │ │ +└──────────────────┴─────────────────────────────────────────────────────────────┘ +``` + +Dettaglio nodo: header compatto e switcher; tab orizzontali; metriche e grafici +prima dei dati granulari. Fleet: righe comparabili con intera riga selezionabile. +Nessuna lista dispositivi duplicata nella navigazione primaria. + +## Design system + +- Canvas caldo `#F6F8F5`, superficie bianca, testo `#17251F`; palette scura equivalente. +- Verde salvia come selezione/enfasi; stato accompagnato sempre da testo. +- Inter locale, numeri allineati; heading 24–28, card 13–14, metadati ≥11. +- Sidebar 220, topbar compatta, spaziatura 8/16/24, bordi sottili, raggi 10–14. +- Densità guidata dai dati, non da grandi vuoti. Overview verificata a 1280×800. +- Luna fornita dall’utente come asset originale, con derivati PNG e icona macOS. +- Animazione soltanto su input/stato. Camera 3D, forme e shader della W0 riutilizzati. +- Tooltip di fonte/qualità/timestamp; assenza distinta da zero e da dato obsoleto. + +## Accettazione + +1. Tutti i percorsi principali navigabili, selezione ID coerente fra mappa, Fleet e dettaglio. +2. Overview mostra KPI, andamento e mappa nel viewport di riferimento senza scroll profondo. +3. Nessun workload, evento, watt totale o numero AI inventato; capacità assente esplicita. +4. Screenshot light/dark e test di click/tastiera/filtri mantenuti; nuove baseline + sono candidate, non approvate automaticamente. +5. Rendering senza regressione di loop continuo; nuova build e bundle identificabili. +6. Review umana dell'interfaccia separata dai test automatici. W2–W4 non dichiarate implementate. diff --git a/docs/moonitor-w1-qualification.md b/docs/moonitor-w1-qualification.md new file mode 100644 index 0000000..86d909e --- /dev/null +++ b/docs/moonitor-w1-qualification.md @@ -0,0 +1,137 @@ +# W1 — redesign nativo e identità dell'app + +23 ottobre 2026. Segue la [specifica W1](moonitor-w1-interface-design.md) e il +[report W0](moonitor-w0-qualification.md). L'avvio del redesign è autorizzato dalla +correzione esplicita dell'utente dopo la W0; non implica il superamento automatico +dei gate esterni W0. Il layout precedente è stato rifiutato dall'utente. + +## Cambiamento verificabile + +- Shell nuova con Overview, Fleet, Workloads, Energy, Events, Explore e Settings; + sidebar senza seconda lista dei dispositivi, top bar persistente e switcher per ID. +- Overview con quattro indicatori derivati da osservazioni correnti, andamento CPU / + RAM / GPU sopra il fold, mappa compatta, modelli osservati e stato delle sorgenti. +- Grafico condiviso con scala percentuale, tempo di ricezione, cursore e campione + puntuale. I gap, valori assenti, timestamp duplicati o inversi non generano segmenti. + L'ispezione non sceglie campioni lontani attraverso una lacuna. +- Fleet confrontabile con intestazioni allineate, righe selezionabili e disponibilità + esplicita. Click su nodo/mappa apre il dettaglio; lo switcher conserva la vista. +- Dettaglio con Summary, Compute, Sensori, AI locale, Processi, Archiviazione, Rete e + Storico. Senza campione viene mostrata una spiegazione e una via di ritorno; non si + etichetta più un dispositivo mai strumentato come ultima lettura obsoleta. +- Lab Map alimentata dal modello W0: etichette con fondo leggibile, separazione delle + etichette vicine, selezione e stati; controlli camera e alternativa elenco. +- Palette chiara/scura, componenti metrici condivisi, barre e avvisi leggibili anche + nel tema scuro. Nessun cambio di framework o di collector. +- Luna fornita dall'utente nel README, nella sidebar, nell'icona finestra e nel bundle + macOS. Originale conservato identico: SHA-256 + `29d498e83a19c74fc1713f4bf2ad93c76bd27828c29881cb21f64bf14667766d`. + [Procedura icone](../assets/brand/README.md), conversione standard 16–1024 px. + +## Semantica e limiti delle viste + +Workloads espone residenza dei modelli e disponibilità dell'adapter Ollama; non +rappresenta richieste, throughput o TTFT inventati. Energy espone potenza GPU board +con copertura, non potenza totale del laboratorio né Wh. Events mostra diagnostica +corrente e spiega l'assenza di una timeline persistita. Explore usa i cinque minuti +realmente disponibili in memoria; non simula selettori 24h/7d. Processi rimane un +campione limitato, non un censimento completo. UI in italiano con nomi delle sezioni +in inglese: un catalogo di localizzazione completo resta da implementare. + +`ui/workbench.rs` compone le viste; `ui/chart.rs` gestisce il grafico; `ui.rs` mantiene +stato, input, metriche e dettagli delle risorse; `view_model.rs` conserva le invarianti +W0. Nessuna nuova sorgente, hub, servizio, porta o modifica di protocollo/storage. +L'unica feature UI aggiunta è il rendering immagini di Iced per l'icona fornita. + +## Verifica riproducibile + +```sh +scripts/check.sh +MOONITOR_QA_DIR="$PWD/.local/qa/w1/visual-current" scripts/qa-ui.sh +scripts/package-macos.sh +``` + +La prima esecuzione registra candidati, non un'approvazione umana. Le successive +confrontano i PNG. Ogni caso viene anche renderizzato due volte e deve restituire +pixel identici con osservazioni e clock congelati. Le fixture sono `cfg(test)`, con +nomi QA espliciti, stati misti e lacune nelle serie; non sono una modalità del prodotto. + +- 18 test Rust workspace: 3 agent, 14 desktop, 1 store; fmt/check/clippy con warning + trattati come errori. Cinque test grafici sono eseguiti separatamente dal runner. +- 60 casi per piattaforma: dieci viste × 1280×800 / 1440×900 / 3840×2160 × light/dark. + Overview/Fleet/Workloads/Energy/Events/Explore/Settings e AI/History/Processes del nodo. +- Elenco alternativo: due catture light/dark aggiuntive, selezione del dispositivo + e contrasto delle righe; verifica aggiunta dopo la prova della finestra reale. +- Controlli di visibilità dei valori CPU/GPU, timeline, mappa e modelli nel viewport; + allineamento delle intestazioni Fleet; stabilità della selezione; filtro e tastiera; + picking della scena; scroll; nodo non strumentato; flotta 0/1/2/5/16/32. +- Snapshot dei numeri dopo aggiornamenti successivi con verifica dei pixel dei glifi, + non solo presenza del testo nel tree. Test di regressione su gap/assenza/clock inverso. +- macOS: finestra reale ispezionata in entrambi i temi, letture dei cinque nodi, + cursore del grafico, Fleet, dettaglio, Workloads e scorciatoie Cmd+1…7. +- Linux/Exon: renderer headless wgpu/NVIDIA; lavoro confinato a due core e 4 GiB, + senza fermare servizi o workload esistenti. Non equivale a una sessione interattiva + X11/Wayland completa. + +L'accumulo di molti device headless nello stesso processo falliva su NVIDIA con +`Create new headless renderer`. Il runner ora isola sia ogni test d'interazione sia +ogni viewport in un processo, così le allocazioni vengono rilasciate. Il fallimento +iniziale e i log successivi restano negli artifact; non è stato ignorato un test. + +Il test elenco ha inoltre intercettato il caricamento asincrono del logo PNG: il +primo frame poteva precedere l'upload e differire dal secondo. Il piccolo asset viene +ora decodificato una sola volta in RGBA; Iced carica questo handle sincronicamente. +Il doppio screenshot elenco e il confronto Overview con la baseline esistente +verificano la correzione senza introdurre attese arbitrarie nella UI. + +Artifact locali ignorati: `.local/qa/w1/`. Le matrici anteriori `visual-v2`, +`visual-final` e `visual-brand` documentano le iterazioni; la matrice della sorgente +corrente è `visual-current`. Log: `checks-current.log`, `visual-current-macos.log`, +`linux/qualification-current.log`. Catture live: `brand-dark-live.png` e +`overview-final-1440.png` e `current-dark-live.png`; i nomi `fixture-*` indicano dati sintetici di test. + +## Prestazioni e gate aperti + +Build con icona raster finale, cinque nodi, Overview dark 1440×900 attivata con +Cmd+1, 30,36 s: CPU **1,61% di un core logico**, RSS massimo **138,94 MiB**. +Artifact: `performance-current-visible.json`. In questa finestra i due budget +preliminari sono rispettati; non è una qualificazione statistica o di lunga durata. +La modalità elenco, stessa finestra e cinque nodi in una sessione separata di +30,35 s, misura **1,65% di un core** e **134,84 MiB RSS massimo** +(`performance-current-list.json`). La differenza CPU è compatibile con il rumore +di queste brevi osservazioni; non prova una differenza causale tra 3D ed elenco. + +La prima misura W1 (prima dell'icona raster), cinque nodi, Overview visibile a +1440×900, 30,35 s: CPU **2,17% di un core logico**, RSS massimo **143,02 MiB**. +È una finestra esplorativa, non una distribuzione statistica né un confronto paired +con W0; il target CPU <2% non è attestato. I collector non sono cambiati: i benchmark +paired di inferenza Exon/Spark restano descritti nella W0, senza estendere il risultato +alla UI nuova o a runtime/modelli non misurati. + +Restano aperti: approvazione visiva dell'utente sul nuovo layout; accessibilità dei +widget alle tecnologie assistive (AX espone solo la finestra); avvio senza backend +GPU; profilo rigoroso foreground/background e latenza fino alla presentazione fisica; +video di sessione; soak e sleep/wake. La UI ha tastiera e modalità senza scena, ma +questi due fatti non dimostrano accessibilità completa o assenza di dipendenza GPU. + +Prossimo lavoro: chiudere la review W1 e i budget misurati, poi W2 — modello delle +osservazioni/serie/eventi, migrazioni SQLite e query storiche. Le metriche di richiesta +AI e l'energia integrata appartengono alla W3. Nessuna di queste capacità è dichiarata +realizzata dal redesign. + +## Esito delle verifiche + +macOS e Linux: 18 test Rust passati, matrice delle 60 viste passata, più i test +grafici di metriche aggiornate, picking/interazioni e le due catture elenco. +Dopo il caricamento RGBA del logo, ripetuti i controlli mirati elenco e il confronto +Overview con la baseline già registrata; entrambi passano. Nessun gate umano è +considerato approvato da questi test. + +## Identità del pacchetto + +Il manifest nel bundle registra commit, stato dirty, hash delle sorgenti e del binario +di build. La firma macOS cambia i byte Mach-O: confrontare direttamente il suo SHA +con il binario non impacchettato sarebbe scorretto. La ricevuta esterna +`.local/moonitor-build-receipt.json` registra separatamente hash di build, eseguibile +firmato, manifest e icona. Il packaging verifica la firma prima di scrivere la ricevuta. +La ricevuta resta esterna per evitare un riferimento circolare dentro la firma. diff --git a/docs/qualification.md b/docs/qualification.md index 9d1375e..d3d60cd 100644 --- a/docs/qualification.md +++ b/docs/qualification.md @@ -1,3 +1,5 @@ +> Historical qualification of the initial desktop. For the current Iced/wgpu branch use [W0 qualification](moonitor-w0-qualification.md); results below are not evidence for the new changes. + # First working version — 2026-10-22 ## Verified diff --git a/docs/roadmap.md b/docs/roadmap.md index eba6fff..24f789c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,3 +1,5 @@ +> Historical implementation sequence. The active roadmap is now [W0–W4](moonitor-w0-audit-and-plan.md), based on the [professional blueprint](moonitor-professional-upgrade-blueprint.md). Iced/wgpu migration is checkpointed; see [current qualification](moonitor-w0-qualification.md). + # Implementation sequence ## 0. Repository foundation diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..c2294d8 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.96.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/benchmark-inference.py b/scripts/benchmark-inference.py new file mode 100644 index 0000000..9bcdd76 --- /dev/null +++ b/scripts/benchmark-inference.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Paired local llama.cpp HTTP inference, collector off/on. No prompts or outputs persisted. +Run on the target node, inside a bounded cgroup. Own processes are always stopped. +""" +import argparse, hashlib, json, math, pathlib, random, socket, statistics, subprocess, threading, time, urllib.request + +def sha(path): + h=hashlib.sha256() + with open(path,'rb') as f: + while chunk:=f.read(4*1024*1024):h.update(chunk) + return h.hexdigest() +def terminate(p): + if p and p.poll() is None: + p.terminate() + try:p.wait(10) + except subprocess.TimeoutExpired:p.kill();p.wait() +def request(url,payload,timeout=90): + body=json.dumps(payload).encode() + req=urllib.request.Request(url,body,{'Content-Type':'application/json'}) + started=time.perf_counter();first=None;timings=None + with urllib.request.urlopen(req,timeout=timeout) as response: + for line in response: + if not line.startswith(b'data: '):continue + data=json.loads(line[6:]) + if data.get('content') and first is None:first=time.perf_counter()-started + if data.get('stop'):timings=data.get('timings') + if not timings or first is None:raise RuntimeError('Runtime did not expose completion timing/first token') + return {'ttft_seconds':first,'elapsed_seconds':time.perf_counter()-started,'timings':timings} +def proc(pid): + s=pathlib.Path(f'/proc/{pid}/stat').read_text().rsplit(')',1)[1].split() + rss=next(int(l.split()[1]) for l in pathlib.Path(f'/proc/{pid}/status').read_text().splitlines() if l.startswith('VmRSS:')) + import os + return {'at':time.perf_counter(),'cpu_seconds':(int(s[11])+int(s[12]))/os.sysconf('SC_CLK_TCK'),'rss_mib':rss/1024} +def quantile(xs,p): + ys=sorted(xs);at=(len(ys)-1)*p;lo=math.floor(at);hi=math.ceil(at) + return ys[lo]+(ys[hi]-ys[lo])*(at-lo) +def bootstrap(xs): + rng=random.Random(23);medians=[statistics.median(rng.choices(xs,k=len(xs))) for _ in range(10000)] + return [quantile(medians,.025),quantile(medians,.975)] +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--server',required=True,type=pathlib.Path);parser.add_argument('--model',required=True,type=pathlib.Path) + parser.add_argument('--agent',required=True,type=pathlib.Path);parser.add_argument('--output',required=True,type=pathlib.Path) + parser.add_argument('--replicates',type=int,default=5);args=parser.parse_args() + if args.replicates<5:parser.error('At least five paired replications are required') + existing=[] + for entry in pathlib.Path('/proc').iterdir(): + try: + if entry.name.isdigit() and (entry/'comm').read_text().strip()=='moonitor-agent':existing.append(entry.name) + except OSError:pass + if existing:raise RuntimeError('A collector is already running; off/on would be invalid') + args.output.parent.mkdir(parents=True,exist_ok=True) + with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] + url=f'http://127.0.0.1:{port}' + results={'schema':1,'fixture_prompt':True,'prompt_output_stored':False,'model_sha256':sha(args.model),'model_parts_sha256':{p.name:sha(p) for p in sorted(args.model.parent.glob(args.model.name.split('-00001-of-')[0]+'-*-of-*.gguf'))},'runtime_sha256':sha(args.server),'agent_sha256':sha(args.agent),'runtime_version':subprocess.check_output([str(args.server),'--version'],stderr=subprocess.STDOUT,text=True).strip(),'replicates':[],'limits':{'threads':2,'context':1024,'tokens':512,'gpu_layers':99},'power_scope':'GpuBoard','cpu_power_watts':None,'limitations':['Shared host; concurrent user workloads are not stopped','TTFT p95 from only five requests per condition is exploratory','GPU power polling adds equal observer overhead to both conditions','No whole-device or per-run energy attribution']} + server=agent=power=None;power_samples=[] + def read_power(): + for raw in power.stdout: + try: + watts,util=map(float,raw.strip().split(','));power_samples.append((time.perf_counter(),watts,util)) + except ValueError:pass + try: + with (args.output.parent/'runtime.log').open('w') as log: + server=subprocess.Popen([str(args.server),'-m',str(args.model),'--host','127.0.0.1','--port',str(port),'-c','1024','-ngl','99','-t','2','-b','128','-ub','128','--parallel','1'],stdout=log,stderr=log) + deadline=time.monotonic()+180 + while True: + if server.poll() is not None:raise RuntimeError('Dedicated runtime exited; inspect runtime.log') + try: + with urllib.request.urlopen(url+'/health',timeout=1) as r: + if r.status==200:break + except Exception: + if time.monotonic()>deadline:raise RuntimeError('Runtime startup timed out') + time.sleep(.5) + # Fixed non-private fixture; disable prompt cache for each measured request. + base=('Explain how a CPU and a GPU perform matrix multiplication. Use precise technical language. ' * 10) + def payload(pair):return {'prompt':f'Benchmark case {pair}. '+base,'n_predict':512,'temperature':0,'seed':23,'ignore_eos':True,'cache_prompt':False,'stream':True} + request(url+'/completion',payload(-1)) + try: + power=subprocess.Popen(['nvidia-smi','--query-gpu=power.draw,utilization.gpu','--format=csv,noheader,nounits','--loop-ms=200'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True) + threading.Thread(target=read_power,daemon=True).start() + except OSError:results['power_scope']=None + for pair in range(args.replicates): + row={'pair':pair,'order':['off','on'] if pair%2==0 else ['on','off']} + for condition in row['order']: + if condition=='on': + agent=subprocess.Popen([str(args.agent),'--node-id','benchmark','--interval-ms','1000'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + time.sleep(3) + if agent.poll() is not None:raise RuntimeError('Collector exited') + before=proc(agent.pid) + start=time.perf_counter();measurement=request(url+'/completion',payload(pair));end=time.perf_counter() + if measurement['timings'].get('cache_n',0)!=0 or measurement['timings']['predicted_n']!=512:raise RuntimeError('Unexpected prompt caching or token count') + if agent: + after=proc(agent.pid);measurement['collector_cpu_percent_one_core']=(after['cpu_seconds']-before['cpu_seconds'])/(after['at']-before['at'])*100 + measurement['collector_rss_endpoint_mib']=max(before['rss_mib'],after['rss_mib']);terminate(agent);agent=None + watts=[w for t,w,u in power_samples if start<=t<=end] + measurement['gpu_board_watts_mean']=statistics.mean(watts) if watts else None + measurement['gpu_power_sample_count']=len(watts) + row[condition]=measurement + time.sleep(1) + results['replicates'].append(row) + args.output.write_text(json.dumps(results,indent=2)+'\n') + finally: + terminate(agent);terminate(power);terminate(server) + changes=[(r['on']['timings']['predicted_per_second']/r['off']['timings']['predicted_per_second']-1)*100 for r in results['replicates']] + results['summary']={'throughput_paired_change_percent_median':statistics.median(changes),'throughput_paired_change_bootstrap_ci95':bootstrap(changes),'ttft_off_p95_seconds':quantile([r['off']['ttft_seconds'] for r in results['replicates']],.95),'ttft_on_p95_seconds':quantile([r['on']['ttft_seconds'] for r in results['replicates']],.95),'throughput_preliminary_target_met':statistics.median(changes)>-2} + args.output.write_text(json.dumps(results,indent=2)+'\n');print(json.dumps(results['summary'],indent=2)) +if __name__=='__main__':main() diff --git a/scripts/build-icons.sh b/scripts/build-icons.sh new file mode 100755 index 0000000..8b947e6 --- /dev/null +++ b/scripts/build-icons.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Deterministic size conversion of the supplied artwork; original pixels retained. +set -eu +cd "$(dirname "$0")/.." +source_icon=assets/brand/moon-original.png +iconset_dir=.local/moonitor.iconset +mkdir -p "$iconset_dir" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$source_icon" --out "$iconset_dir/icon_${size}x${size}.png" >/dev/null + retina=$((size * 2)) + sips -z "$retina" "$retina" "$source_icon" --out "$iconset_dir/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$iconset_dir" -o .local/moonitor.icns diff --git a/scripts/build-manifest.py b/scripts/build-manifest.py new file mode 100644 index 0000000..5308ba7 --- /dev/null +++ b/scripts/build-manifest.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Record source and artifact hashes without including private configuration.""" +import hashlib, json, pathlib, subprocess, sys +root=pathlib.Path(__file__).resolve().parents[1] +def run(*args):return subprocess.check_output(args,cwd=root,text=True).strip() +paths=run('git','ls-files','-co','--exclude-standard').splitlines() +source={p:hashlib.sha256((root/p).read_bytes()).hexdigest() for p in sorted(set(paths)) if (root/p).is_file()} +manifest={'commit':run('git','rev-parse','HEAD'),'dirty':bool(run('git','status','--porcelain')),'rustc':run('rustc','-Vv'),'source_sha256':hashlib.sha256(json.dumps(source,sort_keys=True).encode()).hexdigest(),'files':source,'binaries':{}} +for name in ['moonitor','moonitor-agent']: + p=root/'target/release'/name + if p.exists():manifest['binaries'][name]=hashlib.sha256(p.read_bytes()).hexdigest() +path=pathlib.Path(sys.argv[1]);path.parent.mkdir(parents=True,exist_ok=True);path.write_text(json.dumps(manifest,indent=2)+'\n') diff --git a/scripts/build-receipt.py b/scripts/build-receipt.py new file mode 100644 index 0000000..de1f8a5 --- /dev/null +++ b/scripts/build-receipt.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Record signed bundle hashes outside the signature's own resource envelope.""" +import hashlib +import json +import pathlib +import sys + +app = pathlib.Path(sys.argv[1]) +output = pathlib.Path(sys.argv[2]) +root = pathlib.Path(__file__).resolve().parents[1] + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + +manifest_path = app / 'Contents/Resources/build-manifest.json' +manifest = json.loads(manifest_path.read_text()) +unsigned = digest(root / 'target/release/moonitor') +assert unsigned == manifest['binaries']['moonitor'], 'Build changed after manifest creation' +receipt = { + 'commit': manifest['commit'], + 'dirty': manifest['dirty'], + 'source_sha256': manifest['source_sha256'], + 'build_binary_sha256': unsigned, + 'signed_bundle_binary_sha256': digest(app / 'Contents/MacOS/moonitor'), + 'manifest_sha256': digest(manifest_path), + 'icon_sha256': digest(app / 'Contents/Resources/moonitor.icns'), + 'note': 'Signing changes Mach-O bytes. Build and signed hashes intentionally differ.', +} +output.parent.mkdir(parents=True, exist_ok=True) +output.write_text(json.dumps(receipt, indent=2) + '\n') diff --git a/scripts/measure-process.py b/scripts/measure-process.py new file mode 100644 index 0000000..28fcd6f --- /dev/null +++ b/scripts/measure-process.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Measure an existing owned desktop process; CPU is percent of one logical core.""" +import argparse,json,pathlib,subprocess,time,statistics +p=argparse.ArgumentParser(description=__doc__);p.add_argument('pid',type=int);p.add_argument('output',type=pathlib.Path);p.add_argument('--seconds',type=int,default=30);p.add_argument('--label',required=True);a=p.parse_args() +if not 10<=a.seconds<=300:p.error('Use 10–300 seconds') +def sample(): + fields=subprocess.check_output(['ps','-p',str(a.pid),'-o','time=,rss='],text=True).split() + parts=fields[0].split(':');cpu=sum(float(n)*60**i for i,n in enumerate(reversed(parts))) + return {'at':time.monotonic(),'cpu_seconds':cpu,'rss_mib':int(fields[1])/1024} +rows=[sample()] +for _ in range(a.seconds):time.sleep(1);rows.append(sample()) +r={'label':a.label,'pid':a.pid,'window_seconds':rows[-1]['at']-rows[0]['at'],'cpu_percent_one_core':(rows[-1]['cpu_seconds']-rows[0]['cpu_seconds'])/(rows[-1]['at']-rows[0]['at'])*100,'rss_observed_max_mib':max(r['rss_mib'] for r in rows),'rss_median_mib':statistics.median(r['rss_mib'] for r in rows),'samples':rows} +a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(json.dumps(r,indent=2)+'\n');print(json.dumps({k:v for k,v in r.items() if k!='samples'},indent=2)) diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 9d67387..42cd559 100755 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -12,8 +12,23 @@ fi app_dir="$repo_dir/.local/moonitor.app" mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" cp target/release/moonitor "$app_dir/Contents/MacOS/moonitor" +scripts/build-icons.sh +cp .local/moonitor.icns "$app_dir/Contents/Resources/moonitor.icns" if [ ! -f .local/fleet.toml ]; then cp config/fleet.example.toml .local/fleet.toml; fi -cp .local/fleet.toml "$app_dir/Contents/Resources/fleet.toml" +config_dir="$HOME/Library/Application Support/moonitor" +mkdir -p "$config_dir" +# Preserve an existing user configuration. Never embed private fleet data in a bundle. +if [ ! -e "$config_dir/fleet.toml" ]; then + cp .local/fleet.toml "$config_dir/fleet.toml" + chmod 600 "$config_dir/fleet.toml" +fi +if [ -f "$app_dir/Contents/Resources/fleet.toml" ]; then + backup_dir="$repo_dir/.local/checkpoints/$(date -u +%Y%m%dT%H%M%SZ)-bundle-config" + mkdir -p "$backup_dir" + cp "$app_dir/Contents/Resources/fleet.toml" "$backup_dir/fleet.toml" + rm "$app_dir/Contents/Resources/fleet.toml" +fi +python3 scripts/build-manifest.py "$app_dir/Contents/Resources/build-manifest.json" cat > "$app_dir/Contents/Info.plist" <<'PLIST' @@ -21,6 +36,7 @@ cat > "$app_dir/Contents/Info.plist" <<'PLIST' CFBundleExecutablemoonitor CFBundleIdentifierdev.maiomascio.moonitor CFBundleNamemoonitor +CFBundleIconFilemoonitor.icns CFBundlePackageTypeAPPL CFBundleShortVersionString0.1.0 CFBundleVersion1 @@ -29,4 +45,6 @@ cat > "$app_dir/Contents/Info.plist" <<'PLIST' PLIST codesign --force --sign - "$app_dir" +codesign --verify --deep --strict "$app_dir" +python3 scripts/build-receipt.py "$app_dir" .local/moonitor-build-receipt.json printf '%s\n' "$app_dir" diff --git a/scripts/qa-ui.sh b/scripts/qa-ui.sh new file mode 100755 index 0000000..a30bd69 --- /dev/null +++ b/scripts/qa-ui.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Renderer-specific candidates on first run; pixel comparisons on later runs. +# Each viewport owns a process/device so large Vulkan snapshots release all allocations. +set -eu +cd "$(dirname "$0")/.." +export MOONITOR_QA_DIR="${MOONITOR_QA_DIR:-$PWD/.local/qa/w1/visual}" +mkdir -p "$MOONITOR_QA_DIR" +for test in list_alternative_selection_and_theme changing_metrics_keep_visible_glyphs metric_rendering scene_picking_filter_keyboard_and_scroll visual_matrix_and_interactions; do + MOONITOR_QA_CASE=fleet cargo test --locked -p moonitor-desktop "ui::qa::$test" -- --ignored --exact --test-threads=1 +done +for size in 1280x800 1440x900 3840x2160; do + for tab in overview fleet workloads energy events explore settings ai history processes; do + for theme in light dark; do + MOONITOR_QA_CASE="$tab-$size-$theme" cargo test --locked -p moonitor-desktop ui::qa::visual_matrix_and_interactions -- --ignored --exact --test-threads=1 + done + done +done