From 51b7d8f3a506df99a85cd16254d69723ae52276d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Mon, 16 Mar 2026 20:15:47 +0100 Subject: [PATCH 01/46] create a minimal PoC with iced --- Cargo.lock | 2928 +++++++++++++++++++++++++- Cargo.toml | 3 +- paddler_second_brain_gui/Cargo.toml | 10 + paddler_second_brain_gui/src/main.rs | 30 + 4 files changed, 2874 insertions(+), 97 deletions(-) create mode 100644 paddler_second_brain_gui/Cargo.toml create mode 100644 paddler_second_brain_gui/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 02d2d72b..8f5e2666 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "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 = "actix" version = "0.13.5" @@ -367,6 +383,51 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.11.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-build" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cac4c64175d504608cf239756339c07f6384a476f97f20a7043f92920b0b8fd" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -461,6 +522,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "as-slice" version = "0.2.1" @@ -470,6 +537,15 @@ dependencies = [ "stable_deref_trait", ] +[[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 = "askama" version = "0.14.0" @@ -494,7 +570,7 @@ dependencies = [ "memchr", "proc-macro2", "quote", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_derive", "syn", @@ -512,6 +588,120 @@ dependencies = [ "winnow", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -534,6 +724,12 @@ dependencies = [ "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -572,7 +768,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror", + "thiserror 2.0.18", "v_frame", "y4m", ] @@ -630,7 +826,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn", ] @@ -677,6 +873,12 @@ dependencies = [ "core2", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -686,6 +888,37 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -736,6 +969,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder" @@ -783,6 +1030,57 @@ dependencies = [ "crossbeam-channel", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.0", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "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.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.2.56" @@ -795,6 +1093,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -867,6 +1171,45 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.18", + "x11rb", +] + [[package]] name = "cmake" version = "0.1.57" @@ -876,6 +1219,17 @@ dependencies = [ "cc", ] +[[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]] name = "color_quant" version = "1.1.0" @@ -888,6 +1242,25 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes 1.11.1", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -948,37 +1321,96 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "core2" -version = "0.4.0" +name = "core-graphics" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ - "memchr", + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", ] [[package]] -name = "core_maths" -version = "0.1.1" +name = "core-graphics-types" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ - "libm", + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", ] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "core-graphics-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", "libc", ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "core2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[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.11.0", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa", + "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1023,6 +1455,19 @@ 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.1", + "wgpu", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1054,6 +1499,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctor-lite" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "dashmap" version = "6.1.0" @@ -1150,6 +1607,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1161,6 +1634,36 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "either" version = "1.15.0" @@ -1192,6 +1695,12 @@ dependencies = [ "serde", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enumflags2" version = "0.7.12" @@ -1199,6 +1708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", + "serde", ] [[package]] @@ -1271,6 +1781,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "esbuild-metafile" version = "0.5.2" @@ -1288,6 +1804,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + [[package]] name = "euclid" version = "0.22.13" @@ -1297,6 +1823,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.0" @@ -1418,6 +1965,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 = "fontconfig-parser" version = "0.5.8" @@ -1447,7 +2003,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1456,6 +2033,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1539,6 +2122,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1590,6 +2186,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1638,12 +2244,111 @@ dependencies = [ "weezl", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + [[package]] name = "h2" version = "0.3.27" @@ -1690,9 +2395,23 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "core_maths", + "read-fonts", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1731,6 +2450,18 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hf-hub" version = "0.4.3" @@ -1749,7 +2480,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "ureq", "windows-sys 0.60.2", @@ -1891,25 +2622,209 @@ dependencies = [ ] [[package]] -name = "icu_collections" -version = "2.1.1" +name = "iced" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "thiserror 2.0.18", ] [[package]] -name = "icu_locale_core" -version = "2.1.1" +name = "iced_core" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" dependencies = [ - "displaydoc", + "bitflags 2.11.0", + "bytes 1.11.1", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.1", + "smol_str", + "thiserror 2.0.18", + "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 0.3.32", + "iced_core", + "log", + "rustc-hash 2.1.1", + "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.11.0", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "raw-window-handle", + "rustc-hash 2.1.1", + "thiserror 2.0.18", + "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.18", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes 1.11.1", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo 0.10.4", + "log", + "rustc-hash 2.1.1", + "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.11.0", + "bytemuck", + "cryoglyph", + "futures 0.3.32", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "rustc-hash 2.1.1", + "thiserror 2.0.18", + "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.1", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "mundy", + "rustc-hash 2.1.1", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", "litemap", "tinystr", "writeable", @@ -2199,6 +3114,28 @@ dependencies = [ "syn", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -2246,6 +3183,33 @@ dependencies = [ "uuid-simd", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.0" @@ -2319,9 +3283,33 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "lilt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67562e5eff6b20553fa9be1c503356768420994e28f67e3eafe6f41910e57ad" +dependencies = [ + "web-time", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2334,6 +3322,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "llama-cpp-bindings" version = "0.2.2" @@ -2343,7 +3337,7 @@ dependencies = [ "encoding_rs", "enumflags2", "llama-cpp-bindings-sys", - "thiserror", + "thiserror 2.0.18", "tracing", "tracing-core", ] @@ -2412,6 +3406,21 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2443,6 +3452,30 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -2522,6 +3555,57 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mundy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523813c9e194ec43693805214eb112551f99382115b67f38600d724a692e7e8b" +dependencies = [ + "android-build", + "async-io", + "cfg-if", + "dispatch", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -2548,6 +3632,36 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2685,6 +3799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2698,20 +3813,401 @@ dependencies = [ ] [[package]] -name = "number_prefix" -version = "0.4.0" +name = "num_enum" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] [[package]] -name = "once_cell" -version = "1.21.3" +name = "num_enum_derive" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "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.11.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[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", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" @@ -2723,7 +4219,7 @@ checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.11.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -2765,12 +4261,50 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[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]] name = "paddler" version = "3.0.1" @@ -2816,7 +4350,7 @@ dependencies = [ "serde_json", "shellexpand", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-tungstenite", @@ -2836,7 +4370,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-tungstenite", @@ -2866,6 +4400,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "paddler_second_brain_gui" +version = "3.0.1" +dependencies = [ + "iced", +] + [[package]] name = "paddler_types" version = "3.0.1" @@ -2876,6 +4417,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2894,7 +4441,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2923,6 +4470,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2936,11 +4503,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "pkg-config" -version = "0.3.32" +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.17.16" @@ -2967,6 +4551,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3006,6 +4604,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3026,6 +4630,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3075,6 +4688,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -3155,6 +4777,18 @@ dependencies = [ "getrandom 0.3.4", ] +[[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.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "rav1e" version = "0.8.1" @@ -3185,7 +4819,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "simd_helpers", - "thiserror", + "thiserror 2.0.18", "v_frame", "wasm-bindgen", ] @@ -3205,6 +4839,12 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.11.0" @@ -3225,6 +4865,26 @@ dependencies = [ "crossbeam-utils", ] +[[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", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3234,6 +4894,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3242,7 +4911,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -3315,6 +4984,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.12.28" @@ -3448,6 +5123,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3463,6 +5144,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3472,7 +5166,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3568,12 +5262,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" 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 = "sdd" version = "3.0.10" @@ -3682,6 +5395,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3798,6 +5522,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" @@ -3825,6 +5559,78 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "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.11.0", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.5.10" @@ -3856,12 +5662,58 @@ dependencies = [ "winapi", ] +[[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.4", + "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.4", + "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.11.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict-num" version = "0.1.1" @@ -3883,16 +5735,33 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + [[package]] name = "svgtypes" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo", + "kurbo 0.13.0", "siphasher", ] +[[package]] +name = "swash" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "2.0.117" @@ -3924,6 +5793,15 @@ dependencies = [ "syn", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -3954,10 +5832,19 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -3967,13 +5854,33 @@ dependencies = [ "smawk", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -4058,6 +5965,19 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-xlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +dependencies = [ + "as-raw-xcb-connection", + "ctor-lite", + "libloading", + "pkg-config", + "tracing", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4179,6 +6099,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -4285,7 +6235,7 @@ dependencies = [ "log", "rand 0.9.2", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -4295,6 +6245,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicase" version = "2.9.0" @@ -4331,6 +6292,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-properties" version = "0.1.4" @@ -4423,7 +6390,7 @@ dependencies = [ "flate2", "fontdb", "imagesize", - "kurbo", + "kurbo 0.13.0", "log", "pico-args", "roxmltree 0.21.1", @@ -4458,12 +6425,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "uuid-simd" -version = "0.8.0" +name = "uuid" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "outref", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", "vsimd", ] @@ -4652,79 +6630,505 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.91" +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures 0.3.32", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "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.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "429b99200febaf95d4f4e46deff6fe4382bcff3280ee16a41cf887b3c3364984" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.11.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "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 = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.0", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "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", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "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.18", +] + +[[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 0.52.6", +] + +[[package]] +name = "windows" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "js-sys", - "wasm-bindgen", + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "js-sys", - "wasm-bindgen", + "windows-core 0.62.2", ] [[package]] -name = "webpki-roots" -version = "0.26.11" +name = "windows-core" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "webpki-roots 1.0.6", + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] -name = "webpki-roots" -version = "1.0.6" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "rustls-pki-types", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] -name = "weezl" -version = "0.1.12" +name = "windows-future" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-implement" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "winapi-util" -version = "0.1.11" +name = "windows-interface" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ - "windows-sys 0.61.2", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "windows-link" @@ -4732,6 +7136,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -4739,8 +7153,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -4752,6 +7175,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 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -4761,6 +7194,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4797,6 +7239,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4830,6 +7287,21 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4842,6 +7314,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4854,6 +7332,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4878,6 +7362,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4890,6 +7380,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4902,6 +7398,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4914,6 +7416,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4926,6 +7434,58 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.7.15" @@ -5029,6 +7589,69 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xmlwriter" version = "0.1.0" @@ -5047,6 +7670,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[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.1" @@ -5070,6 +7699,73 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[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.40" @@ -5222,3 +7918,43 @@ checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ "zune-core 0.5.1", ] + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index ed9bfc2a..86641d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["paddler_integration_tests", "paddler", "paddler_client", "paddler_harness", "paddler_model_tests", "paddler_types"] +members = ["paddler_integration_tests", "paddler", "paddler_client", "paddler_harness", "paddler_model_tests", "paddler_second_brain_gui", "paddler_types"] resolver = "2" [workspace.package] @@ -52,6 +52,7 @@ serial_test = { version = "3", features = ["file_locks"] } serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" +iced = "0.14" tempfile = "3.20.0" tokio = { version = "1.48", features = ["full"] } tokio-stream = { version = "0.1.17", features = ["sync"] } diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml new file mode 100644 index 00000000..edea222a --- /dev/null +++ b/paddler_second_brain_gui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "paddler_second_brain_gui" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "Desktop GUI for Paddler Second Brain" +license.workspace = true + +[dependencies] +iced = { workspace = true } diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs new file mode 100644 index 00000000..96acf1fe --- /dev/null +++ b/paddler_second_brain_gui/src/main.rs @@ -0,0 +1,30 @@ +use iced::Center; +use iced::widget::{Column, button, column}; + +fn main() -> iced::Result { + iced::run(SecondBrain::update, SecondBrain::view) +} + +#[derive(Default)] +struct SecondBrain; + +#[derive(Debug, Clone, Copy)] +enum Message { + ButtonPressed, +} + +impl SecondBrain { + fn update(&mut self, message: Message) { + match message { + Message::ButtonPressed => {} + } + } + + fn view<'view>(&'view self) -> Column<'view, Message> { + column![ + button("Hello from Paddler").on_press(Message::ButtonPressed), + ] + .padding(20) + .align_x(Center) + } +} From c8ec145af900b711106f1de1e3fd5f4eb6f06d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 17 Mar 2026 19:32:08 +0100 Subject: [PATCH 02/46] extract paddler/src/cmd into a separate workspace member paddler_cli --- Cargo.lock | 63 +++++++++++++++---- Cargo.toml | 2 +- paddler/Cargo.toml | 2 +- paddler/src/lib.rs | 1 - paddler_cli/Cargo.toml | 29 +++++++++ {paddler => paddler_cli}/src/cmd/agent.rs | 24 +++---- {paddler => paddler_cli}/src/cmd/balancer.rs | 56 ++++++++--------- {paddler => paddler_cli}/src/cmd/handler.rs | 0 {paddler => paddler_cli}/src/cmd/mod.rs | 0 .../src/cmd/value_parser/mod.rs | 0 .../src/cmd/value_parser/parse_duration.rs | 0 .../src/cmd/value_parser/parse_socket_addr.rs | 3 +- {paddler => paddler_cli}/src/main.rs | 8 ++- .../tests/balancer_cluster.rs | 5 +- paddler_second_brain_gui/src/main.rs | 12 ++-- 15 files changed, 139 insertions(+), 66 deletions(-) create mode 100644 paddler_cli/Cargo.toml rename {paddler => paddler_cli}/src/cmd/agent.rs (80%) rename {paddler => paddler_cli}/src/cmd/balancer.rs (82%) rename {paddler => paddler_cli}/src/cmd/handler.rs (100%) rename {paddler => paddler_cli}/src/cmd/mod.rs (100%) rename {paddler => paddler_cli}/src/cmd/value_parser/mod.rs (100%) rename {paddler => paddler_cli}/src/cmd/value_parser/parse_duration.rs (100%) rename {paddler => paddler_cli}/src/cmd/value_parser/parse_socket_addr.rs (97%) rename {paddler => paddler_cli}/src/main.rs (94%) diff --git a/Cargo.lock b/Cargo.lock index 8f5e2666..c86573bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,7 +435,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -458,6 +473,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.5" @@ -1133,9 +1157,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -1143,11 +1167,11 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim", @@ -1155,9 +1179,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -1167,9 +1191,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -1738,7 +1762,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ - "anstream", + "anstream 0.6.21", "anstyle", "env_filter", "jiff", @@ -4357,6 +4381,23 @@ dependencies = [ "url", ] +[[package]] +name = "paddler_cli" +version = "3.0.1" +dependencies = [ + "actix-rt", + "actix-web", + "anyhow", + "async-trait", + "clap", + "env_logger", + "esbuild-metafile", + "log", + "nanoid", + "paddler", + "tokio", +] + [[package]] name = "paddler_client" version = "3.0.1" diff --git a/Cargo.toml b/Cargo.toml index 86641d76..fc81feb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["paddler_integration_tests", "paddler", "paddler_client", "paddler_harness", "paddler_model_tests", "paddler_second_brain_gui", "paddler_types"] +members = ["paddler_cli", "paddler_integration_tests", "paddler", "paddler_client", "paddler_harness", "paddler_model_tests", "paddler_second_brain_gui", "paddler_types"] resolver = "2" [workspace.package] diff --git a/paddler/Cargo.toml b/paddler/Cargo.toml index e024edf9..28daadb5 100644 --- a/paddler/Cargo.toml +++ b/paddler/Cargo.toml @@ -37,9 +37,9 @@ log = { workspace = true } minijinja = { workspace = true } minijinja-contrib = { workspace = true } nanoid = { workspace = true } +nix = { workspace = true } paddler_types = { workspace = true, features = ["validation"] } thiserror = { workspace = true } -nix = { workspace = true } rand = { workspace = true } reqwest = { workspace = true } resvg = { workspace = true } diff --git a/paddler/src/lib.rs b/paddler/src/lib.rs index 612e3a1e..28d7069a 100644 --- a/paddler/src/lib.rs +++ b/paddler/src/lib.rs @@ -10,7 +10,6 @@ pub mod balancer_applicable_state; pub mod balancer_applicable_state_holder; pub mod balancer_desired_state; pub mod chat_template_renderer; -pub mod cmd; pub mod continuation_stop_parameters; pub mod controls_session; pub mod controls_websocket_endpoint; diff --git a/paddler_cli/Cargo.toml b/paddler_cli/Cargo.toml new file mode 100644 index 00000000..157b7ec6 --- /dev/null +++ b/paddler_cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "paddler_cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "CLI binary for Paddler" +license.workspace = true + +[dependencies] +actix-rt = { workspace = true } +actix-web = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +clap = { workspace = true } +env_logger = { workspace = true } +log = { workspace = true } +nanoid = { workspace = true } +paddler = { workspace = true } +tokio = { workspace = true } + +# web dashboard deps +esbuild-metafile = { workspace = true, optional = true } + +[features] +default = [] +web_admin_panel = [ + "dep:esbuild-metafile", + "paddler/web_admin_panel", +] diff --git a/paddler/src/cmd/agent.rs b/paddler_cli/src/cmd/agent.rs similarity index 80% rename from paddler/src/cmd/agent.rs rename to paddler_cli/src/cmd/agent.rs index 23494646..d969fecd 100644 --- a/paddler/src/cmd/agent.rs +++ b/paddler_cli/src/cmd/agent.rs @@ -4,23 +4,23 @@ use anyhow::Result; use async_trait::async_trait; use clap::Parser; use nanoid::nanoid; +use paddler::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; +use paddler::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; +use paddler::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; +use paddler::agent::llamacpp_arbiter_service::LlamaCppArbiterService; +use paddler::agent::management_socket_client_service::ManagementSocketClientService; +use paddler::agent::model_metadata_holder::ModelMetadataHolder; +use paddler::agent::reconciliation_service::ReconciliationService; +use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; +use paddler::agent_desired_state::AgentDesiredState; +use paddler::resolved_socket_addr::ResolvedSocketAddr; +use paddler::service_manager::ServiceManager; +use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; use tokio::sync::mpsc; use tokio::sync::oneshot; use super::handler::Handler; use super::value_parser::parse_socket_addr; -use crate::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; -use crate::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; -use crate::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; -use crate::agent::llamacpp_arbiter_service::LlamaCppArbiterService; -use crate::agent::management_socket_client_service::ManagementSocketClientService; -use crate::agent::model_metadata_holder::ModelMetadataHolder; -use crate::agent::reconciliation_service::ReconciliationService; -use crate::agent_applicable_state_holder::AgentApplicableStateHolder; -use crate::agent_desired_state::AgentDesiredState; -use crate::resolved_socket_addr::ResolvedSocketAddr; -use crate::service_manager::ServiceManager; -use crate::slot_aggregated_status_manager::SlotAggregatedStatusManager; #[derive(Parser)] pub struct Agent { diff --git a/paddler/src/cmd/balancer.rs b/paddler_cli/src/cmd/balancer.rs similarity index 82% rename from paddler/src/cmd/balancer.rs rename to paddler_cli/src/cmd/balancer.rs index 253d9593..40709ce3 100644 --- a/paddler/src/cmd/balancer.rs +++ b/paddler_cli/src/cmd/balancer.rs @@ -4,40 +4,40 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; use clap::Parser; +use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler::balancer::buffered_request_manager::BufferedRequestManager; +use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; +use paddler::balancer::compatibility::openai_service::OpenAIService; +use paddler::balancer::compatibility::openai_service::configuration::Configuration as OpenAIServiceConfiguration; +use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; +use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; +use paddler::balancer::inference_service::InferenceService; +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::management_service::ManagementService; +use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; +use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; +use paddler::balancer::reconciliation_service::ReconciliationService; +use paddler::balancer::state_database::File; +use paddler::balancer::state_database::Memory; +use paddler::balancer::state_database::StateDatabase; +use paddler::balancer::state_database_type::StateDatabaseType; +use paddler::balancer::statsd_service::StatsdService; +use paddler::balancer::statsd_service::configuration::Configuration as StatsdServiceConfiguration; +#[cfg(feature = "web_admin_panel")] +use paddler::balancer::web_admin_panel_service::WebAdminPanelService; +#[cfg(feature = "web_admin_panel")] +use paddler::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; +#[cfg(feature = "web_admin_panel")] +use paddler::balancer::web_admin_panel_service::template_data::TemplateData; +use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; +use paddler::resolved_socket_addr::ResolvedSocketAddr; +use paddler::service_manager::ServiceManager; use tokio::sync::broadcast; use tokio::sync::oneshot; use super::handler::Handler; use super::value_parser::parse_duration; use super::value_parser::parse_socket_addr; -use crate::balancer::agent_controller_pool::AgentControllerPool; -use crate::balancer::buffered_request_manager::BufferedRequestManager; -use crate::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; -use crate::balancer::compatibility::openai_service::OpenAIService; -use crate::balancer::compatibility::openai_service::configuration::Configuration as OpenAIServiceConfiguration; -use crate::balancer::embedding_sender_collection::EmbeddingSenderCollection; -use crate::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; -use crate::balancer::inference_service::InferenceService; -use crate::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; -use crate::balancer::management_service::ManagementService; -use crate::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; -use crate::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; -use crate::balancer::reconciliation_service::ReconciliationService; -use crate::balancer::state_database::File; -use crate::balancer::state_database::Memory; -use crate::balancer::state_database::StateDatabase; -use crate::balancer::state_database_type::StateDatabaseType; -use crate::balancer::statsd_service::StatsdService; -use crate::balancer::statsd_service::configuration::Configuration as StatsdServiceConfiguration; -#[cfg(feature = "web_admin_panel")] -use crate::balancer::web_admin_panel_service::WebAdminPanelService; -#[cfg(feature = "web_admin_panel")] -use crate::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; -#[cfg(feature = "web_admin_panel")] -use crate::balancer::web_admin_panel_service::template_data::TemplateData; -use crate::balancer_applicable_state_holder::BalancerApplicableStateHolder; -use crate::resolved_socket_addr::ResolvedSocketAddr; -use crate::service_manager::ServiceManager; #[derive(Parser)] pub struct Balancer { diff --git a/paddler/src/cmd/handler.rs b/paddler_cli/src/cmd/handler.rs similarity index 100% rename from paddler/src/cmd/handler.rs rename to paddler_cli/src/cmd/handler.rs diff --git a/paddler/src/cmd/mod.rs b/paddler_cli/src/cmd/mod.rs similarity index 100% rename from paddler/src/cmd/mod.rs rename to paddler_cli/src/cmd/mod.rs diff --git a/paddler/src/cmd/value_parser/mod.rs b/paddler_cli/src/cmd/value_parser/mod.rs similarity index 100% rename from paddler/src/cmd/value_parser/mod.rs rename to paddler_cli/src/cmd/value_parser/mod.rs diff --git a/paddler/src/cmd/value_parser/parse_duration.rs b/paddler_cli/src/cmd/value_parser/parse_duration.rs similarity index 100% rename from paddler/src/cmd/value_parser/parse_duration.rs rename to paddler_cli/src/cmd/value_parser/parse_duration.rs diff --git a/paddler/src/cmd/value_parser/parse_socket_addr.rs b/paddler_cli/src/cmd/value_parser/parse_socket_addr.rs similarity index 97% rename from paddler/src/cmd/value_parser/parse_socket_addr.rs rename to paddler_cli/src/cmd/value_parser/parse_socket_addr.rs index 8bef9b9d..08c8b9c2 100644 --- a/paddler/src/cmd/value_parser/parse_socket_addr.rs +++ b/paddler_cli/src/cmd/value_parser/parse_socket_addr.rs @@ -4,8 +4,7 @@ use std::net::ToSocketAddrs; use anyhow::Result; use anyhow::anyhow; use log::warn; - -use crate::resolved_socket_addr::ResolvedSocketAddr; +use paddler::resolved_socket_addr::ResolvedSocketAddr; fn resolve_socket_addr(input_addr: &str) -> Result { let addrs: Vec = input_addr.to_socket_addrs()?.collect(); diff --git a/paddler/src/main.rs b/paddler_cli/src/main.rs similarity index 94% rename from paddler/src/main.rs rename to paddler_cli/src/main.rs index 076bb843..f172e457 100644 --- a/paddler/src/main.rs +++ b/paddler_cli/src/main.rs @@ -4,9 +4,11 @@ use clap::Subcommand; #[cfg(feature = "web_admin_panel")] use esbuild_metafile::instance::initialize_instance; use log::info; -use paddler::cmd::agent::Agent; -use paddler::cmd::balancer::Balancer; -use paddler::cmd::handler::Handler as _; +mod cmd; + +use cmd::agent::Agent; +use cmd::balancer::Balancer; +use cmd::handler::Handler as _; use tokio::signal::unix::SignalKind; use tokio::signal::unix::signal; use tokio::sync::oneshot; diff --git a/paddler_integration_tests/tests/balancer_cluster.rs b/paddler_integration_tests/tests/balancer_cluster.rs index 4d51bd85..6197b0fb 100644 --- a/paddler_integration_tests/tests/balancer_cluster.rs +++ b/paddler_integration_tests/tests/balancer_cluster.rs @@ -546,7 +546,10 @@ async fn test_inference_item_timeout_zero_causes_immediate_timeout() { match message { Message::Error(envelope) => { assert_eq!(envelope.error.code, 504); - assert_eq!(envelope.error.description, "Inference timed out after 0ms waiting for next token. Increase --inference-item-timeout if the prompt requires longer processing."); + assert_eq!( + envelope.error.description, + "Inference timed out after 0ms waiting for next token. Increase --inference-item-timeout if the prompt requires longer processing." + ); } Message::Response(_) => { panic!("expected timeout error, got a successful response"); diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 96acf1fe..d481a357 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,5 +1,7 @@ use iced::Center; -use iced::widget::{Column, button, column}; +use iced::widget::Column; +use iced::widget::button; +use iced::widget::column; fn main() -> iced::Result { iced::run(SecondBrain::update, SecondBrain::view) @@ -21,10 +23,8 @@ impl SecondBrain { } fn view<'view>(&'view self) -> Column<'view, Message> { - column![ - button("Hello from Paddler").on_press(Message::ButtonPressed), - ] - .padding(20) - .align_x(Center) + column![button("Hello from Paddler").on_press(Message::ButtonPressed),] + .padding(20) + .align_x(Center) } } From a78fb40e3ae79a2f2a8f41e5bc082d725e0c74a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 17 Mar 2026 20:46:01 +0100 Subject: [PATCH 03/46] run the balancer from the desktop app --- Cargo.lock | 6 ++ paddler_second_brain_gui/Cargo.toml | 10 +++ .../src/balancer_status.rs | 5 ++ paddler_second_brain_gui/src/main.rs | 35 ++------ paddler_second_brain_gui/src/message.rs | 6 ++ paddler_second_brain_gui/src/second_brain.rs | 88 +++++++++++++++++++ .../src/start_balancer.rs | 19 ++++ .../src/start_balancer_services.rs | 84 ++++++++++++++++++ 8 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 paddler_second_brain_gui/src/balancer_status.rs create mode 100644 paddler_second_brain_gui/src/message.rs create mode 100644 paddler_second_brain_gui/src/second_brain.rs create mode 100644 paddler_second_brain_gui/src/start_balancer.rs create mode 100644 paddler_second_brain_gui/src/start_balancer_services.rs diff --git a/Cargo.lock b/Cargo.lock index c86573bd..49a03a22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4445,7 +4445,13 @@ dependencies = [ name = "paddler_second_brain_gui" version = "3.0.1" dependencies = [ + "actix-web", + "anyhow", + "env_logger", "iced", + "log", + "paddler", + "tokio", ] [[package]] diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index edea222a..ff7792a5 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -7,4 +7,14 @@ description = "Desktop GUI for Paddler Second Brain" license.workspace = true [dependencies] +actix-web = { workspace = true } +anyhow = { workspace = true } +env_logger = { workspace = true } iced = { workspace = true } +log = { workspace = true } +paddler = { workspace = true } +tokio = { workspace = true } + +[features] +default = [] +web_admin_panel = ["paddler/web_admin_panel"] diff --git a/paddler_second_brain_gui/src/balancer_status.rs b/paddler_second_brain_gui/src/balancer_status.rs new file mode 100644 index 00000000..4022783e --- /dev/null +++ b/paddler_second_brain_gui/src/balancer_status.rs @@ -0,0 +1,5 @@ +pub enum BalancerStatus { + Stopped, + Running, + Failed(String), +} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index d481a357..ca8b9f48 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,30 +1,13 @@ -use iced::Center; -use iced::widget::Column; -use iced::widget::button; -use iced::widget::column; +mod balancer_status; +mod message; +mod second_brain; +mod start_balancer; +mod start_balancer_services; -fn main() -> iced::Result { - iced::run(SecondBrain::update, SecondBrain::view) -} - -#[derive(Default)] -struct SecondBrain; +use second_brain::SecondBrain; -#[derive(Debug, Clone, Copy)] -enum Message { - ButtonPressed, -} - -impl SecondBrain { - fn update(&mut self, message: Message) { - match message { - Message::ButtonPressed => {} - } - } +fn main() -> iced::Result { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - fn view<'view>(&'view self) -> Column<'view, Message> { - column![button("Hello from Paddler").on_press(Message::ButtonPressed),] - .padding(20) - .align_x(Center) - } + iced::application(SecondBrain::new, SecondBrain::update, SecondBrain::view).run() } diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs new file mode 100644 index 00000000..b00a07bb --- /dev/null +++ b/paddler_second_brain_gui/src/message.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone)] +pub enum Message { + StartBalancer, + BalancerStopped, + BalancerFailed(String), +} diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs new file mode 100644 index 00000000..a8beefe8 --- /dev/null +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -0,0 +1,88 @@ +use iced::Center; +use iced::Element; +use iced::Task; +use iced::widget::button; +use iced::widget::column; +use iced::widget::text; +use tokio::sync::oneshot; + +use crate::balancer_status::BalancerStatus; +use crate::message::Message; +use crate::start_balancer::start_balancer; + +pub struct SecondBrain { + balancer_status: BalancerStatus, + shutdown_tx: Option>, +} + +impl Drop for SecondBrain { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + if let Err(unsent_signal) = shutdown_tx.send(()) { + log::error!("Failed to send balancer shutdown signal: {unsent_signal:?}"); + } + } + } +} + +impl SecondBrain { + pub fn new() -> (Self, Task) { + let second_brain = Self { + balancer_status: BalancerStatus::Stopped, + shutdown_tx: None, + }; + + (second_brain, Task::none()) + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::StartBalancer => { + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + self.shutdown_tx = Some(shutdown_tx); + self.balancer_status = BalancerStatus::Running; + + Task::perform( + start_balancer(shutdown_rx), + |result: Result<(), anyhow::Error>| match result { + Ok(()) => Message::BalancerStopped, + Err(error) => Message::BalancerFailed(error.to_string()), + }, + ) + } + Message::BalancerStopped => { + self.balancer_status = BalancerStatus::Stopped; + + Task::none() + } + Message::BalancerFailed(error) => { + self.balancer_status = BalancerStatus::Failed(error); + + Task::none() + } + } + } + + pub fn view<'view>(&'view self) -> Element<'view, Message> { + let status_text = match &self.balancer_status { + BalancerStatus::Stopped => "Balancer is stopped", + BalancerStatus::Running => "Balancer is running", + BalancerStatus::Failed(error) => error.as_str(), + }; + + let start_button = match &self.balancer_status { + BalancerStatus::Stopped => button("Start Balancer").on_press(Message::StartBalancer), + BalancerStatus::Running => button("Start Balancer"), + BalancerStatus::Failed(_) => button("Start Balancer").on_press(Message::StartBalancer), + }; + + column![ + start_button, + text(status_text), + ] + .padding(20) + .spacing(10) + .align_x(Center) + .into() + } +} diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs new file mode 100644 index 00000000..9d3132ae --- /dev/null +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -0,0 +1,19 @@ +use tokio::sync::oneshot; + +use crate::start_balancer_services::start_balancer_services; + +pub async fn start_balancer(shutdown_rx: oneshot::Receiver<()>) -> anyhow::Result<()> { + let (result_tx, result_rx) = oneshot::channel(); + + std::thread::spawn(move || { + let system = actix_web::rt::System::new(); + let result = system.block_on(start_balancer_services(shutdown_rx)); + if let Err(unsent_result) = result_tx.send(result) { + log::error!("Failed to send balancer result: {unsent_result:?}"); + } + }); + + result_rx + .await + .map_err(|error| anyhow::anyhow!("Balancer thread terminated: {error}"))? +} diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs new file mode 100644 index 00000000..f00bd7cc --- /dev/null +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -0,0 +1,84 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler::balancer::buffered_request_manager::BufferedRequestManager; +use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; +use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; +use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; +use paddler::balancer::inference_service::InferenceService; +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::management_service::ManagementService; +use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; +use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; +use paddler::balancer::reconciliation_service::ReconciliationService; +use paddler::balancer::state_database::Memory; +use paddler::balancer::state_database::StateDatabase; +use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; +use paddler::service_manager::ServiceManager; +use tokio::sync::broadcast; +use tokio::sync::oneshot; + +pub async fn start_balancer_services(shutdown_rx: oneshot::Receiver<()>) -> anyhow::Result<()> { + let management_addr: SocketAddr = "127.0.0.1:8060".parse()?; + let inference_addr: SocketAddr = "127.0.0.1:8061".parse()?; + + let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); + + let agent_controller_pool = Arc::new(AgentControllerPool::default()); + let balancer_applicable_state_holder = Arc::new(BalancerApplicableStateHolder::default()); + let buffered_request_manager = Arc::new(BufferedRequestManager::new( + agent_controller_pool.clone(), + Duration::from_millis(10000), + 30, + )); + let chat_template_override_sender_collection = + Arc::new(ChatTemplateOverrideSenderCollection::default()); + let embedding_sender_collection = Arc::new(EmbeddingSenderCollection::default()); + let generate_tokens_sender_collection = Arc::new(GenerateTokensSenderCollection::default()); + let model_metadata_sender_collection = Arc::new(ModelMetadataSenderCollection::default()); + let mut service_manager = ServiceManager::default(); + let state_database: Arc = + Arc::new(Memory::new(balancer_desired_state_tx.clone())); + + service_manager.add_service(InferenceService { + balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), + buffered_request_manager: buffered_request_manager.clone(), + configuration: InferenceServiceConfiguration { + addr: inference_addr, + cors_allowed_hosts: vec![], + inference_item_timeout: Duration::from_millis(30000), + }, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: None, + }); + + service_manager.add_service(ManagementService { + agent_controller_pool: agent_controller_pool.clone(), + balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), + buffered_request_manager: buffered_request_manager.clone(), + chat_template_override_sender_collection, + configuration: ManagementServiceConfiguration { + addr: management_addr, + cors_allowed_hosts: vec![], + }, + embedding_sender_collection, + generate_tokens_sender_collection, + model_metadata_sender_collection, + state_database: state_database.clone(), + statsd_prefix: "paddler_".to_string(), + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: None, + }); + + service_manager.add_service(ReconciliationService { + agent_controller_pool: agent_controller_pool.clone(), + balancer_applicable_state_holder, + balancer_desired_state: state_database.read_balancer_desired_state().await?, + balancer_desired_state_rx, + is_converted_to_applicable_state: false, + }); + + service_manager.run_forever(shutdown_rx).await +} From d57c3b08a0a6300ca185095fbdeba211015204db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 17 Mar 2026 21:08:39 +0100 Subject: [PATCH 04/46] rename balancer to cluster, add stopping cluster --- .../{balancer_status.rs => cluster_status.rs} | 2 +- paddler_second_brain_gui/src/main.rs | 2 +- paddler_second_brain_gui/src/message.rs | 7 +- paddler_second_brain_gui/src/second_brain.rs | 64 +++++++++++-------- 4 files changed, 42 insertions(+), 33 deletions(-) rename paddler_second_brain_gui/src/{balancer_status.rs => cluster_status.rs} (64%) diff --git a/paddler_second_brain_gui/src/balancer_status.rs b/paddler_second_brain_gui/src/cluster_status.rs similarity index 64% rename from paddler_second_brain_gui/src/balancer_status.rs rename to paddler_second_brain_gui/src/cluster_status.rs index 4022783e..808b7a01 100644 --- a/paddler_second_brain_gui/src/balancer_status.rs +++ b/paddler_second_brain_gui/src/cluster_status.rs @@ -1,4 +1,4 @@ -pub enum BalancerStatus { +pub enum ClusterStatus { Stopped, Running, Failed(String), diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index ca8b9f48..ae9291aa 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,4 +1,4 @@ -mod balancer_status; +mod cluster_status; mod message; mod second_brain; mod start_balancer; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index b00a07bb..2aaabdf1 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone)] pub enum Message { - StartBalancer, - BalancerStopped, - BalancerFailed(String), + StartCluster, + StopCluster, + ClusterStopped, + ClusterFailed(String), } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index a8beefe8..452ab8da 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -6,12 +6,12 @@ use iced::widget::column; use iced::widget::text; use tokio::sync::oneshot; -use crate::balancer_status::BalancerStatus; +use crate::cluster_status::ClusterStatus; use crate::message::Message; use crate::start_balancer::start_balancer; pub struct SecondBrain { - balancer_status: BalancerStatus, + cluster_status: ClusterStatus, shutdown_tx: Option>, } @@ -19,7 +19,7 @@ impl Drop for SecondBrain { fn drop(&mut self) { if let Some(shutdown_tx) = self.shutdown_tx.take() { if let Err(unsent_signal) = shutdown_tx.send(()) { - log::error!("Failed to send balancer shutdown signal: {unsent_signal:?}"); + log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } } } @@ -28,7 +28,7 @@ impl Drop for SecondBrain { impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { - balancer_status: BalancerStatus::Stopped, + cluster_status: ClusterStatus::Stopped, shutdown_tx: None, }; @@ -37,26 +37,37 @@ impl SecondBrain { pub fn update(&mut self, message: Message) -> Task { match message { - Message::StartBalancer => { + Message::StartCluster => { let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); self.shutdown_tx = Some(shutdown_tx); - self.balancer_status = BalancerStatus::Running; + self.cluster_status = ClusterStatus::Running; Task::perform( start_balancer(shutdown_rx), |result: Result<(), anyhow::Error>| match result { - Ok(()) => Message::BalancerStopped, - Err(error) => Message::BalancerFailed(error.to_string()), + Ok(()) => Message::ClusterStopped, + Err(error) => Message::ClusterFailed(error.to_string()), }, ) } - Message::BalancerStopped => { - self.balancer_status = BalancerStatus::Stopped; + Message::StopCluster => { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + if let Err(unsent_signal) = shutdown_tx.send(()) { + log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); + } + } + + self.cluster_status = ClusterStatus::Stopped; + + Task::none() + } + Message::ClusterStopped => { + self.cluster_status = ClusterStatus::Stopped; Task::none() } - Message::BalancerFailed(error) => { - self.balancer_status = BalancerStatus::Failed(error); + Message::ClusterFailed(error) => { + self.cluster_status = ClusterStatus::Failed(error); Task::none() } @@ -64,25 +75,22 @@ impl SecondBrain { } pub fn view<'view>(&'view self) -> Element<'view, Message> { - let status_text = match &self.balancer_status { - BalancerStatus::Stopped => "Balancer is stopped", - BalancerStatus::Running => "Balancer is running", - BalancerStatus::Failed(error) => error.as_str(), + let status_text = match &self.cluster_status { + ClusterStatus::Stopped => "Cluster is stopped", + ClusterStatus::Running => "Cluster is running", + ClusterStatus::Failed(error) => error.as_str(), }; - let start_button = match &self.balancer_status { - BalancerStatus::Stopped => button("Start Balancer").on_press(Message::StartBalancer), - BalancerStatus::Running => button("Start Balancer"), - BalancerStatus::Failed(_) => button("Start Balancer").on_press(Message::StartBalancer), + let action_button = match &self.cluster_status { + ClusterStatus::Stopped => button("Start a cluster").on_press(Message::StartCluster), + ClusterStatus::Running => button("Stop cluster").on_press(Message::StopCluster), + ClusterStatus::Failed(_) => button("Start a cluster").on_press(Message::StartCluster), }; - column![ - start_button, - text(status_text), - ] - .padding(20) - .spacing(10) - .align_x(Center) - .into() + column![action_button, text(status_text),] + .padding(20) + .spacing(10) + .align_x(Center) + .into() } } From b6ac3b6e6465bd622bca0d741f09bbe439b80f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 00:19:18 +0100 Subject: [PATCH 05/46] add statum state machine --- Cargo.lock | 56 +++++++ Cargo.toml | 1 + paddler_second_brain_gui/Cargo.toml | 1 + .../src/cluster_status.rs | 5 - paddler_second_brain_gui/src/main.rs | 11 +- paddler_second_brain_gui/src/message.rs | 9 +- .../src/running_cluster_data.rs | 5 + paddler_second_brain_gui/src/screen.rs | 77 ++++++++++ .../src/screen_current.rs | 20 +++ paddler_second_brain_gui/src/second_brain.rs | 145 +++++++++++++----- .../src/start_cluster_config_data.rs | 5 + .../src/starting_cluster_data.rs | 4 + paddler_second_brain_gui/src/view_home.rs | 14 ++ .../src/view_running_cluster.rs | 18 +++ .../src/view_start_cluster_config.rs | 37 +++++ .../src/view_starting_cluster.rs | 8 + .../src/view_stopping_cluster.rs | 8 + 17 files changed, 374 insertions(+), 50 deletions(-) delete mode 100644 paddler_second_brain_gui/src/cluster_status.rs create mode 100644 paddler_second_brain_gui/src/running_cluster_data.rs create mode 100644 paddler_second_brain_gui/src/screen.rs create mode 100644 paddler_second_brain_gui/src/screen_current.rs create mode 100644 paddler_second_brain_gui/src/start_cluster_config_data.rs create mode 100644 paddler_second_brain_gui/src/starting_cluster_data.rs create mode 100644 paddler_second_brain_gui/src/view_home.rs create mode 100644 paddler_second_brain_gui/src/view_running_cluster.rs create mode 100644 paddler_second_brain_gui/src/view_start_cluster_config.rs create mode 100644 paddler_second_brain_gui/src/view_starting_cluster.rs create mode 100644 paddler_second_brain_gui/src/view_stopping_cluster.rs diff --git a/Cargo.lock b/Cargo.lock index 49a03a22..9f7f29c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3436,6 +3436,16 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +[[package]] +name = "macro_registry" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ab9348404a2145b71c3bf727ce776a347c39a3f021404f9133910038ecad40" +dependencies = [ + "module_path_extractor", + "syn", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3569,6 +3579,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moddef" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e519fd9c6131c1c9a4a67f8bdc4f32eb4105b16c1468adea1b8e68c98c85ec4" + +[[package]] +name = "module_path_extractor" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f656ff91572d3d978c024b17b1b5cc14b7aec274daa08bf9c5e45b7745a714bc" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -4451,6 +4477,7 @@ dependencies = [ "iced", "log", "paddler", + "statum", "tokio", ] @@ -5761,6 +5788,35 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "statum" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d1afcdce24b50a26220589d91030320f4c6385edf8bf0f87da061e2210e8cf" +dependencies = [ + "statum-core", + "statum-macros", +] + +[[package]] +name = "statum-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8143ffdd035c3f000bded6282f1bbaa19a48c381c811dba252b326ee90e1f7" + +[[package]] +name = "statum-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd1681b7963477e9994c6cfb262083fba09e842dad85c2b43ce2c5f6222391c" +dependencies = [ + "macro_registry", + "moddef", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "strict-num" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index fc81feb8..b9e9ef87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" iced = "0.14" +statum = "0.6" tempfile = "3.20.0" tokio = { version = "1.48", features = ["full"] } tokio-stream = { version = "0.1.17", features = ["sync"] } diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index ff7792a5..6bdf1ae2 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -13,6 +13,7 @@ env_logger = { workspace = true } iced = { workspace = true } log = { workspace = true } paddler = { workspace = true } +statum = { workspace = true } tokio = { workspace = true } [features] diff --git a/paddler_second_brain_gui/src/cluster_status.rs b/paddler_second_brain_gui/src/cluster_status.rs deleted file mode 100644 index 808b7a01..00000000 --- a/paddler_second_brain_gui/src/cluster_status.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub enum ClusterStatus { - Stopped, - Running, - Failed(String), -} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index ae9291aa..8ebea6c4 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,8 +1,17 @@ -mod cluster_status; mod message; +mod running_cluster_data; +mod screen; +mod screen_current; mod second_brain; mod start_balancer; mod start_balancer_services; +mod start_cluster_config_data; +mod starting_cluster_data; +mod view_home; +mod view_running_cluster; +mod view_start_cluster_config; +mod view_starting_cluster; +mod view_stopping_cluster; use second_brain::SecondBrain; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index 2aaabdf1..b7ed10a3 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -1,7 +1,12 @@ #[derive(Debug, Clone)] pub enum Message { StartCluster, - StopCluster, - ClusterStopped, + Cancel, + SelectModel(String), + ToggleRunAgentLocally(bool), + Confirm, + ClusterStarted, ClusterFailed(String), + Stop, + ClusterStopped, } diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs new file mode 100644 index 00000000..2fcb665f --- /dev/null +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -0,0 +1,5 @@ +pub struct RunningClusterData { + pub cluster_address: String, + pub selected_model: Option, + pub run_agent_locally: bool, +} diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs new file mode 100644 index 00000000..d14cb9ca --- /dev/null +++ b/paddler_second_brain_gui/src/screen.rs @@ -0,0 +1,77 @@ +use statum::machine; +use statum::state; +use statum::transition; + +use crate::running_cluster_data::RunningClusterData; +use crate::start_cluster_config_data::StartClusterConfigData; +use crate::starting_cluster_data::StartingClusterData; + +#[state] +pub enum ScreenState { + Home, + StartClusterConfig(StartClusterConfigData), + StartingCluster(StartingClusterData), + RunningCluster(RunningClusterData), + StoppingCluster, +} + +#[machine] +pub struct Screen {} + +#[transition] +impl Screen { + pub fn start_cluster(self) -> Screen { + self.transition_with(StartClusterConfigData::default()) + } +} + +#[transition] +impl Screen { + pub fn cancel(self) -> Screen { + self.transition() + } + + pub fn confirm(self) -> Screen { + self.transition_map(|config_data| StartingClusterData { + selected_model: config_data.selected_model, + run_agent_locally: config_data.run_agent_locally, + }) + } +} + +#[transition] +impl Screen { + pub fn cluster_started(self, cluster_address: String) -> Screen { + self.transition_map(|starting_data| RunningClusterData { + cluster_address, + selected_model: starting_data.selected_model, + run_agent_locally: starting_data.run_agent_locally, + }) + } + + pub fn cluster_failed(self) -> Screen { + self.transition() + } +} + +#[transition] +impl Screen { + pub fn stop(self) -> Screen { + self.transition() + } + + pub fn cluster_failed(self) -> Screen { + self.transition() + } +} + +#[transition] +impl Screen { + pub fn cluster_stopped(self) -> Screen { + self.transition() + } + + pub fn cluster_failed(self) -> Screen { + self.transition() + } +} diff --git a/paddler_second_brain_gui/src/screen_current.rs b/paddler_second_brain_gui/src/screen_current.rs new file mode 100644 index 00000000..e20dca20 --- /dev/null +++ b/paddler_second_brain_gui/src/screen_current.rs @@ -0,0 +1,20 @@ +use crate::screen::Home; +use crate::screen::RunningCluster; +use crate::screen::Screen; +use crate::screen::StartClusterConfig; +use crate::screen::StartingCluster; +use crate::screen::StoppingCluster; + +pub enum CurrentScreen { + Home(Screen), + StartClusterConfig(Screen), + StartingCluster(Screen), + RunningCluster(Screen), + StoppingCluster(Screen), +} + +impl Default for CurrentScreen { + fn default() -> Self { + CurrentScreen::Home(Screen::::builder().build()) + } +} diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 452ab8da..8853cba6 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -1,26 +1,32 @@ +use std::mem; + use iced::Center; use iced::Element; use iced::Task; -use iced::widget::button; use iced::widget::column; use iced::widget::text; use tokio::sync::oneshot; -use crate::cluster_status::ClusterStatus; use crate::message::Message; +use crate::screen_current::CurrentScreen; use crate::start_balancer::start_balancer; +use crate::view_home::view_home; +use crate::view_running_cluster::view_running_cluster; +use crate::view_start_cluster_config::view_start_cluster_config; +use crate::view_starting_cluster::view_starting_cluster; +use crate::view_stopping_cluster::view_stopping_cluster; pub struct SecondBrain { - cluster_status: ClusterStatus, + screen: CurrentScreen, shutdown_tx: Option>, } impl Drop for SecondBrain { fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - if let Err(unsent_signal) = shutdown_tx.send(()) { - log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); - } + if let Some(shutdown_tx) = self.shutdown_tx.take() + && let Err(unsent_signal) = shutdown_tx.send(()) + { + log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } } } @@ -28,7 +34,7 @@ impl Drop for SecondBrain { impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { - cluster_status: ClusterStatus::Stopped, + screen: CurrentScreen::default(), shutdown_tx: None, }; @@ -36,60 +42,115 @@ impl SecondBrain { } pub fn update(&mut self, message: Message) -> Task { - match message { - Message::StartCluster => { + let screen = mem::take(&mut self.screen); + + match (screen, message) { + (CurrentScreen::Home(home), Message::StartCluster) => { + self.screen = CurrentScreen::StartClusterConfig(home.start_cluster()); + + Task::none() + } + (CurrentScreen::StartClusterConfig(config), Message::Cancel) => { + self.screen = CurrentScreen::Home(config.cancel()); + + Task::none() + } + (CurrentScreen::StartClusterConfig(mut config), Message::SelectModel(model)) => { + config.state_data.selected_model = Some(model); + self.screen = CurrentScreen::StartClusterConfig(config); + + Task::none() + } + ( + CurrentScreen::StartClusterConfig(mut config), + Message::ToggleRunAgentLocally(enabled), + ) => { + config.state_data.run_agent_locally = enabled; + self.screen = CurrentScreen::StartClusterConfig(config); + + Task::none() + } + (CurrentScreen::StartClusterConfig(config), Message::Confirm) => { let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); self.shutdown_tx = Some(shutdown_tx); - self.cluster_status = ClusterStatus::Running; - - Task::perform( - start_balancer(shutdown_rx), - |result: Result<(), anyhow::Error>| match result { - Ok(()) => Message::ClusterStopped, - Err(error) => Message::ClusterFailed(error.to_string()), - }, - ) + self.screen = CurrentScreen::StartingCluster(config.confirm()); + + Task::batch([ + Task::perform( + start_balancer(shutdown_rx), + |result: Result<(), anyhow::Error>| match result { + Ok(()) => Message::ClusterStopped, + Err(error) => Message::ClusterFailed(error.to_string()), + }, + ), + Task::done(Message::ClusterStarted), + ]) + } + (CurrentScreen::StartingCluster(starting), Message::ClusterStarted) => { + let cluster_address = "192.168.1.1".to_string(); // TODO: detect local IP + self.screen = + CurrentScreen::RunningCluster(starting.cluster_started(cluster_address)); + + Task::none() } - Message::StopCluster => { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - if let Err(unsent_signal) = shutdown_tx.send(()) { - log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); - } + (CurrentScreen::StartingCluster(starting), Message::ClusterFailed(error)) => { + log::error!("Cluster failed to start: {error}"); + self.shutdown_tx = None; + self.screen = CurrentScreen::Home(starting.cluster_failed()); + + Task::none() + } + (CurrentScreen::RunningCluster(running), Message::Stop) => { + if let Some(shutdown_tx) = self.shutdown_tx.take() + && let Err(unsent_signal) = shutdown_tx.send(()) + { + log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } + self.screen = CurrentScreen::StoppingCluster(running.stop()); - self.cluster_status = ClusterStatus::Stopped; + Task::none() + } + (CurrentScreen::RunningCluster(running), Message::ClusterFailed(error)) => { + log::error!("Cluster failed unexpectedly: {error}"); + self.shutdown_tx = None; + self.screen = CurrentScreen::Home(running.cluster_failed()); + + Task::none() + } + (CurrentScreen::StoppingCluster(stopping), Message::ClusterStopped) => { + self.screen = CurrentScreen::Home(stopping.cluster_stopped()); Task::none() } - Message::ClusterStopped => { - self.cluster_status = ClusterStatus::Stopped; + (CurrentScreen::StoppingCluster(stopping), Message::ClusterFailed(error)) => { + log::error!("Cluster failed during shutdown: {error}"); + self.screen = CurrentScreen::Home(stopping.cluster_failed()); Task::none() } - Message::ClusterFailed(error) => { - self.cluster_status = ClusterStatus::Failed(error); + (screen, message) => { + log::warn!("Unhandled message {message:?} for current screen"); + self.screen = screen; Task::none() } } } - pub fn view<'view>(&'view self) -> Element<'view, Message> { - let status_text = match &self.cluster_status { - ClusterStatus::Stopped => "Cluster is stopped", - ClusterStatus::Running => "Cluster is running", - ClusterStatus::Failed(error) => error.as_str(), - }; - - let action_button = match &self.cluster_status { - ClusterStatus::Stopped => button("Start a cluster").on_press(Message::StartCluster), - ClusterStatus::Running => button("Stop cluster").on_press(Message::StopCluster), - ClusterStatus::Failed(_) => button("Start a cluster").on_press(Message::StartCluster), + pub fn view(&self) -> Element<'_, Message> { + let screen_content = match &self.screen { + CurrentScreen::Home(_) => view_home(), + CurrentScreen::StartClusterConfig(screen) => { + view_start_cluster_config(&screen.state_data) + } + CurrentScreen::StartingCluster(_) => view_starting_cluster(), + CurrentScreen::RunningCluster(screen) => view_running_cluster(&screen.state_data), + CurrentScreen::StoppingCluster(_) => view_stopping_cluster(), }; - column![action_button, text(status_text),] + column![text("Paddler second brain").size(24), screen_content] .padding(20) - .spacing(10) + .spacing(20) .align_x(Center) .into() } diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs new file mode 100644 index 00000000..0e8a6caf --- /dev/null +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -0,0 +1,5 @@ +#[derive(Default)] +pub struct StartClusterConfigData { + pub selected_model: Option, + pub run_agent_locally: bool, +} diff --git a/paddler_second_brain_gui/src/starting_cluster_data.rs b/paddler_second_brain_gui/src/starting_cluster_data.rs new file mode 100644 index 00000000..148069de --- /dev/null +++ b/paddler_second_brain_gui/src/starting_cluster_data.rs @@ -0,0 +1,4 @@ +pub struct StartingClusterData { + pub selected_model: Option, + pub run_agent_locally: bool, +} diff --git a/paddler_second_brain_gui/src/view_home.rs b/paddler_second_brain_gui/src/view_home.rs new file mode 100644 index 00000000..455fe7b0 --- /dev/null +++ b/paddler_second_brain_gui/src/view_home.rs @@ -0,0 +1,14 @@ +use iced::Element; +use iced::widget::button; +use iced::widget::column; + +use crate::message::Message; + +pub fn view_home() -> Element<'static, Message> { + column![ + button("Start a cluster").on_press(Message::StartCluster), + button("Join a cluster"), + ] + .spacing(10) + .into() +} diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs new file mode 100644 index 00000000..46daa34d --- /dev/null +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -0,0 +1,18 @@ +use iced::Element; +use iced::widget::button; +use iced::widget::column; +use iced::widget::text; + +use crate::message::Message; +use crate::running_cluster_data::RunningClusterData; + +pub fn view_running_cluster<'content>( + data: &'content RunningClusterData, +) -> Element<'content, Message> { + column![ + text(format!("Cluster is running at {}", data.cluster_address)), + button("Stop cluster").on_press(Message::Stop), + ] + .spacing(10) + .into() +} diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs new file mode 100644 index 00000000..94d09e83 --- /dev/null +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -0,0 +1,37 @@ +use iced::Element; +use iced::widget::button; +use iced::widget::column; +use iced::widget::pick_list; +use iced::widget::text; +use iced::widget::toggler; + +use crate::message::Message; +use crate::start_cluster_config_data::StartClusterConfigData; + +const AVAILABLE_MODELS: &[&str] = &[ + "llama-3.2-1b", + "llama-3.2-3b", + "llama-3.1-8b", + "mistral-7b", + "phi-3-mini", +]; + +pub fn view_start_cluster_config<'content>( + data: &'content StartClusterConfigData, +) -> Element<'content, Message> { + column![ + button("Back").on_press(Message::Cancel), + text("Select a model"), + pick_list( + AVAILABLE_MODELS, + data.selected_model.as_deref(), + |model: &str| Message::SelectModel(model.to_owned()), + ), + toggler(data.run_agent_locally) + .label("Run an agent on your computer") + .on_toggle(Message::ToggleRunAgentLocally), + button("Start a cluster").on_press(Message::Confirm), + ] + .spacing(10) + .into() +} diff --git a/paddler_second_brain_gui/src/view_starting_cluster.rs b/paddler_second_brain_gui/src/view_starting_cluster.rs new file mode 100644 index 00000000..ce9019a4 --- /dev/null +++ b/paddler_second_brain_gui/src/view_starting_cluster.rs @@ -0,0 +1,8 @@ +use iced::Element; +use iced::widget::text; + +use crate::message::Message; + +pub fn view_starting_cluster() -> Element<'static, Message> { + text("Starting cluster...").into() +} diff --git a/paddler_second_brain_gui/src/view_stopping_cluster.rs b/paddler_second_brain_gui/src/view_stopping_cluster.rs new file mode 100644 index 00000000..b68656a8 --- /dev/null +++ b/paddler_second_brain_gui/src/view_stopping_cluster.rs @@ -0,0 +1,8 @@ +use iced::Element; +use iced::widget::text; + +use crate::message::Message; + +pub fn view_stopping_cluster() -> Element<'static, Message> { + text("Stopping cluster...").into() +} From bfa22b0e8cee6ba47b3b2eabe69e8729ca78c04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 00:51:19 +0100 Subject: [PATCH 06/46] add model preset --- Cargo.lock | 1 + paddler_second_brain_gui/Cargo.toml | 1 + paddler_second_brain_gui/src/main.rs | 1 + paddler_second_brain_gui/src/message.rs | 4 +- paddler_second_brain_gui/src/model_preset.rs | 60 +++++++++++++++++++ .../src/running_cluster_data.rs | 2 +- paddler_second_brain_gui/src/screen.rs | 7 ++- paddler_second_brain_gui/src/second_brain.rs | 13 +++- .../src/start_balancer.rs | 8 ++- .../src/start_balancer_services.rs | 10 +++- .../src/start_cluster_config_data.rs | 4 +- .../src/starting_cluster_data.rs | 2 +- .../src/view_start_cluster_config.rs | 25 ++++---- 13 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 paddler_second_brain_gui/src/model_preset.rs diff --git a/Cargo.lock b/Cargo.lock index 9f7f29c6..724c7acb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4477,6 +4477,7 @@ dependencies = [ "iced", "log", "paddler", + "paddler_types", "statum", "tokio", ] diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index 6bdf1ae2..97010c78 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -13,6 +13,7 @@ env_logger = { workspace = true } iced = { workspace = true } log = { workspace = true } paddler = { workspace = true } +paddler_types = { workspace = true } statum = { workspace = true } tokio = { workspace = true } diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 8ebea6c4..abc652b7 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,4 +1,5 @@ mod message; +mod model_preset; mod running_cluster_data; mod screen; mod screen_current; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index b7ed10a3..19b90b48 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -1,8 +1,10 @@ +use crate::model_preset::ModelPreset; + #[derive(Debug, Clone)] pub enum Message { StartCluster, Cancel, - SelectModel(String), + SelectModel(ModelPreset), ToggleRunAgentLocally(bool), Confirm, ClusterStarted, diff --git a/paddler_second_brain_gui/src/model_preset.rs b/paddler_second_brain_gui/src/model_preset.rs new file mode 100644 index 00000000..c4f1b282 --- /dev/null +++ b/paddler_second_brain_gui/src/model_preset.rs @@ -0,0 +1,60 @@ +use std::fmt; + +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; +use paddler_types::inference_parameters::InferenceParameters; + +#[derive(Clone, Debug)] +pub struct ModelPreset { + pub display_name: String, + pub model: HuggingFaceModelReference, + pub multimodal_projection: Option, + pub inference_parameters: InferenceParameters, +} + +impl ModelPreset { + pub fn available_presets() -> Vec { + vec![ModelPreset { + display_name: "Qwen 3.5 0.8B".to_string(), + model: HuggingFaceModelReference { + repo_id: "unsloth/Qwen3.5-0.8B-GGUF".to_string(), + filename: "Qwen3.5-0.8B-Q4_K_M.gguf".to_string(), + revision: "main".to_string(), + }, + multimodal_projection: Some(HuggingFaceModelReference { + repo_id: "unsloth/Qwen3.5-0.8B-GGUF".to_string(), + filename: "mmproj-F16.gguf".to_string(), + revision: "main".to_string(), + }), + inference_parameters: InferenceParameters::default(), + }] + } + + pub fn to_balancer_desired_state(&self) -> BalancerDesiredState { + let multimodal_projection = match &self.multimodal_projection { + Some(reference) => AgentDesiredModel::HuggingFace(reference.clone()), + None => AgentDesiredModel::None, + }; + + BalancerDesiredState { + chat_template_override: None, + inference_parameters: self.inference_parameters.clone(), + model: AgentDesiredModel::HuggingFace(self.model.clone()), + multimodal_projection, + use_chat_template_override: false, + } + } +} + +impl fmt::Display for ModelPreset { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.display_name) + } +} + +impl PartialEq for ModelPreset { + fn eq(&self, other: &Self) -> bool { + self.display_name == other.display_name + } +} diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs index 2fcb665f..6aad3efa 100644 --- a/paddler_second_brain_gui/src/running_cluster_data.rs +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -1,5 +1,5 @@ pub struct RunningClusterData { pub cluster_address: String, - pub selected_model: Option, + pub selected_model_name: String, pub run_agent_locally: bool, } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index d14cb9ca..2ddac013 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -33,7 +33,10 @@ impl Screen { pub fn confirm(self) -> Screen { self.transition_map(|config_data| StartingClusterData { - selected_model: config_data.selected_model, + selected_model_name: config_data + .selected_model + .map(|preset| preset.display_name) + .unwrap_or_default(), run_agent_locally: config_data.run_agent_locally, }) } @@ -44,7 +47,7 @@ impl Screen { pub fn cluster_started(self, cluster_address: String) -> Screen { self.transition_map(|starting_data| RunningClusterData { cluster_address, - selected_model: starting_data.selected_model, + selected_model_name: starting_data.selected_model_name, run_agent_locally: starting_data.run_agent_locally, }) } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 8853cba6..8755a359 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -55,8 +55,8 @@ impl SecondBrain { Task::none() } - (CurrentScreen::StartClusterConfig(mut config), Message::SelectModel(model)) => { - config.state_data.selected_model = Some(model); + (CurrentScreen::StartClusterConfig(mut config), Message::SelectModel(preset)) => { + config.state_data.selected_model = Some(preset); self.screen = CurrentScreen::StartClusterConfig(config); Task::none() @@ -71,13 +71,20 @@ impl SecondBrain { Task::none() } (CurrentScreen::StartClusterConfig(config), Message::Confirm) => { + let desired_state = config + .state_data + .selected_model + .as_ref() + .map(|preset| preset.to_balancer_desired_state()) + .unwrap_or_default(); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); self.shutdown_tx = Some(shutdown_tx); self.screen = CurrentScreen::StartingCluster(config.confirm()); Task::batch([ Task::perform( - start_balancer(shutdown_rx), + start_balancer(desired_state, shutdown_rx), |result: Result<(), anyhow::Error>| match result { Ok(()) => Message::ClusterStopped, Err(error) => Message::ClusterFailed(error.to_string()), diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs index 9d3132ae..df239079 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -1,13 +1,17 @@ +use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::oneshot; use crate::start_balancer_services::start_balancer_services; -pub async fn start_balancer(shutdown_rx: oneshot::Receiver<()>) -> anyhow::Result<()> { +pub async fn start_balancer( + initial_desired_state: BalancerDesiredState, + shutdown_rx: oneshot::Receiver<()>, +) -> anyhow::Result<()> { let (result_tx, result_rx) = oneshot::channel(); std::thread::spawn(move || { let system = actix_web::rt::System::new(); - let result = system.block_on(start_balancer_services(shutdown_rx)); + let result = system.block_on(start_balancer_services(initial_desired_state, shutdown_rx)); if let Err(unsent_result) = result_tx.send(result) { log::error!("Failed to send balancer result: {unsent_result:?}"); } diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs index f00bd7cc..71c550b6 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -17,10 +17,14 @@ use paddler::balancer::state_database::Memory; use paddler::balancer::state_database::StateDatabase; use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; use paddler::service_manager::ServiceManager; +use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::broadcast; use tokio::sync::oneshot; -pub async fn start_balancer_services(shutdown_rx: oneshot::Receiver<()>) -> anyhow::Result<()> { +pub async fn start_balancer_services( + initial_desired_state: BalancerDesiredState, + shutdown_rx: oneshot::Receiver<()>, +) -> anyhow::Result<()> { let management_addr: SocketAddr = "127.0.0.1:8060".parse()?; let inference_addr: SocketAddr = "127.0.0.1:8061".parse()?; @@ -42,6 +46,10 @@ pub async fn start_balancer_services(shutdown_rx: oneshot::Receiver<()>) -> anyh let state_database: Arc = Arc::new(Memory::new(balancer_desired_state_tx.clone())); + state_database + .store_balancer_desired_state(&initial_desired_state) + .await?; + service_manager.add_service(InferenceService { balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), buffered_request_manager: buffered_request_manager.clone(), diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index 0e8a6caf..bf78fd88 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -1,5 +1,7 @@ +use crate::model_preset::ModelPreset; + #[derive(Default)] pub struct StartClusterConfigData { - pub selected_model: Option, + pub selected_model: Option, pub run_agent_locally: bool, } diff --git a/paddler_second_brain_gui/src/starting_cluster_data.rs b/paddler_second_brain_gui/src/starting_cluster_data.rs index 148069de..70d088da 100644 --- a/paddler_second_brain_gui/src/starting_cluster_data.rs +++ b/paddler_second_brain_gui/src/starting_cluster_data.rs @@ -1,4 +1,4 @@ pub struct StartingClusterData { - pub selected_model: Option, + pub selected_model_name: String, pub run_agent_locally: bool, } diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index 94d09e83..0d52ec5a 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -6,31 +6,32 @@ use iced::widget::text; use iced::widget::toggler; use crate::message::Message; +use crate::model_preset::ModelPreset; use crate::start_cluster_config_data::StartClusterConfigData; -const AVAILABLE_MODELS: &[&str] = &[ - "llama-3.2-1b", - "llama-3.2-3b", - "llama-3.1-8b", - "mistral-7b", - "phi-3-mini", -]; - pub fn view_start_cluster_config<'content>( data: &'content StartClusterConfigData, ) -> Element<'content, Message> { + let available_models = ModelPreset::available_presets(); + + let confirm_button = if data.selected_model.is_some() { + button("Start a cluster").on_press(Message::Confirm) + } else { + button("Start a cluster") + }; + column![ button("Back").on_press(Message::Cancel), text("Select a model"), pick_list( - AVAILABLE_MODELS, - data.selected_model.as_deref(), - |model: &str| Message::SelectModel(model.to_owned()), + available_models, + data.selected_model.as_ref(), + Message::SelectModel, ), toggler(data.run_agent_locally) .label("Run an agent on your computer") .on_toggle(Message::ToggleRunAgentLocally), - button("Start a cluster").on_press(Message::Confirm), + confirm_button, ] .spacing(10) .into() From 1728f419ef731f5568434e1eb767c787a725e733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 03:03:45 +0100 Subject: [PATCH 07/46] detec and show IP address of the cluster manager --- Cargo.lock | 13 ++++ Cargo.toml | 3 +- paddler_second_brain_gui/Cargo.toml | 2 + .../src/detect_network_interfaces.rs | 31 +++++++++ paddler_second_brain_gui/src/main.rs | 7 +- paddler_second_brain_gui/src/message.rs | 1 + .../src/network_interface_address.rs | 7 ++ .../src/network_monitor_service.rs | 54 +++++++++++++++ .../src/running_cluster_data.rs | 5 +- paddler_second_brain_gui/src/screen.rs | 14 +++- paddler_second_brain_gui/src/second_brain.rs | 68 +++++++++++++++++-- .../src/start_balancer.rs | 13 +++- .../src/start_balancer_services.rs | 15 +++- .../src/start_cluster_config_data.rs | 1 + .../src/starting_cluster_data.rs | 4 ++ .../src/view_running_cluster.rs | 23 +++++-- .../src/view_start_cluster_config.rs | 11 ++- 17 files changed, 249 insertions(+), 23 deletions(-) create mode 100644 paddler_second_brain_gui/src/detect_network_interfaces.rs create mode 100644 paddler_second_brain_gui/src/network_interface_address.rs create mode 100644 paddler_second_brain_gui/src/network_monitor_service.rs diff --git a/Cargo.lock b/Cargo.lock index 724c7acb..5e659634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2700,6 +2700,7 @@ dependencies = [ "iced_core", "log", "rustc-hash 2.1.1", + "tokio", "wasm-bindgen-futures", "wasmtimer", ] @@ -2937,6 +2938,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "image" version = "0.25.9" @@ -4473,8 +4484,10 @@ version = "3.0.1" dependencies = [ "actix-web", "anyhow", + "async-trait", "env_logger", "iced", + "if-addrs", "log", "paddler", "paddler_types", diff --git a/Cargo.toml b/Cargo.toml index b9e9ef87..b1872dad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,8 @@ serial_test = { version = "3", features = ["file_locks"] } serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" -iced = "0.14" +iced = { version = "0.14", features = ["tokio"] } +if-addrs = "0.13" statum = "0.6" tempfile = "3.20.0" tokio = { version = "1.48", features = ["full"] } diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index 97010c78..1b51d829 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -9,8 +9,10 @@ license.workspace = true [dependencies] actix-web = { workspace = true } anyhow = { workspace = true } +async-trait = { workspace = true } env_logger = { workspace = true } iced = { workspace = true } +if-addrs = { workspace = true } log = { workspace = true } paddler = { workspace = true } paddler_types = { workspace = true } diff --git a/paddler_second_brain_gui/src/detect_network_interfaces.rs b/paddler_second_brain_gui/src/detect_network_interfaces.rs new file mode 100644 index 00000000..9bc1c390 --- /dev/null +++ b/paddler_second_brain_gui/src/detect_network_interfaces.rs @@ -0,0 +1,31 @@ +use std::net::IpAddr; + +use crate::network_interface_address::NetworkInterfaceAddress; + +pub fn detect_network_interfaces() -> Vec { + let interfaces = match if_addrs::get_if_addrs() { + Ok(interfaces) => interfaces, + Err(error) => { + log::error!("Failed to detect network interfaces: {error}"); + + return Vec::new(); + } + }; + + interfaces + .into_iter() + .filter(|interface| !interface.is_loopback()) + .filter(|interface| match interface.ip() { + IpAddr::V4(ipv4) => ipv4.is_private(), + IpAddr::V6(_) => false, + }) + .map(|interface| { + let ip_address = interface.ip(); + + NetworkInterfaceAddress { + interface_name: interface.name, + ip_address, + } + }) + .collect() +} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index abc652b7..de36bf3f 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,5 +1,8 @@ +mod detect_network_interfaces; mod message; mod model_preset; +mod network_interface_address; +mod network_monitor_service; mod running_cluster_data; mod screen; mod screen_current; @@ -19,5 +22,7 @@ use second_brain::SecondBrain; fn main() -> iced::Result { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - iced::application(SecondBrain::new, SecondBrain::update, SecondBrain::view).run() + iced::application(SecondBrain::new, SecondBrain::update, SecondBrain::view) + .subscription(SecondBrain::subscription) + .run() } diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index 19b90b48..f1a0af47 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -11,4 +11,5 @@ pub enum Message { ClusterFailed(String), Stop, ClusterStopped, + RefreshNetworkInterfaces, } diff --git a/paddler_second_brain_gui/src/network_interface_address.rs b/paddler_second_brain_gui/src/network_interface_address.rs new file mode 100644 index 00000000..50916b25 --- /dev/null +++ b/paddler_second_brain_gui/src/network_interface_address.rs @@ -0,0 +1,7 @@ +use std::net::IpAddr; + +#[derive(Clone, Debug, PartialEq)] +pub struct NetworkInterfaceAddress { + pub interface_name: String, + pub ip_address: IpAddr, +} diff --git a/paddler_second_brain_gui/src/network_monitor_service.rs b/paddler_second_brain_gui/src/network_monitor_service.rs new file mode 100644 index 00000000..85878fd0 --- /dev/null +++ b/paddler_second_brain_gui/src/network_monitor_service.rs @@ -0,0 +1,54 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use paddler::service::Service; +use tokio::sync::broadcast; +use tokio::sync::mpsc; + +use crate::detect_network_interfaces::detect_network_interfaces; +use crate::network_interface_address::NetworkInterfaceAddress; + +pub struct NetworkMonitorService { + pub network_interfaces_tx: mpsc::UnboundedSender>, +} + +#[async_trait] +impl Service for NetworkMonitorService { + fn name(&self) -> &'static str { + "network_monitor" + } + + async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + let mut previous_interfaces: Option> = None; + + loop { + tokio::select! { + _ = interval.tick() => { + let interfaces = detect_network_interfaces(); + + let has_changed = previous_interfaces + .as_ref() + .map(|previous| *previous != interfaces) + .unwrap_or(true); + + if has_changed { + if self.network_interfaces_tx.send(interfaces.clone()).is_err() { + log::warn!("Network interfaces receiver dropped"); + + break; + } + + previous_interfaces = Some(interfaces); + } + } + _ = shutdown_rx.recv() => { + break; + } + } + } + + Ok(()) + } +} diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs index 6aad3efa..81208750 100644 --- a/paddler_second_brain_gui/src/running_cluster_data.rs +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -1,5 +1,8 @@ +use crate::network_interface_address::NetworkInterfaceAddress; + pub struct RunningClusterData { - pub cluster_address: String, + pub network_interfaces: Vec, + pub management_port: u16, pub selected_model_name: String, pub run_agent_locally: bool, } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index 2ddac013..d28775b9 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -2,6 +2,7 @@ use statum::machine; use statum::state; use statum::transition; +use crate::network_interface_address::NetworkInterfaceAddress; use crate::running_cluster_data::RunningClusterData; use crate::start_cluster_config_data::StartClusterConfigData; use crate::starting_cluster_data::StartingClusterData; @@ -31,8 +32,14 @@ impl Screen { self.transition() } - pub fn confirm(self) -> Screen { + pub fn confirm( + self, + network_interfaces: Vec, + management_port: u16, + ) -> Screen { self.transition_map(|config_data| StartingClusterData { + network_interfaces, + management_port, selected_model_name: config_data .selected_model .map(|preset| preset.display_name) @@ -44,9 +51,10 @@ impl Screen { #[transition] impl Screen { - pub fn cluster_started(self, cluster_address: String) -> Screen { + pub fn cluster_started(self) -> Screen { self.transition_map(|starting_data| RunningClusterData { - cluster_address, + network_interfaces: starting_data.network_interfaces, + management_port: starting_data.management_port, selected_model_name: starting_data.selected_model_name, run_agent_locally: starting_data.run_agent_locally, }) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 8755a359..adeaed72 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -1,13 +1,19 @@ use std::mem; +use std::time::Duration; use iced::Center; use iced::Element; +use iced::Subscription; use iced::Task; +use iced::time; use iced::widget::column; use iced::widget::text; +use tokio::sync::mpsc; use tokio::sync::oneshot; +use crate::detect_network_interfaces::detect_network_interfaces; use crate::message::Message; +use crate::network_interface_address::NetworkInterfaceAddress; use crate::screen_current::CurrentScreen; use crate::start_balancer::start_balancer; use crate::view_home::view_home; @@ -17,6 +23,7 @@ use crate::view_starting_cluster::view_starting_cluster; use crate::view_stopping_cluster::view_stopping_cluster; pub struct SecondBrain { + network_interfaces_rx: Option>>, screen: CurrentScreen, shutdown_tx: Option>, } @@ -34,6 +41,7 @@ impl Drop for SecondBrain { impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { + network_interfaces_rx: None, screen: CurrentScreen::default(), shutdown_tx: None, }; @@ -57,6 +65,7 @@ impl SecondBrain { } (CurrentScreen::StartClusterConfig(mut config), Message::SelectModel(preset)) => { config.state_data.selected_model = Some(preset); + config.state_data.error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() @@ -66,11 +75,29 @@ impl SecondBrain { Message::ToggleRunAgentLocally(enabled), ) => { config.state_data.run_agent_locally = enabled; + config.state_data.error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() } (CurrentScreen::StartClusterConfig(config), Message::Confirm) => { + let network_interfaces = detect_network_interfaces(); + let management_port = 8060; + + let bind_ip = match network_interfaces.first() { + Some(interface) => interface.ip_address, + None => { + let mut config = config; + config.state_data.error = Some( + "No local network found. Connect to internet to start a cluster." + .to_string(), + ); + self.screen = CurrentScreen::StartClusterConfig(config); + + return Task::none(); + } + }; + let desired_state = config .state_data .selected_model @@ -79,12 +106,18 @@ impl SecondBrain { .unwrap_or_default(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let (network_interfaces_tx, network_interfaces_rx) = + mpsc::unbounded_channel::>(); + + self.network_interfaces_rx = Some(network_interfaces_rx); self.shutdown_tx = Some(shutdown_tx); - self.screen = CurrentScreen::StartingCluster(config.confirm()); + self.screen = CurrentScreen::StartingCluster( + config.confirm(network_interfaces, management_port), + ); Task::batch([ Task::perform( - start_balancer(desired_state, shutdown_rx), + start_balancer(bind_ip, desired_state, network_interfaces_tx, shutdown_rx), |result: Result<(), anyhow::Error>| match result { Ok(()) => Message::ClusterStopped, Err(error) => Message::ClusterFailed(error.to_string()), @@ -94,9 +127,7 @@ impl SecondBrain { ]) } (CurrentScreen::StartingCluster(starting), Message::ClusterStarted) => { - let cluster_address = "192.168.1.1".to_string(); // TODO: detect local IP - self.screen = - CurrentScreen::RunningCluster(starting.cluster_started(cluster_address)); + self.screen = CurrentScreen::RunningCluster(starting.cluster_started()); Task::none() } @@ -107,18 +138,36 @@ impl SecondBrain { Task::none() } + (CurrentScreen::RunningCluster(mut running), Message::RefreshNetworkInterfaces) => { + if let Some(network_interfaces_rx) = &mut self.network_interfaces_rx { + let mut latest_interfaces = None; + + while let Ok(interfaces) = network_interfaces_rx.try_recv() { + latest_interfaces = Some(interfaces); + } + + if let Some(interfaces) = latest_interfaces { + running.state_data.network_interfaces = interfaces; + } + } + self.screen = CurrentScreen::RunningCluster(running); + + Task::none() + } (CurrentScreen::RunningCluster(running), Message::Stop) => { if let Some(shutdown_tx) = self.shutdown_tx.take() && let Err(unsent_signal) = shutdown_tx.send(()) { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } + self.network_interfaces_rx = None; self.screen = CurrentScreen::StoppingCluster(running.stop()); Task::none() } (CurrentScreen::RunningCluster(running), Message::ClusterFailed(error)) => { log::error!("Cluster failed unexpectedly: {error}"); + self.network_interfaces_rx = None; self.shutdown_tx = None; self.screen = CurrentScreen::Home(running.cluster_failed()); @@ -144,6 +193,15 @@ impl SecondBrain { } } + pub fn subscription(&self) -> Subscription { + match &self.screen { + CurrentScreen::RunningCluster(_) => { + time::every(Duration::from_secs(1)).map(|_| Message::RefreshNetworkInterfaces) + } + _ => Subscription::none(), + } + } + pub fn view(&self) -> Element<'_, Message> { let screen_content = match &self.screen { CurrentScreen::Home(_) => view_home(), diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs index df239079..2458f688 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -1,17 +1,28 @@ +use std::net::IpAddr; + use paddler_types::balancer_desired_state::BalancerDesiredState; +use tokio::sync::mpsc; use tokio::sync::oneshot; +use crate::network_interface_address::NetworkInterfaceAddress; use crate::start_balancer_services::start_balancer_services; pub async fn start_balancer( + bind_ip: IpAddr, initial_desired_state: BalancerDesiredState, + network_interfaces_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { let (result_tx, result_rx) = oneshot::channel(); std::thread::spawn(move || { let system = actix_web::rt::System::new(); - let result = system.block_on(start_balancer_services(initial_desired_state, shutdown_rx)); + let result = system.block_on(start_balancer_services( + bind_ip, + initial_desired_state, + network_interfaces_tx, + shutdown_rx, + )); if let Err(unsent_result) = result_tx.send(result) { log::error!("Failed to send balancer result: {unsent_result:?}"); } diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs index 71c550b6..f63354fe 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -19,14 +20,20 @@ use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; use paddler::service_manager::ServiceManager; use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::broadcast; +use tokio::sync::mpsc; use tokio::sync::oneshot; +use crate::network_interface_address::NetworkInterfaceAddress; +use crate::network_monitor_service::NetworkMonitorService; + pub async fn start_balancer_services( + bind_ip: IpAddr, initial_desired_state: BalancerDesiredState, + network_interfaces_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { - let management_addr: SocketAddr = "127.0.0.1:8060".parse()?; - let inference_addr: SocketAddr = "127.0.0.1:8061".parse()?; + let management_addr = SocketAddr::new(bind_ip, 8060); + let inference_addr = SocketAddr::new(bind_ip, 8061); let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); @@ -80,6 +87,10 @@ pub async fn start_balancer_services( web_admin_panel_service_configuration: None, }); + service_manager.add_service(NetworkMonitorService { + network_interfaces_tx, + }); + service_manager.add_service(ReconciliationService { agent_controller_pool: agent_controller_pool.clone(), balancer_applicable_state_holder, diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index bf78fd88..26226653 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -2,6 +2,7 @@ use crate::model_preset::ModelPreset; #[derive(Default)] pub struct StartClusterConfigData { + pub error: Option, pub selected_model: Option, pub run_agent_locally: bool, } diff --git a/paddler_second_brain_gui/src/starting_cluster_data.rs b/paddler_second_brain_gui/src/starting_cluster_data.rs index 70d088da..f50193d7 100644 --- a/paddler_second_brain_gui/src/starting_cluster_data.rs +++ b/paddler_second_brain_gui/src/starting_cluster_data.rs @@ -1,4 +1,8 @@ +use crate::network_interface_address::NetworkInterfaceAddress; + pub struct StartingClusterData { + pub network_interfaces: Vec, + pub management_port: u16, pub selected_model_name: String, pub run_agent_locally: bool, } diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index 46daa34d..baf17fad 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -1,6 +1,7 @@ use iced::Element; use iced::widget::button; use iced::widget::column; +use iced::widget::row; use iced::widget::text; use crate::message::Message; @@ -9,10 +10,20 @@ use crate::running_cluster_data::RunningClusterData; pub fn view_running_cluster<'content>( data: &'content RunningClusterData, ) -> Element<'content, Message> { - column![ - text(format!("Cluster is running at {}", data.cluster_address)), - button("Stop cluster").on_press(Message::Stop), - ] - .spacing(10) - .into() + let mut content = column![text("Your cluster").size(20)].spacing(10); + + for interface in &data.network_interfaces { + let address = format!("{}:{}", interface.ip_address, data.management_port); + + content = content + .push(row![text(interface.interface_name.to_string()), text(address),].spacing(10)); + } + + if data.network_interfaces.is_empty() { + content = content.push(text("No network interfaces detected")); + } + + content = content.push(button("Stop cluster").on_press(Message::Stop)); + + content.into() } diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index 0d52ec5a..5066ed17 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -20,7 +20,7 @@ pub fn view_start_cluster_config<'content>( button("Start a cluster") }; - column![ + let mut content = column![ button("Back").on_press(Message::Cancel), text("Select a model"), pick_list( @@ -33,6 +33,11 @@ pub fn view_start_cluster_config<'content>( .on_toggle(Message::ToggleRunAgentLocally), confirm_button, ] - .spacing(10) - .into() + .spacing(10); + + if let Some(error) = &data.error { + content = content.push(text(error.clone())); + } + + content.into() } From 3a839e8dc44cbf89245daa349a99fa5e85fa2813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 03:29:18 +0100 Subject: [PATCH 08/46] add agent monitoring and count --- .../src/agent_monitor_service.rs | 51 +++++++++++++++++++ paddler_second_brain_gui/src/main.rs | 1 + paddler_second_brain_gui/src/message.rs | 1 + .../src/running_cluster_data.rs | 1 + paddler_second_brain_gui/src/screen.rs | 1 + paddler_second_brain_gui/src/second_brain.rs | 37 ++++++++++++-- .../src/start_balancer.rs | 2 + .../src/start_balancer_services.rs | 7 +++ .../src/view_running_cluster.rs | 7 ++- 9 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 paddler_second_brain_gui/src/agent_monitor_service.rs diff --git a/paddler_second_brain_gui/src/agent_monitor_service.rs b/paddler_second_brain_gui/src/agent_monitor_service.rs new file mode 100644 index 00000000..2b228aa5 --- /dev/null +++ b/paddler_second_brain_gui/src/agent_monitor_service.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler::service::Service; +use tokio::sync::broadcast; +use tokio::sync::mpsc; + +pub struct AgentMonitorService { + pub agent_controller_pool: Arc, + pub agent_count_tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl Service for AgentMonitorService { + fn name(&self) -> &'static str { + "agent_monitor" + } + + async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { + let mut previous_count: Option = None; + + loop { + let count = self.agent_controller_pool.agents.len(); + + let has_changed = previous_count + .map(|previous| previous != count) + .unwrap_or(true); + + if has_changed { + if self.agent_count_tx.send(count).is_err() { + log::warn!("Agent count receiver dropped"); + + break; + } + + previous_count = Some(count); + } + + tokio::select! { + _ = self.agent_controller_pool.update_notifier.notified() => {} + _ = shutdown_rx.recv() => { + break; + } + } + } + + Ok(()) + } +} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index de36bf3f..b84c1c22 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,3 +1,4 @@ +mod agent_monitor_service; mod detect_network_interfaces; mod message; mod model_preset; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index f1a0af47..470e2aed 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -11,5 +11,6 @@ pub enum Message { ClusterFailed(String), Stop, ClusterStopped, + RefreshAgentCount, RefreshNetworkInterfaces, } diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs index 81208750..bbcdf1b9 100644 --- a/paddler_second_brain_gui/src/running_cluster_data.rs +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -1,6 +1,7 @@ use crate::network_interface_address::NetworkInterfaceAddress; pub struct RunningClusterData { + pub agent_count: usize, pub network_interfaces: Vec, pub management_port: u16, pub selected_model_name: String, diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index d28775b9..93acb49b 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -53,6 +53,7 @@ impl Screen { impl Screen { pub fn cluster_started(self) -> Screen { self.transition_map(|starting_data| RunningClusterData { + agent_count: 0, network_interfaces: starting_data.network_interfaces, management_port: starting_data.management_port, selected_model_name: starting_data.selected_model_name, diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index adeaed72..8b5f4148 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -23,6 +23,7 @@ use crate::view_starting_cluster::view_starting_cluster; use crate::view_stopping_cluster::view_stopping_cluster; pub struct SecondBrain { + agent_count_rx: Option>, network_interfaces_rx: Option>>, screen: CurrentScreen, shutdown_tx: Option>, @@ -41,6 +42,7 @@ impl Drop for SecondBrain { impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { + agent_count_rx: None, network_interfaces_rx: None, screen: CurrentScreen::default(), shutdown_tx: None, @@ -106,9 +108,11 @@ impl SecondBrain { .unwrap_or_default(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let (agent_count_tx, agent_count_rx) = mpsc::unbounded_channel::(); let (network_interfaces_tx, network_interfaces_rx) = mpsc::unbounded_channel::>(); + self.agent_count_rx = Some(agent_count_rx); self.network_interfaces_rx = Some(network_interfaces_rx); self.shutdown_tx = Some(shutdown_tx); self.screen = CurrentScreen::StartingCluster( @@ -117,7 +121,13 @@ impl SecondBrain { Task::batch([ Task::perform( - start_balancer(bind_ip, desired_state, network_interfaces_tx, shutdown_rx), + start_balancer( + bind_ip, + desired_state, + agent_count_tx, + network_interfaces_tx, + shutdown_rx, + ), |result: Result<(), anyhow::Error>| match result { Ok(()) => Message::ClusterStopped, Err(error) => Message::ClusterFailed(error.to_string()), @@ -138,6 +148,22 @@ impl SecondBrain { Task::none() } + (CurrentScreen::RunningCluster(mut running), Message::RefreshAgentCount) => { + if let Some(agent_count_rx) = &mut self.agent_count_rx { + let mut latest_count = None; + + while let Ok(count) = agent_count_rx.try_recv() { + latest_count = Some(count); + } + + if let Some(count) = latest_count { + running.state_data.agent_count = count; + } + } + self.screen = CurrentScreen::RunningCluster(running); + + Task::none() + } (CurrentScreen::RunningCluster(mut running), Message::RefreshNetworkInterfaces) => { if let Some(network_interfaces_rx) = &mut self.network_interfaces_rx { let mut latest_interfaces = None; @@ -160,6 +186,7 @@ impl SecondBrain { { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } + self.agent_count_rx = None; self.network_interfaces_rx = None; self.screen = CurrentScreen::StoppingCluster(running.stop()); @@ -167,6 +194,7 @@ impl SecondBrain { } (CurrentScreen::RunningCluster(running), Message::ClusterFailed(error)) => { log::error!("Cluster failed unexpectedly: {error}"); + self.agent_count_rx = None; self.network_interfaces_rx = None; self.shutdown_tx = None; self.screen = CurrentScreen::Home(running.cluster_failed()); @@ -195,9 +223,10 @@ impl SecondBrain { pub fn subscription(&self) -> Subscription { match &self.screen { - CurrentScreen::RunningCluster(_) => { - time::every(Duration::from_secs(1)).map(|_| Message::RefreshNetworkInterfaces) - } + CurrentScreen::RunningCluster(_) => Subscription::batch([ + time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentCount), + time::every(Duration::from_secs(1)).map(|_| Message::RefreshNetworkInterfaces), + ]), _ => Subscription::none(), } } diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs index 2458f688..241c9a95 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -10,6 +10,7 @@ use crate::start_balancer_services::start_balancer_services; pub async fn start_balancer( bind_ip: IpAddr, initial_desired_state: BalancerDesiredState, + agent_count_tx: mpsc::UnboundedSender, network_interfaces_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { @@ -20,6 +21,7 @@ pub async fn start_balancer( let result = system.block_on(start_balancer_services( bind_ip, initial_desired_state, + agent_count_tx, network_interfaces_tx, shutdown_rx, )); diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs index f63354fe..e4541170 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -23,12 +23,14 @@ use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; +use crate::agent_monitor_service::AgentMonitorService; use crate::network_interface_address::NetworkInterfaceAddress; use crate::network_monitor_service::NetworkMonitorService; pub async fn start_balancer_services( bind_ip: IpAddr, initial_desired_state: BalancerDesiredState, + agent_count_tx: mpsc::UnboundedSender, network_interfaces_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { @@ -87,6 +89,11 @@ pub async fn start_balancer_services( web_admin_panel_service_configuration: None, }); + service_manager.add_service(AgentMonitorService { + agent_controller_pool: agent_controller_pool.clone(), + agent_count_tx, + }); + service_manager.add_service(NetworkMonitorService { network_interfaces_tx, }); diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index baf17fad..cfa3de0c 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -10,7 +10,12 @@ use crate::running_cluster_data::RunningClusterData; pub fn view_running_cluster<'content>( data: &'content RunningClusterData, ) -> Element<'content, Message> { - let mut content = column![text("Your cluster").size(20)].spacing(10); + let agent_label = match data.agent_count { + 1 => "1 agent connected".to_string(), + count => format!("{count} agents connected"), + }; + + let mut content = column![text("Your cluster").size(20), text(agent_label)].spacing(10); for interface in &data.network_interfaces { let address = format!("{}:{}", interface.ip_address, data.management_port); From 4dc25b2b1abaf5638abf21a74c6934b49566dc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 04:14:56 +0100 Subject: [PATCH 09/46] add support for registering an agent --- Cargo.lock | 1 + paddler_second_brain_gui/Cargo.toml | 1 + .../src/agent_running_data.rs | 5 + .../src/agent_status_monitor_service.rs | 53 +++++++ .../src/join_cluster_config_data.rs | 6 + paddler_second_brain_gui/src/main.rs | 7 + paddler_second_brain_gui/src/message.rs | 8 ++ paddler_second_brain_gui/src/screen.rs | 34 +++++ .../src/screen_current.rs | 4 + paddler_second_brain_gui/src/second_brain.rs | 129 ++++++++++++++++++ paddler_second_brain_gui/src/start_agent.rs | 31 +++++ .../src/start_agent_services.rs | 93 +++++++++++++ .../src/view_agent_running.rs | 51 +++++++ paddler_second_brain_gui/src/view_home.rs | 2 +- .../src/view_join_cluster_config.rs | 43 ++++++ .../src/view_running_cluster.rs | 7 +- 16 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 paddler_second_brain_gui/src/agent_running_data.rs create mode 100644 paddler_second_brain_gui/src/agent_status_monitor_service.rs create mode 100644 paddler_second_brain_gui/src/join_cluster_config_data.rs create mode 100644 paddler_second_brain_gui/src/start_agent.rs create mode 100644 paddler_second_brain_gui/src/start_agent_services.rs create mode 100644 paddler_second_brain_gui/src/view_agent_running.rs create mode 100644 paddler_second_brain_gui/src/view_join_cluster_config.rs diff --git a/Cargo.lock b/Cargo.lock index 5e659634..cbc74f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4489,6 +4489,7 @@ dependencies = [ "iced", "if-addrs", "log", + "nanoid", "paddler", "paddler_types", "statum", diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index 1b51d829..6e45ccf8 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -14,6 +14,7 @@ env_logger = { workspace = true } iced = { workspace = true } if-addrs = { workspace = true } log = { workspace = true } +nanoid = { workspace = true } paddler = { workspace = true } paddler_types = { workspace = true } statum = { workspace = true } diff --git a/paddler_second_brain_gui/src/agent_running_data.rs b/paddler_second_brain_gui/src/agent_running_data.rs new file mode 100644 index 00000000..bb252ba2 --- /dev/null +++ b/paddler_second_brain_gui/src/agent_running_data.rs @@ -0,0 +1,5 @@ +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + +pub struct AgentRunningData { + pub status: Option, +} diff --git a/paddler_second_brain_gui/src/agent_status_monitor_service.rs b/paddler_second_brain_gui/src/agent_status_monitor_service.rs new file mode 100644 index 00000000..4acebe25 --- /dev/null +++ b/paddler_second_brain_gui/src/agent_status_monitor_service.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use paddler::produces_snapshot::ProducesSnapshot; +use paddler::service::Service; +use paddler::slot_aggregated_status::SlotAggregatedStatus; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; +use tokio::sync::broadcast; +use tokio::sync::mpsc; + +pub struct AgentStatusMonitorService { + pub agent_status_tx: mpsc::UnboundedSender, + pub slot_aggregated_status: Arc, +} + +#[async_trait] +impl Service for AgentStatusMonitorService { + fn name(&self) -> &'static str { + "agent_status_monitor" + } + + async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { + let mut previous_version: Option = None; + + loop { + let snapshot = self.slot_aggregated_status.make_snapshot()?; + + let has_changed = previous_version + .map(|previous| previous != snapshot.version) + .unwrap_or(true); + + if has_changed { + previous_version = Some(snapshot.version); + + if self.agent_status_tx.send(snapshot).is_err() { + log::warn!("Agent status receiver dropped"); + + break; + } + } + + tokio::select! { + _ = self.slot_aggregated_status.update_notifier.notified() => {} + _ = shutdown_rx.recv() => { + break; + } + } + } + + Ok(()) + } +} diff --git a/paddler_second_brain_gui/src/join_cluster_config_data.rs b/paddler_second_brain_gui/src/join_cluster_config_data.rs new file mode 100644 index 00000000..720978d5 --- /dev/null +++ b/paddler_second_brain_gui/src/join_cluster_config_data.rs @@ -0,0 +1,6 @@ +#[derive(Default)] +pub struct JoinClusterConfigData { + pub cluster_address: String, + pub error: Option, + pub slots_count: String, +} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index b84c1c22..a75947ff 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,5 +1,8 @@ mod agent_monitor_service; +mod agent_running_data; +mod agent_status_monitor_service; mod detect_network_interfaces; +mod join_cluster_config_data; mod message; mod model_preset; mod network_interface_address; @@ -8,11 +11,15 @@ mod running_cluster_data; mod screen; mod screen_current; mod second_brain; +mod start_agent; +mod start_agent_services; mod start_balancer; mod start_balancer_services; mod start_cluster_config_data; mod starting_cluster_data; +mod view_agent_running; mod view_home; +mod view_join_cluster_config; mod view_running_cluster; mod view_start_cluster_config; mod view_starting_cluster; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index 470e2aed..a9cc7133 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -2,6 +2,14 @@ use crate::model_preset::ModelPreset; #[derive(Debug, Clone)] pub enum Message { + AgentFailed(String), + AgentStopped, + Connect, + Disconnect, + JoinCluster, + RefreshAgentStatus, + SetClusterAddress(String), + SetSlotsCount(String), StartCluster, Cancel, SelectModel(ModelPreset), diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index 93acb49b..d841de70 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -2,6 +2,8 @@ use statum::machine; use statum::state; use statum::transition; +use crate::agent_running_data::AgentRunningData; +use crate::join_cluster_config_data::JoinClusterConfigData; use crate::network_interface_address::NetworkInterfaceAddress; use crate::running_cluster_data::RunningClusterData; use crate::start_cluster_config_data::StartClusterConfigData; @@ -9,7 +11,9 @@ use crate::starting_cluster_data::StartingClusterData; #[state] pub enum ScreenState { + AgentRunning(AgentRunningData), Home, + JoinClusterConfig(JoinClusterConfigData), StartClusterConfig(StartClusterConfigData), StartingCluster(StartingClusterData), RunningCluster(RunningClusterData), @@ -21,11 +25,37 @@ pub struct Screen {} #[transition] impl Screen { + pub fn join_cluster(self) -> Screen { + self.transition_with(JoinClusterConfigData::default()) + } + pub fn start_cluster(self) -> Screen { self.transition_with(StartClusterConfigData::default()) } } +#[transition] +impl Screen { + pub fn cancel(self) -> Screen { + self.transition() + } + + pub fn connect(self) -> Screen { + self.transition_with(AgentRunningData { status: None }) + } +} + +#[transition] +impl Screen { + pub fn disconnect(self) -> Screen { + self.transition() + } + + pub fn agent_failed(self) -> Screen { + self.transition() + } +} + #[transition] impl Screen { pub fn cancel(self) -> Screen { @@ -68,6 +98,10 @@ impl Screen { #[transition] impl Screen { + pub fn dismiss(self) -> Screen { + self.transition() + } + pub fn stop(self) -> Screen { self.transition() } diff --git a/paddler_second_brain_gui/src/screen_current.rs b/paddler_second_brain_gui/src/screen_current.rs index e20dca20..2919ec31 100644 --- a/paddler_second_brain_gui/src/screen_current.rs +++ b/paddler_second_brain_gui/src/screen_current.rs @@ -1,4 +1,6 @@ +use crate::screen::AgentRunning; use crate::screen::Home; +use crate::screen::JoinClusterConfig; use crate::screen::RunningCluster; use crate::screen::Screen; use crate::screen::StartClusterConfig; @@ -6,7 +8,9 @@ use crate::screen::StartingCluster; use crate::screen::StoppingCluster; pub enum CurrentScreen { + AgentRunning(Screen), Home(Screen), + JoinClusterConfig(Screen), StartClusterConfig(Screen), StartingCluster(Screen), RunningCluster(Screen), diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 8b5f4148..c001b3aa 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -8,6 +8,7 @@ use iced::Task; use iced::time; use iced::widget::column; use iced::widget::text; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -15,8 +16,11 @@ use crate::detect_network_interfaces::detect_network_interfaces; use crate::message::Message; use crate::network_interface_address::NetworkInterfaceAddress; use crate::screen_current::CurrentScreen; +use crate::start_agent::start_agent; use crate::start_balancer::start_balancer; +use crate::view_agent_running::view_agent_running; use crate::view_home::view_home; +use crate::view_join_cluster_config::view_join_cluster_config; use crate::view_running_cluster::view_running_cluster; use crate::view_start_cluster_config::view_start_cluster_config; use crate::view_starting_cluster::view_starting_cluster; @@ -24,6 +28,8 @@ use crate::view_stopping_cluster::view_stopping_cluster; pub struct SecondBrain { agent_count_rx: Option>, + agent_shutdown_tx: Option>, + agent_status_rx: Option>, network_interfaces_rx: Option>>, screen: CurrentScreen, shutdown_tx: Option>, @@ -36,6 +42,12 @@ impl Drop for SecondBrain { { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } + + if let Some(agent_shutdown_tx) = self.agent_shutdown_tx.take() + && let Err(unsent_signal) = agent_shutdown_tx.send(()) + { + log::error!("Failed to send agent shutdown signal: {unsent_signal:?}"); + } } } @@ -43,6 +55,8 @@ impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { agent_count_rx: None, + agent_shutdown_tx: None, + agent_status_rx: None, network_interfaces_rx: None, screen: CurrentScreen::default(), shutdown_tx: None, @@ -55,11 +69,71 @@ impl SecondBrain { let screen = mem::take(&mut self.screen); match (screen, message) { + (CurrentScreen::Home(home), Message::JoinCluster) => { + self.screen = CurrentScreen::JoinClusterConfig(home.join_cluster()); + + Task::none() + } (CurrentScreen::Home(home), Message::StartCluster) => { self.screen = CurrentScreen::StartClusterConfig(home.start_cluster()); Task::none() } + (CurrentScreen::JoinClusterConfig(config), Message::Cancel) => { + self.screen = CurrentScreen::Home(config.cancel()); + + Task::none() + } + (CurrentScreen::JoinClusterConfig(mut config), Message::SetClusterAddress(address)) => { + config.state_data.cluster_address = address; + config.state_data.error = None; + self.screen = CurrentScreen::JoinClusterConfig(config); + + Task::none() + } + (CurrentScreen::JoinClusterConfig(mut config), Message::SetSlotsCount(slots)) => { + config.state_data.slots_count = slots; + config.state_data.error = None; + self.screen = CurrentScreen::JoinClusterConfig(config); + + Task::none() + } + (CurrentScreen::JoinClusterConfig(config), Message::Connect) => { + let slots = match config.state_data.slots_count.parse::() { + Ok(slots) if slots > 0 => slots, + _ => { + let mut config = config; + config.state_data.error = + Some("Enter a valid number of slots.".to_string()); + self.screen = CurrentScreen::JoinClusterConfig(config); + + return Task::none(); + } + }; + + let management_address = config.state_data.cluster_address.clone(); + + let (agent_shutdown_tx, agent_shutdown_rx) = oneshot::channel::<()>(); + let (agent_status_tx, agent_status_rx) = + mpsc::unbounded_channel::(); + + self.agent_shutdown_tx = Some(agent_shutdown_tx); + self.agent_status_rx = Some(agent_status_rx); + self.screen = CurrentScreen::AgentRunning(config.connect()); + + Task::perform( + start_agent( + management_address, + slots, + agent_status_tx, + agent_shutdown_rx, + ), + |result: Result<(), anyhow::Error>| match result { + Ok(()) => Message::AgentStopped, + Err(error) => Message::AgentFailed(error.to_string()), + }, + ) + } (CurrentScreen::StartClusterConfig(config), Message::Cancel) => { self.screen = CurrentScreen::Home(config.cancel()); @@ -148,6 +222,11 @@ impl SecondBrain { Task::none() } + (CurrentScreen::RunningCluster(running), Message::Cancel) => { + self.screen = CurrentScreen::Home(running.dismiss()); + + Task::none() + } (CurrentScreen::RunningCluster(mut running), Message::RefreshAgentCount) => { if let Some(agent_count_rx) = &mut self.agent_count_rx { let mut latest_count = None; @@ -201,6 +280,49 @@ impl SecondBrain { Task::none() } + (CurrentScreen::AgentRunning(mut running), Message::RefreshAgentStatus) => { + if let Some(agent_status_rx) = &mut self.agent_status_rx { + let mut latest_status = None; + + while let Ok(status) = agent_status_rx.try_recv() { + latest_status = Some(status); + } + + if let Some(status) = latest_status { + running.state_data.status = Some(status); + } + } + self.screen = CurrentScreen::AgentRunning(running); + + Task::none() + } + (CurrentScreen::AgentRunning(running), Message::Disconnect) => { + if let Some(agent_shutdown_tx) = self.agent_shutdown_tx.take() + && let Err(unsent_signal) = agent_shutdown_tx.send(()) + { + log::error!("Failed to send agent shutdown signal: {unsent_signal:?}"); + } + self.agent_status_rx = None; + self.screen = CurrentScreen::Home(running.disconnect()); + + Task::none() + } + (CurrentScreen::AgentRunning(running), Message::AgentStopped) => { + log::info!("Agent stopped"); + self.agent_shutdown_tx = None; + self.agent_status_rx = None; + self.screen = CurrentScreen::Home(running.disconnect()); + + Task::none() + } + (CurrentScreen::AgentRunning(running), Message::AgentFailed(error)) => { + log::error!("Agent failed: {error}"); + self.agent_shutdown_tx = None; + self.agent_status_rx = None; + self.screen = CurrentScreen::Home(running.agent_failed()); + + Task::none() + } (CurrentScreen::StoppingCluster(stopping), Message::ClusterStopped) => { self.screen = CurrentScreen::Home(stopping.cluster_stopped()); @@ -223,6 +345,9 @@ impl SecondBrain { pub fn subscription(&self) -> Subscription { match &self.screen { + CurrentScreen::AgentRunning(_) => { + time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentStatus) + } CurrentScreen::RunningCluster(_) => Subscription::batch([ time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentCount), time::every(Duration::from_secs(1)).map(|_| Message::RefreshNetworkInterfaces), @@ -233,7 +358,11 @@ impl SecondBrain { pub fn view(&self) -> Element<'_, Message> { let screen_content = match &self.screen { + CurrentScreen::AgentRunning(screen) => view_agent_running(&screen.state_data), CurrentScreen::Home(_) => view_home(), + CurrentScreen::JoinClusterConfig(screen) => { + view_join_cluster_config(&screen.state_data) + } CurrentScreen::StartClusterConfig(screen) => { view_start_cluster_config(&screen.state_data) } diff --git a/paddler_second_brain_gui/src/start_agent.rs b/paddler_second_brain_gui/src/start_agent.rs new file mode 100644 index 00000000..a8af9433 --- /dev/null +++ b/paddler_second_brain_gui/src/start_agent.rs @@ -0,0 +1,31 @@ +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +use crate::start_agent_services::start_agent_services; + +pub async fn start_agent( + management_address: String, + slots: i32, + agent_status_tx: mpsc::UnboundedSender, + shutdown_rx: oneshot::Receiver<()>, +) -> anyhow::Result<()> { + let (result_tx, result_rx) = oneshot::channel(); + + std::thread::spawn(move || { + let system = actix_web::rt::System::new(); + let result = system.block_on(start_agent_services( + management_address, + slots, + agent_status_tx, + shutdown_rx, + )); + if let Err(unsent_result) = result_tx.send(result) { + log::error!("Failed to send agent result: {unsent_result:?}"); + } + }); + + result_rx + .await + .map_err(|error| anyhow::anyhow!("Agent thread terminated: {error}"))? +} diff --git a/paddler_second_brain_gui/src/start_agent_services.rs b/paddler_second_brain_gui/src/start_agent_services.rs new file mode 100644 index 00000000..07a18ee3 --- /dev/null +++ b/paddler_second_brain_gui/src/start_agent_services.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use nanoid::nanoid; +use paddler::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; +use paddler::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; +use paddler::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; +use paddler::agent::llamacpp_arbiter_service::LlamaCppArbiterService; +use paddler::agent::management_socket_client_service::ManagementSocketClientService; +use paddler::agent::model_metadata_holder::ModelMetadataHolder; +use paddler::agent::reconciliation_service::ReconciliationService; +use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; +use paddler::agent_desired_state::AgentDesiredState; +use paddler::service_manager::ServiceManager; +use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +use crate::agent_status_monitor_service::AgentStatusMonitorService; + +pub async fn start_agent_services( + management_address: String, + slots: i32, + agent_status_tx: mpsc::UnboundedSender, + shutdown_rx: oneshot::Receiver<()>, +) -> anyhow::Result<()> { + let (agent_desired_state_tx, agent_desired_state_rx) = + mpsc::unbounded_channel::(); + let ( + continue_from_conversation_history_request_tx, + continue_from_conversation_history_request_rx, + ) = mpsc::unbounded_channel::(); + let (continue_from_raw_prompt_request_tx, continue_from_raw_prompt_request_rx) = + mpsc::unbounded_channel::(); + let (generate_embedding_batch_request_tx, generate_embedding_batch_request_rx) = + mpsc::unbounded_channel::(); + + let agent_applicable_state_holder = Arc::new(AgentApplicableStateHolder::default()); + let model_metadata_holder = Arc::new(ModelMetadataHolder::default()); + let mut service_manager = ServiceManager::default(); + let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(slots)); + + service_manager.add_service(AgentStatusMonitorService { + agent_status_tx, + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + }); + + service_manager.add_service(LlamaCppArbiterService { + agent_applicable_state: None, + agent_applicable_state_holder: agent_applicable_state_holder.clone(), + agent_name: None, + continue_from_conversation_history_request_rx, + continue_from_raw_prompt_request_rx, + desired_slots_total: slots, + generate_embedding_batch_request_rx, + llamacpp_arbiter_handle: None, + model_metadata_holder: model_metadata_holder.clone(), + slot_aggregated_status_manager: slot_aggregated_status_manager.clone(), + }); + + service_manager.add_service(ManagementSocketClientService { + agent_applicable_state_holder: agent_applicable_state_holder.clone(), + agent_desired_state_tx, + continue_from_conversation_history_request_tx, + continue_from_raw_prompt_request_tx, + generate_embedding_batch_request_tx, + model_metadata_holder, + name: None, + receive_stream_stopper_collection: Default::default(), + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + socket_url: format!( + "ws://{}/api/v1/agent_socket/{}", + management_address, + nanoid!() + ), + }); + + service_manager.add_service(ReconciliationService { + agent_applicable_state_holder, + agent_desired_state: None, + agent_desired_state_rx, + is_converted_to_applicable_state: false, + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + }); + + service_manager.run_forever(shutdown_rx).await +} diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/view_agent_running.rs new file mode 100644 index 00000000..08641f35 --- /dev/null +++ b/paddler_second_brain_gui/src/view_agent_running.rs @@ -0,0 +1,51 @@ +use iced::Element; +use iced::widget::button; +use iced::widget::column; +use iced::widget::progress_bar; +use iced::widget::text; + +use crate::agent_running_data::AgentRunningData; +use crate::message::Message; + +pub fn view_agent_running<'content>( + data: &'content AgentRunningData, +) -> Element<'content, Message> { + let mut content = column![button("Disconnect").on_press(Message::Disconnect),].spacing(10); + + match &data.status { + None => { + content = content.push(text("Connecting to cluster...")); + } + Some(status) => { + if status.download_total > 0 && status.download_current < status.download_total { + let percentage = + (status.download_current as f32 / status.download_total as f32) * 100.0; + let download_label = match &status.download_filename { + Some(filename) => format!("Downloading {filename} ({percentage:.0}%)"), + None => format!("Downloading model ({percentage:.0}%)"), + }; + + content = content.push(text(download_label)); + content = content.push(progress_bar( + 0.0..=status.download_total as f32, + status.download_current as f32, + )); + } else if status.model_path.is_some() { + let status_label = format!( + "Ready ({}/{} slots busy)", + status.slots_processing, status.slots_total, + ); + + content = content.push(text(status_label)); + } else { + content = content.push(text("Waiting for model...")); + } + + if !status.issues.is_empty() { + content = content.push(text(format!("{} issues", status.issues.len()))); + } + } + } + + content.into() +} diff --git a/paddler_second_brain_gui/src/view_home.rs b/paddler_second_brain_gui/src/view_home.rs index 455fe7b0..2ba6e49f 100644 --- a/paddler_second_brain_gui/src/view_home.rs +++ b/paddler_second_brain_gui/src/view_home.rs @@ -7,7 +7,7 @@ use crate::message::Message; pub fn view_home() -> Element<'static, Message> { column![ button("Start a cluster").on_press(Message::StartCluster), - button("Join a cluster"), + button("Join a cluster").on_press(Message::JoinCluster), ] .spacing(10) .into() diff --git a/paddler_second_brain_gui/src/view_join_cluster_config.rs b/paddler_second_brain_gui/src/view_join_cluster_config.rs new file mode 100644 index 00000000..70756b86 --- /dev/null +++ b/paddler_second_brain_gui/src/view_join_cluster_config.rs @@ -0,0 +1,43 @@ +use iced::Element; +use iced::widget::button; +use iced::widget::column; +use iced::widget::text; +use iced::widget::text_input; + +use crate::join_cluster_config_data::JoinClusterConfigData; +use crate::message::Message; + +pub fn view_join_cluster_config<'content>( + data: &'content JoinClusterConfigData, +) -> Element<'content, Message> { + let is_valid_slots = data + .slots_count + .parse::() + .map(|slots| slots > 0) + .unwrap_or(false); + + let connect_button = if !data.cluster_address.is_empty() && is_valid_slots { + button("Connect").on_press(Message::Connect) + } else { + button("Connect") + }; + + let mut content = column![ + button("Back").on_press(Message::Cancel), + text("Join a cluster"), + text_input( + "Cluster address (e.g. 192.168.1.5:8060)", + &data.cluster_address, + ) + .on_input(Message::SetClusterAddress), + text_input("Slots (e.g. 1)", &data.slots_count).on_input(Message::SetSlotsCount), + connect_button, + ] + .spacing(10); + + if let Some(error) = &data.error { + content = content.push(text(error.clone())); + } + + content.into() +} diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index cfa3de0c..03274cd2 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -15,7 +15,12 @@ pub fn view_running_cluster<'content>( count => format!("{count} agents connected"), }; - let mut content = column![text("Your cluster").size(20), text(agent_label)].spacing(10); + let mut content = column![ + button("Back").on_press(Message::Cancel), + text("Your cluster").size(20), + text(agent_label), + ] + .spacing(10); for interface in &data.network_interfaces { let address = format!("{}:{}", interface.ip_address, data.management_port); From e320abbdd41a3826f25d6c96eb2c12466bd29131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 04:34:13 +0100 Subject: [PATCH 10/46] cleanup, fix jarmuz scripts after paddler cli extraction --- jarmuz/worker-paddler.mjs | 8 +-- .../src/agent_monitor_service.rs | 4 +- .../src/agent_status_monitor_service.rs | 4 +- paddler_second_brain_gui/src/model_preset.rs | 8 +-- .../src/network_monitor_service.rs | 4 +- paddler_second_brain_gui/src/second_brain.rs | 49 +++++++------------ 6 files changed, 29 insertions(+), 48 deletions(-) diff --git a/jarmuz/worker-paddler.mjs b/jarmuz/worker-paddler.mjs index b958ad3e..e0b4c847 100644 --- a/jarmuz/worker-paddler.mjs +++ b/jarmuz/worker-paddler.mjs @@ -17,7 +17,7 @@ spawner(async function ({ buildId, command }) { const results = await Promise.all([ command(` - target/debug/paddler balancer + target/debug/paddler_cli balancer --inference-addr 127.0.0.1:8061 --inference-item-timeout 30000 --management-addr 127.0.0.1:8060 @@ -25,19 +25,19 @@ spawner(async function ({ buildId, command }) { --web-admin-panel-addr 127.0.0.1:8062 `), command(` - target/debug/paddler agent + target/debug/paddler_cli agent --management-addr 127.0.0.1:8060 --name agent-1 --slots 4 `), command(` - target/debug/paddler agent + target/debug/paddler_cli agent --management-addr 127.0.0.1:8060 --name agent-2 --slots 4 `), command(` - target/debug/paddler agent + target/debug/paddler_cli agent --management-addr 127.0.0.1:8060 --name agent-3 --slots 2 diff --git a/paddler_second_brain_gui/src/agent_monitor_service.rs b/paddler_second_brain_gui/src/agent_monitor_service.rs index 2b228aa5..185b4e74 100644 --- a/paddler_second_brain_gui/src/agent_monitor_service.rs +++ b/paddler_second_brain_gui/src/agent_monitor_service.rs @@ -29,8 +29,8 @@ impl Service for AgentMonitorService { .unwrap_or(true); if has_changed { - if self.agent_count_tx.send(count).is_err() { - log::warn!("Agent count receiver dropped"); + if let Err(send_error) = self.agent_count_tx.send(count) { + log::warn!("Agent count receiver dropped: {send_error}"); break; } diff --git a/paddler_second_brain_gui/src/agent_status_monitor_service.rs b/paddler_second_brain_gui/src/agent_status_monitor_service.rs index 4acebe25..1c7d9b56 100644 --- a/paddler_second_brain_gui/src/agent_status_monitor_service.rs +++ b/paddler_second_brain_gui/src/agent_status_monitor_service.rs @@ -33,8 +33,8 @@ impl Service for AgentStatusMonitorService { if has_changed { previous_version = Some(snapshot.version); - if self.agent_status_tx.send(snapshot).is_err() { - log::warn!("Agent status receiver dropped"); + if let Err(send_error) = self.agent_status_tx.send(snapshot) { + log::warn!("Agent status receiver dropped: {send_error}"); break; } diff --git a/paddler_second_brain_gui/src/model_preset.rs b/paddler_second_brain_gui/src/model_preset.rs index c4f1b282..f8548de2 100644 --- a/paddler_second_brain_gui/src/model_preset.rs +++ b/paddler_second_brain_gui/src/model_preset.rs @@ -5,7 +5,7 @@ use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::huggingface_model_reference::HuggingFaceModelReference; use paddler_types::inference_parameters::InferenceParameters; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct ModelPreset { pub display_name: String, pub model: HuggingFaceModelReference, @@ -52,9 +52,3 @@ impl fmt::Display for ModelPreset { write!(formatter, "{}", self.display_name) } } - -impl PartialEq for ModelPreset { - fn eq(&self, other: &Self) -> bool { - self.display_name == other.display_name - } -} diff --git a/paddler_second_brain_gui/src/network_monitor_service.rs b/paddler_second_brain_gui/src/network_monitor_service.rs index 85878fd0..624382da 100644 --- a/paddler_second_brain_gui/src/network_monitor_service.rs +++ b/paddler_second_brain_gui/src/network_monitor_service.rs @@ -34,8 +34,8 @@ impl Service for NetworkMonitorService { .unwrap_or(true); if has_changed { - if self.network_interfaces_tx.send(interfaces.clone()).is_err() { - log::warn!("Network interfaces receiver dropped"); + if let Err(send_error) = self.network_interfaces_tx.send(interfaces.clone()) { + log::warn!("Network interfaces receiver dropped: {send_error}"); break; } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index c001b3aa..2d1e1632 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -26,6 +26,16 @@ use crate::view_start_cluster_config::view_start_cluster_config; use crate::view_starting_cluster::view_starting_cluster; use crate::view_stopping_cluster::view_stopping_cluster; +fn drain_latest(receiver: &mut mpsc::UnboundedReceiver) -> Option { + let mut latest = None; + + while let Ok(value) = receiver.try_recv() { + latest = Some(value); + } + + latest +} + pub struct SecondBrain { agent_count_rx: Option>, agent_shutdown_tx: Option>, @@ -228,32 +238,17 @@ impl SecondBrain { Task::none() } (CurrentScreen::RunningCluster(mut running), Message::RefreshAgentCount) => { - if let Some(agent_count_rx) = &mut self.agent_count_rx { - let mut latest_count = None; - - while let Ok(count) = agent_count_rx.try_recv() { - latest_count = Some(count); - } - - if let Some(count) = latest_count { - running.state_data.agent_count = count; - } + if let Some(count) = self.agent_count_rx.as_mut().and_then(drain_latest) { + running.state_data.agent_count = count; } self.screen = CurrentScreen::RunningCluster(running); Task::none() } (CurrentScreen::RunningCluster(mut running), Message::RefreshNetworkInterfaces) => { - if let Some(network_interfaces_rx) = &mut self.network_interfaces_rx { - let mut latest_interfaces = None; - - while let Ok(interfaces) = network_interfaces_rx.try_recv() { - latest_interfaces = Some(interfaces); - } - - if let Some(interfaces) = latest_interfaces { - running.state_data.network_interfaces = interfaces; - } + if let Some(interfaces) = self.network_interfaces_rx.as_mut().and_then(drain_latest) + { + running.state_data.network_interfaces = interfaces; } self.screen = CurrentScreen::RunningCluster(running); @@ -281,16 +276,8 @@ impl SecondBrain { Task::none() } (CurrentScreen::AgentRunning(mut running), Message::RefreshAgentStatus) => { - if let Some(agent_status_rx) = &mut self.agent_status_rx { - let mut latest_status = None; - - while let Ok(status) = agent_status_rx.try_recv() { - latest_status = Some(status); - } - - if let Some(status) = latest_status { - running.state_data.status = Some(status); - } + if let Some(status) = self.agent_status_rx.as_mut().and_then(drain_latest) { + running.state_data.status = Some(status); } self.screen = CurrentScreen::AgentRunning(running); @@ -356,7 +343,7 @@ impl SecondBrain { } } - pub fn view(&self) -> Element<'_, Message> { + pub fn view<'view>(&'view self) -> Element<'view, Message> { let screen_content = match &self.screen { CurrentScreen::AgentRunning(screen) => view_agent_running(&screen.state_data), CurrentScreen::Home(_) => view_home(), From 1721149b73f15784b5ccd43a9ac8c1028365461d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 14:47:11 +0100 Subject: [PATCH 11/46] remove the intermediary starting... screens --- paddler_second_brain_gui/src/main.rs | 3 -- .../src/running_cluster_data.rs | 1 + paddler_second_brain_gui/src/screen.rs | 39 ++++---------- .../src/screen_current.rs | 4 -- paddler_second_brain_gui/src/second_brain.rs | 52 +++++++++---------- .../src/start_cluster_config_data.rs | 1 + .../src/starting_cluster_data.rs | 8 --- .../src/view_agent_running.rs | 6 ++- .../src/view_running_cluster.rs | 6 ++- .../src/view_start_cluster_config.rs | 4 +- .../src/view_starting_cluster.rs | 8 --- .../src/view_stopping_cluster.rs | 8 --- 12 files changed, 49 insertions(+), 91 deletions(-) delete mode 100644 paddler_second_brain_gui/src/starting_cluster_data.rs delete mode 100644 paddler_second_brain_gui/src/view_starting_cluster.rs delete mode 100644 paddler_second_brain_gui/src/view_stopping_cluster.rs diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index a75947ff..a6dd1716 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -16,14 +16,11 @@ mod start_agent_services; mod start_balancer; mod start_balancer_services; mod start_cluster_config_data; -mod starting_cluster_data; mod view_agent_running; mod view_home; mod view_join_cluster_config; mod view_running_cluster; mod view_start_cluster_config; -mod view_starting_cluster; -mod view_stopping_cluster; use second_brain::SecondBrain; diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs index bbcdf1b9..80a4e91e 100644 --- a/paddler_second_brain_gui/src/running_cluster_data.rs +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -6,4 +6,5 @@ pub struct RunningClusterData { pub management_port: u16, pub selected_model_name: String, pub run_agent_locally: bool, + pub stopping: bool, } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index d841de70..ba4f1a58 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -7,7 +7,6 @@ use crate::join_cluster_config_data::JoinClusterConfigData; use crate::network_interface_address::NetworkInterfaceAddress; use crate::running_cluster_data::RunningClusterData; use crate::start_cluster_config_data::StartClusterConfigData; -use crate::starting_cluster_data::StartingClusterData; #[state] pub enum ScreenState { @@ -15,9 +14,7 @@ pub enum ScreenState { Home, JoinClusterConfig(JoinClusterConfigData), StartClusterConfig(StartClusterConfigData), - StartingCluster(StartingClusterData), RunningCluster(RunningClusterData), - StoppingCluster, } #[machine] @@ -47,6 +44,10 @@ impl Screen { #[transition] impl Screen { + pub fn back(self) -> Screen { + self.transition() + } + pub fn disconnect(self) -> Screen { self.transition() } @@ -62,12 +63,13 @@ impl Screen { self.transition() } - pub fn confirm( + pub fn cluster_started( self, network_interfaces: Vec, management_port: u16, - ) -> Screen { - self.transition_map(|config_data| StartingClusterData { + ) -> Screen { + self.transition_map(|config_data| RunningClusterData { + agent_count: 0, network_interfaces, management_port, selected_model_name: config_data @@ -75,19 +77,7 @@ impl Screen { .map(|preset| preset.display_name) .unwrap_or_default(), run_agent_locally: config_data.run_agent_locally, - }) - } -} - -#[transition] -impl Screen { - pub fn cluster_started(self) -> Screen { - self.transition_map(|starting_data| RunningClusterData { - agent_count: 0, - network_interfaces: starting_data.network_interfaces, - management_port: starting_data.management_port, - selected_model_name: starting_data.selected_model_name, - run_agent_locally: starting_data.run_agent_locally, + stopping: false, }) } @@ -102,17 +92,6 @@ impl Screen { self.transition() } - pub fn stop(self) -> Screen { - self.transition() - } - - pub fn cluster_failed(self) -> Screen { - self.transition() - } -} - -#[transition] -impl Screen { pub fn cluster_stopped(self) -> Screen { self.transition() } diff --git a/paddler_second_brain_gui/src/screen_current.rs b/paddler_second_brain_gui/src/screen_current.rs index 2919ec31..b8fe5384 100644 --- a/paddler_second_brain_gui/src/screen_current.rs +++ b/paddler_second_brain_gui/src/screen_current.rs @@ -4,17 +4,13 @@ use crate::screen::JoinClusterConfig; use crate::screen::RunningCluster; use crate::screen::Screen; use crate::screen::StartClusterConfig; -use crate::screen::StartingCluster; -use crate::screen::StoppingCluster; pub enum CurrentScreen { AgentRunning(Screen), Home(Screen), JoinClusterConfig(Screen), StartClusterConfig(Screen), - StartingCluster(Screen), RunningCluster(Screen), - StoppingCluster(Screen), } impl Default for CurrentScreen { diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 2d1e1632..6f42bafa 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -23,8 +23,6 @@ use crate::view_home::view_home; use crate::view_join_cluster_config::view_join_cluster_config; use crate::view_running_cluster::view_running_cluster; use crate::view_start_cluster_config::view_start_cluster_config; -use crate::view_starting_cluster::view_starting_cluster; -use crate::view_stopping_cluster::view_stopping_cluster; fn drain_latest(receiver: &mut mpsc::UnboundedReceiver) -> Option { let mut latest = None; @@ -166,14 +164,12 @@ impl SecondBrain { Task::none() } - (CurrentScreen::StartClusterConfig(config), Message::Confirm) => { + (CurrentScreen::StartClusterConfig(mut config), Message::Confirm) => { let network_interfaces = detect_network_interfaces(); - let management_port = 8060; let bind_ip = match network_interfaces.first() { Some(interface) => interface.ip_address, None => { - let mut config = config; config.state_data.error = Some( "No local network found. Connect to internet to start a cluster." .to_string(), @@ -199,9 +195,8 @@ impl SecondBrain { self.agent_count_rx = Some(agent_count_rx); self.network_interfaces_rx = Some(network_interfaces_rx); self.shutdown_tx = Some(shutdown_tx); - self.screen = CurrentScreen::StartingCluster( - config.confirm(network_interfaces, management_port), - ); + config.state_data.starting = true; + self.screen = CurrentScreen::StartClusterConfig(config); Task::batch([ Task::perform( @@ -220,15 +215,20 @@ impl SecondBrain { Task::done(Message::ClusterStarted), ]) } - (CurrentScreen::StartingCluster(starting), Message::ClusterStarted) => { - self.screen = CurrentScreen::RunningCluster(starting.cluster_started()); + (CurrentScreen::StartClusterConfig(config), Message::ClusterStarted) => { + let network_interfaces = detect_network_interfaces(); + let management_port = 8060; + + self.screen = CurrentScreen::RunningCluster( + config.cluster_started(network_interfaces, management_port), + ); Task::none() } - (CurrentScreen::StartingCluster(starting), Message::ClusterFailed(error)) => { + (CurrentScreen::StartClusterConfig(config), Message::ClusterFailed(error)) => { log::error!("Cluster failed to start: {error}"); self.shutdown_tx = None; - self.screen = CurrentScreen::Home(starting.cluster_failed()); + self.screen = CurrentScreen::Home(config.cluster_failed()); Task::none() } @@ -254,7 +254,7 @@ impl SecondBrain { Task::none() } - (CurrentScreen::RunningCluster(running), Message::Stop) => { + (CurrentScreen::RunningCluster(mut running), Message::Stop) => { if let Some(shutdown_tx) = self.shutdown_tx.take() && let Err(unsent_signal) = shutdown_tx.send(()) { @@ -262,7 +262,13 @@ impl SecondBrain { } self.agent_count_rx = None; self.network_interfaces_rx = None; - self.screen = CurrentScreen::StoppingCluster(running.stop()); + running.state_data.stopping = true; + self.screen = CurrentScreen::RunningCluster(running); + + Task::none() + } + (CurrentScreen::RunningCluster(running), Message::ClusterStopped) => { + self.screen = CurrentScreen::Home(running.cluster_stopped()); Task::none() } @@ -275,6 +281,11 @@ impl SecondBrain { Task::none() } + (CurrentScreen::AgentRunning(running), Message::Cancel) => { + self.screen = CurrentScreen::Home(running.back()); + + Task::none() + } (CurrentScreen::AgentRunning(mut running), Message::RefreshAgentStatus) => { if let Some(status) = self.agent_status_rx.as_mut().and_then(drain_latest) { running.state_data.status = Some(status); @@ -310,17 +321,6 @@ impl SecondBrain { Task::none() } - (CurrentScreen::StoppingCluster(stopping), Message::ClusterStopped) => { - self.screen = CurrentScreen::Home(stopping.cluster_stopped()); - - Task::none() - } - (CurrentScreen::StoppingCluster(stopping), Message::ClusterFailed(error)) => { - log::error!("Cluster failed during shutdown: {error}"); - self.screen = CurrentScreen::Home(stopping.cluster_failed()); - - Task::none() - } (screen, message) => { log::warn!("Unhandled message {message:?} for current screen"); self.screen = screen; @@ -353,9 +353,7 @@ impl SecondBrain { CurrentScreen::StartClusterConfig(screen) => { view_start_cluster_config(&screen.state_data) } - CurrentScreen::StartingCluster(_) => view_starting_cluster(), CurrentScreen::RunningCluster(screen) => view_running_cluster(&screen.state_data), - CurrentScreen::StoppingCluster(_) => view_stopping_cluster(), }; column![text("Paddler second brain").size(24), screen_content] diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index 26226653..d021fa45 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -5,4 +5,5 @@ pub struct StartClusterConfigData { pub error: Option, pub selected_model: Option, pub run_agent_locally: bool, + pub starting: bool, } diff --git a/paddler_second_brain_gui/src/starting_cluster_data.rs b/paddler_second_brain_gui/src/starting_cluster_data.rs deleted file mode 100644 index f50193d7..00000000 --- a/paddler_second_brain_gui/src/starting_cluster_data.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::network_interface_address::NetworkInterfaceAddress; - -pub struct StartingClusterData { - pub network_interfaces: Vec, - pub management_port: u16, - pub selected_model_name: String, - pub run_agent_locally: bool, -} diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/view_agent_running.rs index 08641f35..e1ddb27e 100644 --- a/paddler_second_brain_gui/src/view_agent_running.rs +++ b/paddler_second_brain_gui/src/view_agent_running.rs @@ -10,7 +10,11 @@ use crate::message::Message; pub fn view_agent_running<'content>( data: &'content AgentRunningData, ) -> Element<'content, Message> { - let mut content = column![button("Disconnect").on_press(Message::Disconnect),].spacing(10); + let mut content = column![ + button("Back").on_press(Message::Cancel), + button("Disconnect").on_press(Message::Disconnect), + ] + .spacing(10); match &data.status { None => { diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index 03274cd2..d0c1b57c 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -33,7 +33,11 @@ pub fn view_running_cluster<'content>( content = content.push(text("No network interfaces detected")); } - content = content.push(button("Stop cluster").on_press(Message::Stop)); + content = content.push(if data.stopping { + button("Stopping...") + } else { + button("Stop cluster").on_press(Message::Stop) + }); content.into() } diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index 5066ed17..a7286b11 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -14,7 +14,9 @@ pub fn view_start_cluster_config<'content>( ) -> Element<'content, Message> { let available_models = ModelPreset::available_presets(); - let confirm_button = if data.selected_model.is_some() { + let confirm_button = if data.starting { + button("Starting...") + } else if data.selected_model.is_some() { button("Start a cluster").on_press(Message::Confirm) } else { button("Start a cluster") diff --git a/paddler_second_brain_gui/src/view_starting_cluster.rs b/paddler_second_brain_gui/src/view_starting_cluster.rs deleted file mode 100644 index ce9019a4..00000000 --- a/paddler_second_brain_gui/src/view_starting_cluster.rs +++ /dev/null @@ -1,8 +0,0 @@ -use iced::Element; -use iced::widget::text; - -use crate::message::Message; - -pub fn view_starting_cluster() -> Element<'static, Message> { - text("Starting cluster...").into() -} diff --git a/paddler_second_brain_gui/src/view_stopping_cluster.rs b/paddler_second_brain_gui/src/view_stopping_cluster.rs deleted file mode 100644 index b68656a8..00000000 --- a/paddler_second_brain_gui/src/view_stopping_cluster.rs +++ /dev/null @@ -1,8 +0,0 @@ -use iced::Element; -use iced::widget::text; - -use crate::message::Message; - -pub fn view_stopping_cluster() -> Element<'static, Message> { - text("Stopping cluster...").into() -} From a3dc601cea084ce419ec579e051c9e552b911e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 18:46:19 +0100 Subject: [PATCH 12/46] remove live IP check, remove add agent toggle, allow the clutser manager to input balancer address --- paddler_second_brain_gui/src/main.rs | 1 - paddler_second_brain_gui/src/message.rs | 5 +- .../src/network_monitor_service.rs | 54 ------------ .../src/running_cluster_data.rs | 7 +- paddler_second_brain_gui/src/screen.rs | 37 ++++---- paddler_second_brain_gui/src/second_brain.rs | 86 +++++++------------ .../src/start_balancer.rs | 5 +- .../src/start_balancer_services.rs | 12 +-- .../src/start_cluster_config_data.rs | 4 +- .../src/view_agent_running.rs | 6 +- .../src/view_running_cluster.rs | 17 ++-- .../src/view_start_cluster_config.rs | 11 +-- 12 files changed, 71 insertions(+), 174 deletions(-) delete mode 100644 paddler_second_brain_gui/src/network_monitor_service.rs diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index a6dd1716..9a002c08 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -6,7 +6,6 @@ mod join_cluster_config_data; mod message; mod model_preset; mod network_interface_address; -mod network_monitor_service; mod running_cluster_data; mod screen; mod screen_current; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index a9cc7133..bafdf990 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -12,13 +12,14 @@ pub enum Message { SetSlotsCount(String), StartCluster, Cancel, + CopyToClipboard(String), + SetBindAddress(String), + SetBindPort(String), SelectModel(ModelPreset), - ToggleRunAgentLocally(bool), Confirm, ClusterStarted, ClusterFailed(String), Stop, ClusterStopped, RefreshAgentCount, - RefreshNetworkInterfaces, } diff --git a/paddler_second_brain_gui/src/network_monitor_service.rs b/paddler_second_brain_gui/src/network_monitor_service.rs deleted file mode 100644 index 624382da..00000000 --- a/paddler_second_brain_gui/src/network_monitor_service.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::time::Duration; - -use anyhow::Result; -use async_trait::async_trait; -use paddler::service::Service; -use tokio::sync::broadcast; -use tokio::sync::mpsc; - -use crate::detect_network_interfaces::detect_network_interfaces; -use crate::network_interface_address::NetworkInterfaceAddress; - -pub struct NetworkMonitorService { - pub network_interfaces_tx: mpsc::UnboundedSender>, -} - -#[async_trait] -impl Service for NetworkMonitorService { - fn name(&self) -> &'static str { - "network_monitor" - } - - async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { - let mut interval = tokio::time::interval(Duration::from_secs(1)); - let mut previous_interfaces: Option> = None; - - loop { - tokio::select! { - _ = interval.tick() => { - let interfaces = detect_network_interfaces(); - - let has_changed = previous_interfaces - .as_ref() - .map(|previous| *previous != interfaces) - .unwrap_or(true); - - if has_changed { - if let Err(send_error) = self.network_interfaces_tx.send(interfaces.clone()) { - log::warn!("Network interfaces receiver dropped: {send_error}"); - - break; - } - - previous_interfaces = Some(interfaces); - } - } - _ = shutdown_rx.recv() => { - break; - } - } - } - - Ok(()) - } -} diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs index 80a4e91e..0f9aef7c 100644 --- a/paddler_second_brain_gui/src/running_cluster_data.rs +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -1,10 +1,5 @@ -use crate::network_interface_address::NetworkInterfaceAddress; - pub struct RunningClusterData { pub agent_count: usize, - pub network_interfaces: Vec, - pub management_port: u16, - pub selected_model_name: String, - pub run_agent_locally: bool, + pub cluster_address: String, pub stopping: bool, } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index ba4f1a58..d3a5b6d2 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -3,8 +3,8 @@ use statum::state; use statum::transition; use crate::agent_running_data::AgentRunningData; +use crate::detect_network_interfaces::detect_network_interfaces; use crate::join_cluster_config_data::JoinClusterConfigData; -use crate::network_interface_address::NetworkInterfaceAddress; use crate::running_cluster_data::RunningClusterData; use crate::start_cluster_config_data::StartClusterConfigData; @@ -27,7 +27,18 @@ impl Screen { } pub fn start_cluster(self) -> Screen { - self.transition_with(StartClusterConfigData::default()) + let suggested_address = detect_network_interfaces() + .first() + .map(|interface| interface.ip_address.to_string()) + .unwrap_or_default(); + + self.transition_with(StartClusterConfigData { + bind_address: suggested_address, + bind_port: "8060".to_string(), + error: None, + selected_model: None, + starting: false, + }) } } @@ -44,10 +55,6 @@ impl Screen { #[transition] impl Screen { - pub fn back(self) -> Screen { - self.transition() - } - pub fn disconnect(self) -> Screen { self.transition() } @@ -63,20 +70,10 @@ impl Screen { self.transition() } - pub fn cluster_started( - self, - network_interfaces: Vec, - management_port: u16, - ) -> Screen { + pub fn cluster_started(self) -> Screen { self.transition_map(|config_data| RunningClusterData { agent_count: 0, - network_interfaces, - management_port, - selected_model_name: config_data - .selected_model - .map(|preset| preset.display_name) - .unwrap_or_default(), - run_agent_locally: config_data.run_agent_locally, + cluster_address: format!("{}:{}", config_data.bind_address, config_data.bind_port), stopping: false, }) } @@ -88,10 +85,6 @@ impl Screen { #[transition] impl Screen { - pub fn dismiss(self) -> Screen { - self.transition() - } - pub fn cluster_stopped(self) -> Screen { self.transition() } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 6f42bafa..201dbb19 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -12,9 +12,7 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot use tokio::sync::mpsc; use tokio::sync::oneshot; -use crate::detect_network_interfaces::detect_network_interfaces; use crate::message::Message; -use crate::network_interface_address::NetworkInterfaceAddress; use crate::screen_current::CurrentScreen; use crate::start_agent::start_agent; use crate::start_balancer::start_balancer; @@ -38,7 +36,6 @@ pub struct SecondBrain { agent_count_rx: Option>, agent_shutdown_tx: Option>, agent_status_rx: Option>, - network_interfaces_rx: Option>>, screen: CurrentScreen, shutdown_tx: Option>, } @@ -65,7 +62,6 @@ impl SecondBrain { agent_count_rx: None, agent_shutdown_tx: None, agent_status_rx: None, - network_interfaces_rx: None, screen: CurrentScreen::default(), shutdown_tx: None, }; @@ -154,26 +150,35 @@ impl SecondBrain { Task::none() } - ( - CurrentScreen::StartClusterConfig(mut config), - Message::ToggleRunAgentLocally(enabled), - ) => { - config.state_data.run_agent_locally = enabled; + (CurrentScreen::StartClusterConfig(mut config), Message::SetBindAddress(address)) => { + config.state_data.bind_address = address; + config.state_data.error = None; + self.screen = CurrentScreen::StartClusterConfig(config); + + Task::none() + } + (CurrentScreen::StartClusterConfig(mut config), Message::SetBindPort(port)) => { + config.state_data.bind_port = port; config.state_data.error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() } (CurrentScreen::StartClusterConfig(mut config), Message::Confirm) => { - let network_interfaces = detect_network_interfaces(); - - let bind_ip = match network_interfaces.first() { - Some(interface) => interface.ip_address, - None => { - config.state_data.error = Some( - "No local network found. Connect to internet to start a cluster." - .to_string(), - ); + let bind_ip = match config.state_data.bind_address.parse::() { + Ok(ip) => ip, + Err(std::net::AddrParseError { .. }) => { + config.state_data.error = Some("Enter a valid IP address.".to_string()); + self.screen = CurrentScreen::StartClusterConfig(config); + + return Task::none(); + } + }; + + let management_port = match config.state_data.bind_port.parse::() { + Ok(port) if port > 0 => port, + _ => { + config.state_data.error = Some("Enter a valid port number.".to_string()); self.screen = CurrentScreen::StartClusterConfig(config); return Task::none(); @@ -189,11 +194,8 @@ impl SecondBrain { let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (agent_count_tx, agent_count_rx) = mpsc::unbounded_channel::(); - let (network_interfaces_tx, network_interfaces_rx) = - mpsc::unbounded_channel::>(); self.agent_count_rx = Some(agent_count_rx); - self.network_interfaces_rx = Some(network_interfaces_rx); self.shutdown_tx = Some(shutdown_tx); config.state_data.starting = true; self.screen = CurrentScreen::StartClusterConfig(config); @@ -202,9 +204,9 @@ impl SecondBrain { Task::perform( start_balancer( bind_ip, + management_port, desired_state, agent_count_tx, - network_interfaces_tx, shutdown_rx, ), |result: Result<(), anyhow::Error>| match result { @@ -216,12 +218,7 @@ impl SecondBrain { ]) } (CurrentScreen::StartClusterConfig(config), Message::ClusterStarted) => { - let network_interfaces = detect_network_interfaces(); - let management_port = 8060; - - self.screen = CurrentScreen::RunningCluster( - config.cluster_started(network_interfaces, management_port), - ); + self.screen = CurrentScreen::RunningCluster(config.cluster_started()); Task::none() } @@ -232,11 +229,6 @@ impl SecondBrain { Task::none() } - (CurrentScreen::RunningCluster(running), Message::Cancel) => { - self.screen = CurrentScreen::Home(running.dismiss()); - - Task::none() - } (CurrentScreen::RunningCluster(mut running), Message::RefreshAgentCount) => { if let Some(count) = self.agent_count_rx.as_mut().and_then(drain_latest) { running.state_data.agent_count = count; @@ -245,15 +237,6 @@ impl SecondBrain { Task::none() } - (CurrentScreen::RunningCluster(mut running), Message::RefreshNetworkInterfaces) => { - if let Some(interfaces) = self.network_interfaces_rx.as_mut().and_then(drain_latest) - { - running.state_data.network_interfaces = interfaces; - } - self.screen = CurrentScreen::RunningCluster(running); - - Task::none() - } (CurrentScreen::RunningCluster(mut running), Message::Stop) => { if let Some(shutdown_tx) = self.shutdown_tx.take() && let Err(unsent_signal) = shutdown_tx.send(()) @@ -261,7 +244,6 @@ impl SecondBrain { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } self.agent_count_rx = None; - self.network_interfaces_rx = None; running.state_data.stopping = true; self.screen = CurrentScreen::RunningCluster(running); @@ -275,17 +257,11 @@ impl SecondBrain { (CurrentScreen::RunningCluster(running), Message::ClusterFailed(error)) => { log::error!("Cluster failed unexpectedly: {error}"); self.agent_count_rx = None; - self.network_interfaces_rx = None; self.shutdown_tx = None; self.screen = CurrentScreen::Home(running.cluster_failed()); Task::none() } - (CurrentScreen::AgentRunning(running), Message::Cancel) => { - self.screen = CurrentScreen::Home(running.back()); - - Task::none() - } (CurrentScreen::AgentRunning(mut running), Message::RefreshAgentStatus) => { if let Some(status) = self.agent_status_rx.as_mut().and_then(drain_latest) { running.state_data.status = Some(status); @@ -321,6 +297,11 @@ impl SecondBrain { Task::none() } + (screen, Message::CopyToClipboard(content)) => { + self.screen = screen; + + iced::clipboard::write::(content).discard() + } (screen, message) => { log::warn!("Unhandled message {message:?} for current screen"); self.screen = screen; @@ -335,10 +316,9 @@ impl SecondBrain { CurrentScreen::AgentRunning(_) => { time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentStatus) } - CurrentScreen::RunningCluster(_) => Subscription::batch([ - time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentCount), - time::every(Duration::from_secs(1)).map(|_| Message::RefreshNetworkInterfaces), - ]), + CurrentScreen::RunningCluster(_) => { + time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentCount) + } _ => Subscription::none(), } } diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs index 241c9a95..56117595 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -4,14 +4,13 @@ use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::mpsc; use tokio::sync::oneshot; -use crate::network_interface_address::NetworkInterfaceAddress; use crate::start_balancer_services::start_balancer_services; pub async fn start_balancer( bind_ip: IpAddr, + management_port: u16, initial_desired_state: BalancerDesiredState, agent_count_tx: mpsc::UnboundedSender, - network_interfaces_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { let (result_tx, result_rx) = oneshot::channel(); @@ -20,9 +19,9 @@ pub async fn start_balancer( let system = actix_web::rt::System::new(); let result = system.block_on(start_balancer_services( bind_ip, + management_port, initial_desired_state, agent_count_tx, - network_interfaces_tx, shutdown_rx, )); if let Err(unsent_result) = result_tx.send(result) { diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs index e4541170..3e2ec2fa 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -24,18 +24,16 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use crate::agent_monitor_service::AgentMonitorService; -use crate::network_interface_address::NetworkInterfaceAddress; -use crate::network_monitor_service::NetworkMonitorService; pub async fn start_balancer_services( bind_ip: IpAddr, + management_port: u16, initial_desired_state: BalancerDesiredState, agent_count_tx: mpsc::UnboundedSender, - network_interfaces_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { - let management_addr = SocketAddr::new(bind_ip, 8060); - let inference_addr = SocketAddr::new(bind_ip, 8061); + let management_addr = SocketAddr::new(bind_ip, management_port); + let inference_addr = SocketAddr::new(bind_ip, management_port + 1); let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); @@ -94,10 +92,6 @@ pub async fn start_balancer_services( agent_count_tx, }); - service_manager.add_service(NetworkMonitorService { - network_interfaces_tx, - }); - service_manager.add_service(ReconciliationService { agent_controller_pool: agent_controller_pool.clone(), balancer_applicable_state_holder, diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index d021fa45..58a5797d 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -1,9 +1,9 @@ use crate::model_preset::ModelPreset; -#[derive(Default)] pub struct StartClusterConfigData { + pub bind_address: String, + pub bind_port: String, pub error: Option, pub selected_model: Option, - pub run_agent_locally: bool, pub starting: bool, } diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/view_agent_running.rs index e1ddb27e..08641f35 100644 --- a/paddler_second_brain_gui/src/view_agent_running.rs +++ b/paddler_second_brain_gui/src/view_agent_running.rs @@ -10,11 +10,7 @@ use crate::message::Message; pub fn view_agent_running<'content>( data: &'content AgentRunningData, ) -> Element<'content, Message> { - let mut content = column![ - button("Back").on_press(Message::Cancel), - button("Disconnect").on_press(Message::Disconnect), - ] - .spacing(10); + let mut content = column![button("Disconnect").on_press(Message::Disconnect),].spacing(10); match &data.status { None => { diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index d0c1b57c..5fce2be0 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -16,23 +16,16 @@ pub fn view_running_cluster<'content>( }; let mut content = column![ - button("Back").on_press(Message::Cancel), text("Your cluster").size(20), text(agent_label), + row![ + text(data.cluster_address.clone()), + button("Copy").on_press(Message::CopyToClipboard(data.cluster_address.clone())), + ] + .spacing(10), ] .spacing(10); - for interface in &data.network_interfaces { - let address = format!("{}:{}", interface.ip_address, data.management_port); - - content = content - .push(row![text(interface.interface_name.to_string()), text(address),].spacing(10)); - } - - if data.network_interfaces.is_empty() { - content = content.push(text("No network interfaces detected")); - } - content = content.push(if data.stopping { button("Stopping...") } else { diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index a7286b11..39e27713 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -3,7 +3,7 @@ use iced::widget::button; use iced::widget::column; use iced::widget::pick_list; use iced::widget::text; -use iced::widget::toggler; +use iced::widget::text_input; use crate::message::Message; use crate::model_preset::ModelPreset; @@ -16,7 +16,7 @@ pub fn view_start_cluster_config<'content>( let confirm_button = if data.starting { button("Starting...") - } else if data.selected_model.is_some() { + } else if data.selected_model.is_some() && !data.bind_address.is_empty() { button("Start a cluster").on_press(Message::Confirm) } else { button("Start a cluster") @@ -24,15 +24,16 @@ pub fn view_start_cluster_config<'content>( let mut content = column![ button("Back").on_press(Message::Cancel), + text("Balancer address"), + text_input("IP address", &data.bind_address).on_input(Message::SetBindAddress), + text("Balancer port"), + text_input("Port", &data.bind_port).on_input(Message::SetBindPort), text("Select a model"), pick_list( available_models, data.selected_model.as_ref(), Message::SelectModel, ), - toggler(data.run_agent_locally) - .label("Run an agent on your computer") - .on_toggle(Message::ToggleRunAgentLocally), confirm_button, ] .spacing(10); From cc1df36d1ab8d8917b6c08cb74085814d8012eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 22:28:01 +0100 Subject: [PATCH 13/46] start using fonts from the file in the repo / remove CDN, introduce styling variables for the desktop app, style the view cluster config view --- paddler_second_brain_gui/src/font.rs | 10 ++ paddler_second_brain_gui/src/main.rs | 10 ++ paddler_second_brain_gui/src/second_brain.rs | 20 ++-- .../src/style_button_primary.rs | 21 ++++ .../src/style_field_container.rs | 18 ++++ .../src/style_field_pick_list.rs | 23 +++++ .../src/style_field_pick_list_menu.rs | 25 +++++ .../src/style_field_text_input.rs | 27 +++++ paddler_second_brain_gui/src/variables.rs | 24 +++++ .../src/view_start_cluster_config.rs | 93 +++++++++++++++--- resources/css/_fonts.css | 14 ++- resources/fonts/JetBrainsMono-Bold.ttf | Bin 0 -> 114828 bytes resources/fonts/JetBrainsMono-Regular.ttf | Bin 0 -> 114904 bytes 13 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 paddler_second_brain_gui/src/font.rs create mode 100644 paddler_second_brain_gui/src/style_button_primary.rs create mode 100644 paddler_second_brain_gui/src/style_field_container.rs create mode 100644 paddler_second_brain_gui/src/style_field_pick_list.rs create mode 100644 paddler_second_brain_gui/src/style_field_pick_list_menu.rs create mode 100644 paddler_second_brain_gui/src/style_field_text_input.rs create mode 100644 paddler_second_brain_gui/src/variables.rs create mode 100644 resources/fonts/JetBrainsMono-Bold.ttf create mode 100644 resources/fonts/JetBrainsMono-Regular.ttf diff --git a/paddler_second_brain_gui/src/font.rs b/paddler_second_brain_gui/src/font.rs new file mode 100644 index 00000000..b049f77a --- /dev/null +++ b/paddler_second_brain_gui/src/font.rs @@ -0,0 +1,10 @@ +use iced::Font; +use iced::font::Family; +use iced::font::Weight; + +pub const BOLD: Font = Font { + family: Family::Name("JetBrains Mono"), + weight: Weight::Bold, + stretch: iced::font::Stretch::Normal, + style: iced::font::Style::Normal, +}; diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 9a002c08..18666bcc 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -2,6 +2,7 @@ mod agent_monitor_service; mod agent_running_data; mod agent_status_monitor_service; mod detect_network_interfaces; +mod font; mod join_cluster_config_data; mod message; mod model_preset; @@ -15,6 +16,12 @@ mod start_agent_services; mod start_balancer; mod start_balancer_services; mod start_cluster_config_data; +mod style_button_primary; +mod style_field_container; +mod style_field_pick_list; +mod style_field_pick_list_menu; +mod style_field_text_input; +mod variables; mod view_agent_running; mod view_home; mod view_join_cluster_config; @@ -27,6 +34,9 @@ fn main() -> iced::Result { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); iced::application(SecondBrain::new, SecondBrain::update, SecondBrain::view) + .font(include_bytes!( + "../../resources/fonts/JetBrainsMono-Bold.ttf" + )) .subscription(SecondBrain::subscription) .run() } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 201dbb19..6049d977 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -7,7 +7,6 @@ use iced::Subscription; use iced::Task; use iced::time; use iced::widget::column; -use iced::widget::text; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -175,15 +174,16 @@ impl SecondBrain { } }; - let management_port = match config.state_data.bind_port.parse::() { - Ok(port) if port > 0 => port, - _ => { - config.state_data.error = Some("Enter a valid port number.".to_string()); - self.screen = CurrentScreen::StartClusterConfig(config); + let management_port = + match config.state_data.bind_port.parse::() { + Ok(port) => port.get(), + Err(parse_error) => { + config.state_data.error = Some(format!("Invalid port: {parse_error}")); + self.screen = CurrentScreen::StartClusterConfig(config); - return Task::none(); - } - }; + return Task::none(); + } + }; let desired_state = config .state_data @@ -336,7 +336,7 @@ impl SecondBrain { CurrentScreen::RunningCluster(screen) => view_running_cluster(&screen.state_data), }; - column![text("Paddler second brain").size(24), screen_content] + column![screen_content] .padding(20) .spacing(20) .align_x(Center) diff --git a/paddler_second_brain_gui/src/style_button_primary.rs b/paddler_second_brain_gui/src/style_button_primary.rs new file mode 100644 index 00000000..e10d9480 --- /dev/null +++ b/paddler_second_brain_gui/src/style_button_primary.rs @@ -0,0 +1,21 @@ +use iced::Background; +use iced::Border; +use iced::widget::button; + +use crate::variables::COLOR_BODY_BACKGROUND; +use crate::variables::COLOR_BORDER; + +pub fn style_button_primary(theme: &iced::Theme, status: button::Status) -> button::Style { + let base = button::primary(theme, status); + + button::Style { + background: Some(Background::Color(COLOR_BORDER)), + text_color: COLOR_BODY_BACKGROUND, + border: Border { + color: COLOR_BORDER, + width: 0.0, + radius: 0.into(), + }, + ..base + } +} diff --git a/paddler_second_brain_gui/src/style_field_container.rs b/paddler_second_brain_gui/src/style_field_container.rs new file mode 100644 index 00000000..b045a16d --- /dev/null +++ b/paddler_second_brain_gui/src/style_field_container.rs @@ -0,0 +1,18 @@ +use iced::Shadow; +use iced::Vector; +use iced::widget::container; + +use crate::variables::COLOR_BORDER; + +pub fn style_field_container(theme: &iced::Theme) -> container::Style { + let base = container::transparent(theme); + + container::Style { + shadow: Shadow { + color: COLOR_BORDER, + offset: Vector::new(4.0, 4.0), + blur_radius: 0.0, + }, + ..base + } +} diff --git a/paddler_second_brain_gui/src/style_field_pick_list.rs b/paddler_second_brain_gui/src/style_field_pick_list.rs new file mode 100644 index 00000000..7d827d3e --- /dev/null +++ b/paddler_second_brain_gui/src/style_field_pick_list.rs @@ -0,0 +1,23 @@ +use iced::Background; +use iced::Border; +use iced::widget::pick_list; + +use crate::variables::COLOR_BODY_BACKGROUND; +use crate::variables::COLOR_BODY_FONT; +use crate::variables::COLOR_BORDER; + +pub fn style_field_pick_list(theme: &iced::Theme, status: pick_list::Status) -> pick_list::Style { + let base = pick_list::default(theme, status); + + pick_list::Style { + text_color: COLOR_BODY_FONT, + placeholder_color: base.placeholder_color, + handle_color: COLOR_BODY_FONT, + background: Background::Color(COLOR_BODY_BACKGROUND), + border: Border { + color: COLOR_BORDER, + width: 2.0, + radius: 0.into(), + }, + } +} diff --git a/paddler_second_brain_gui/src/style_field_pick_list_menu.rs b/paddler_second_brain_gui/src/style_field_pick_list_menu.rs new file mode 100644 index 00000000..61ba7aea --- /dev/null +++ b/paddler_second_brain_gui/src/style_field_pick_list_menu.rs @@ -0,0 +1,25 @@ +use iced::Background; +use iced::Border; +use iced::Color; +use iced::overlay::menu; + +use crate::variables::COLOR_BODY_BACKGROUND; +use crate::variables::COLOR_BODY_FONT; +use crate::variables::COLOR_BORDER; + +pub fn style_field_pick_list_menu(theme: &iced::Theme) -> menu::Style { + let base = menu::default(theme); + + menu::Style { + background: Background::Color(COLOR_BODY_BACKGROUND), + border: Border { + color: COLOR_BORDER, + width: 1.0, + radius: 0.into(), + }, + text_color: COLOR_BODY_FONT, + selected_text_color: COLOR_BODY_FONT, + selected_background: Background::Color(Color::from_rgb(0.9, 0.9, 0.9)), + ..base + } +} diff --git a/paddler_second_brain_gui/src/style_field_text_input.rs b/paddler_second_brain_gui/src/style_field_text_input.rs new file mode 100644 index 00000000..2fa00f2f --- /dev/null +++ b/paddler_second_brain_gui/src/style_field_text_input.rs @@ -0,0 +1,27 @@ +use iced::Background; +use iced::Border; +use iced::widget::text_input; + +use crate::variables::COLOR_BODY_BACKGROUND; +use crate::variables::COLOR_BODY_FONT; +use crate::variables::COLOR_BORDER; + +pub fn style_field_text_input( + theme: &iced::Theme, + status: text_input::Status, +) -> text_input::Style { + let base = text_input::default(theme, status); + + text_input::Style { + background: Background::Color(COLOR_BODY_BACKGROUND), + border: Border { + color: COLOR_BORDER, + width: 2.0, + radius: 0.into(), + }, + icon: COLOR_BODY_FONT, + placeholder: base.placeholder, + value: COLOR_BODY_FONT, + ..base + } +} diff --git a/paddler_second_brain_gui/src/variables.rs b/paddler_second_brain_gui/src/variables.rs new file mode 100644 index 00000000..b10278d6 --- /dev/null +++ b/paddler_second_brain_gui/src/variables.rs @@ -0,0 +1,24 @@ +use iced::Color; + +// Font sizes + +pub const FONT_SIZE_BASE: f32 = 14.0; +pub const FONT_SIZE_L1: f32 = 1.5 * FONT_SIZE_BASE; +pub const FONT_SIZE_L2: f32 = 1.5 * FONT_SIZE_L1; + +// Spacing + +pub const SPACING_BASE: f32 = 16.0; +pub const SPACING_2X: f32 = 2.0 * SPACING_BASE; +pub const SPACING_HALF: f32 = 0.5 * SPACING_BASE; + +// Colors + +pub const COLOR_BODY_BACKGROUND: Color = Color::WHITE; +pub const COLOR_BODY_FONT: Color = Color { + r: 0.067, + g: 0.067, + b: 0.067, + a: 1.0, +}; +pub const COLOR_BORDER: Color = Color::BLACK; diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index 39e27713..29af1850 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -1,13 +1,26 @@ +use iced::Center; use iced::Element; use iced::widget::button; use iced::widget::column; +use iced::widget::container; use iced::widget::pick_list; +use iced::widget::row; use iced::widget::text; use iced::widget::text_input; +use crate::font::BOLD; use crate::message::Message; use crate::model_preset::ModelPreset; use crate::start_cluster_config_data::StartClusterConfigData; +use crate::style_button_primary::style_button_primary; +use crate::style_field_container::style_field_container; +use crate::style_field_pick_list::style_field_pick_list; +use crate::style_field_pick_list_menu::style_field_pick_list_menu; +use crate::style_field_text_input::style_field_text_input; +use crate::variables::FONT_SIZE_L2; +use crate::variables::SPACING_2X; +use crate::variables::SPACING_BASE; +use crate::variables::SPACING_HALF; pub fn view_start_cluster_config<'content>( data: &'content StartClusterConfigData, @@ -15,28 +28,76 @@ pub fn view_start_cluster_config<'content>( let available_models = ModelPreset::available_presets(); let confirm_button = if data.starting { - button("Starting...") + button(text("Starting...").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) } else if data.selected_model.is_some() && !data.bind_address.is_empty() { - button("Start a cluster").on_press(Message::Confirm) + button(text("Start a cluster").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + .on_press(Message::Confirm) } else { - button("Start a cluster") + button(text("Start a cluster").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) }; + let cancel_button = button(text("Cancel").font(BOLD)) + .style(button::text) + .on_press(Message::Cancel); + let mut content = column![ - button("Back").on_press(Message::Cancel), - text("Balancer address"), - text_input("IP address", &data.bind_address).on_input(Message::SetBindAddress), - text("Balancer port"), - text_input("Port", &data.bind_port).on_input(Message::SetBindPort), - text("Select a model"), - pick_list( - available_models, - data.selected_model.as_ref(), - Message::SelectModel, - ), - confirm_button, + text("Start a cluster").size(FONT_SIZE_L2).font(BOLD), + column![ + container(text("Balancer address").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("IP address", &data.bind_address) + .on_input(Message::SetBindAddress) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF), + column![ + container(text("Balancer port").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("Port", &data.bind_port) + .on_input(Message::SetBindPort) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF), + column![ + container(text("Select a model").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + pick_list( + available_models, + data.selected_model.as_ref(), + Message::SelectModel, + ) + .width(iced::Fill) + .padding(SPACING_BASE) + .style(style_field_pick_list) + .menu_style(style_field_pick_list_menu), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF), + container( + row![cancel_button, confirm_button] + .align_y(Center) + .spacing(SPACING_BASE), + ) + .width(300) + .align_x(iced::alignment::Horizontal::Right), ] - .spacing(10); + .spacing(SPACING_2X); if let Some(error) = &data.error { content = content.push(text(error.clone())); diff --git a/resources/css/_fonts.css b/resources/css/_fonts.css index e6e42047..b11ef5f0 100644 --- a/resources/css/_fonts.css +++ b/resources/css/_fonts.css @@ -1,7 +1,17 @@ @font-face { font-display: swap; font-family: "JetBrains Mono"; - src: url(https://fonts.gstatic.com/s/jetbrainsmono/v23/tDbX2o-flEEny0FZhsfKu5WU4xD-Cw6nSHrV.woff2) - format("woff2"); + src: url("../fonts/JetBrainsMono-Regular.ttf") format("truetype"); + /*src: url(https://fonts.gstatic.com/s/jetbrainsmono/v23/tDbX2o-flEEny0FZhsfKu5WU4xD-Cw6nSHrV.woff2) + format("woff2");*/ font-style: normal; + font-weight: 400; +} + +@font-face { + font-display: swap; + font-family: "JetBrains Mono"; + src: url("../fonts/JetBrainsMono-Bold.ttf") format("truetype"); + font-style: normal; + font-weight: 700; } diff --git a/resources/fonts/JetBrainsMono-Bold.ttf b/resources/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1926c804b95bcd44835689b0ddf1ac10d0b7a805 GIT binary patch literal 114828 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 aV?G>`5QswYyYuzw_`-R@-wqEc%Krr6ZWs;# literal 0 HcmV?d00001 From bb8a339e40644f7f1c6517789b0474531ba67e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 22:50:10 +0100 Subject: [PATCH 14/46] replace balancer address and port inputs with combines + inference --- paddler_second_brain_gui/src/message.rs | 4 +- paddler_second_brain_gui/src/screen.rs | 6 +-- paddler_second_brain_gui/src/second_brain.rs | 53 ++++++++++++------- .../src/start_balancer.rs | 10 ++-- .../src/start_balancer_services.rs | 8 +-- .../src/start_cluster_config_data.rs | 4 +- .../src/view_start_cluster_config.rs | 15 +++--- 7 files changed, 57 insertions(+), 43 deletions(-) diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index bafdf990..22491292 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -13,8 +13,8 @@ pub enum Message { StartCluster, Cancel, CopyToClipboard(String), - SetBindAddress(String), - SetBindPort(String), + SetBalancerAddress(String), + SetInferenceAddress(String), SelectModel(ModelPreset), Confirm, ClusterStarted, diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index d3a5b6d2..1c83ccbb 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -33,9 +33,9 @@ impl Screen { .unwrap_or_default(); self.transition_with(StartClusterConfigData { - bind_address: suggested_address, - bind_port: "8060".to_string(), + balancer_address: format!("{suggested_address}:8060"), error: None, + inference_address: format!("{suggested_address}:8061"), selected_model: None, starting: false, }) @@ -73,7 +73,7 @@ impl Screen { pub fn cluster_started(self) -> Screen { self.transition_map(|config_data| RunningClusterData { agent_count: 0, - cluster_address: format!("{}:{}", config_data.bind_address, config_data.bind_port), + cluster_address: config_data.balancer_address.clone(), stopping: false, }) } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 6049d977..3de1d561 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -149,41 +149,56 @@ impl SecondBrain { Task::none() } - (CurrentScreen::StartClusterConfig(mut config), Message::SetBindAddress(address)) => { - config.state_data.bind_address = address; + ( + CurrentScreen::StartClusterConfig(mut config), + Message::SetBalancerAddress(address), + ) => { + config.state_data.balancer_address = address; config.state_data.error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() } - (CurrentScreen::StartClusterConfig(mut config), Message::SetBindPort(port)) => { - config.state_data.bind_port = port; + ( + CurrentScreen::StartClusterConfig(mut config), + Message::SetInferenceAddress(address), + ) => { + config.state_data.inference_address = address; config.state_data.error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() } (CurrentScreen::StartClusterConfig(mut config), Message::Confirm) => { - let bind_ip = match config.state_data.bind_address.parse::() { - Ok(ip) => ip, - Err(std::net::AddrParseError { .. }) => { - config.state_data.error = Some("Enter a valid IP address.".to_string()); + let management_addr = match config + .state_data + .balancer_address + .parse::() + { + Ok(addr) => addr, + Err(parse_error) => { + config.state_data.error = + Some(format!("Invalid balancer address: {parse_error}")); self.screen = CurrentScreen::StartClusterConfig(config); return Task::none(); } }; - let management_port = - match config.state_data.bind_port.parse::() { - Ok(port) => port.get(), - Err(parse_error) => { - config.state_data.error = Some(format!("Invalid port: {parse_error}")); - self.screen = CurrentScreen::StartClusterConfig(config); + let inference_addr = match config + .state_data + .inference_address + .parse::() + { + Ok(addr) => addr, + Err(parse_error) => { + config.state_data.error = + Some(format!("Invalid inference address: {parse_error}")); + self.screen = CurrentScreen::StartClusterConfig(config); - return Task::none(); - } - }; + return Task::none(); + } + }; let desired_state = config .state_data @@ -203,8 +218,8 @@ impl SecondBrain { Task::batch([ Task::perform( start_balancer( - bind_ip, - management_port, + management_addr, + inference_addr, desired_state, agent_count_tx, shutdown_rx, diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs index 56117595..a871cbea 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -1,4 +1,4 @@ -use std::net::IpAddr; +use std::net::SocketAddr; use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::mpsc; @@ -7,8 +7,8 @@ use tokio::sync::oneshot; use crate::start_balancer_services::start_balancer_services; pub async fn start_balancer( - bind_ip: IpAddr, - management_port: u16, + management_addr: SocketAddr, + inference_addr: SocketAddr, initial_desired_state: BalancerDesiredState, agent_count_tx: mpsc::UnboundedSender, shutdown_rx: oneshot::Receiver<()>, @@ -18,8 +18,8 @@ pub async fn start_balancer( std::thread::spawn(move || { let system = actix_web::rt::System::new(); let result = system.block_on(start_balancer_services( - bind_ip, - management_port, + management_addr, + inference_addr, initial_desired_state, agent_count_tx, shutdown_rx, diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs index 3e2ec2fa..6845e627 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -1,4 +1,3 @@ -use std::net::IpAddr; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -26,15 +25,12 @@ use tokio::sync::oneshot; use crate::agent_monitor_service::AgentMonitorService; pub async fn start_balancer_services( - bind_ip: IpAddr, - management_port: u16, + management_addr: SocketAddr, + inference_addr: SocketAddr, initial_desired_state: BalancerDesiredState, agent_count_tx: mpsc::UnboundedSender, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { - let management_addr = SocketAddr::new(bind_ip, management_port); - let inference_addr = SocketAddr::new(bind_ip, management_port + 1); - let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); let agent_controller_pool = Arc::new(AgentControllerPool::default()); diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index 58a5797d..e3b3590b 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -1,9 +1,9 @@ use crate::model_preset::ModelPreset; pub struct StartClusterConfigData { - pub bind_address: String, - pub bind_port: String, + pub balancer_address: String, pub error: Option, + pub inference_address: String, pub selected_model: Option, pub starting: bool, } diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index 29af1850..6bcdce3c 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -31,7 +31,10 @@ pub fn view_start_cluster_config<'content>( button(text("Starting...").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) - } else if data.selected_model.is_some() && !data.bind_address.is_empty() { + } else if data.selected_model.is_some() + && !data.balancer_address.is_empty() + && !data.inference_address.is_empty() + { button(text("Start a cluster").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) @@ -51,8 +54,8 @@ pub fn view_start_cluster_config<'content>( column![ container(text("Balancer address").font(BOLD)).padding([0.0, SPACING_BASE]), container( - text_input("IP address", &data.bind_address) - .on_input(Message::SetBindAddress) + text_input("IP:port", &data.balancer_address) + .on_input(Message::SetBalancerAddress) .padding(SPACING_BASE) .style(style_field_text_input), ) @@ -61,10 +64,10 @@ pub fn view_start_cluster_config<'content>( ] .spacing(SPACING_HALF), column![ - container(text("Balancer port").font(BOLD)).padding([0.0, SPACING_BASE]), + container(text("Inference address").font(BOLD)).padding([0.0, SPACING_BASE]), container( - text_input("Port", &data.bind_port) - .on_input(Message::SetBindPort) + text_input("IP:port", &data.inference_address) + .on_input(Message::SetInferenceAddress) .padding(SPACING_BASE) .style(style_field_text_input), ) From 86408ca090e0b20cedd5675c191d1b6deec98988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 18 Mar 2026 23:36:23 +0100 Subject: [PATCH 15/46] style the join cluster view, allow to set agent name --- .../src/join_cluster_config_data.rs | 1 + paddler_second_brain_gui/src/message.rs | 1 + paddler_second_brain_gui/src/second_brain.rs | 9 ++ paddler_second_brain_gui/src/start_agent.rs | 2 + .../src/start_agent_services.rs | 5 +- .../src/view_join_cluster_config.rs | 84 +++++++++++++++---- 6 files changed, 86 insertions(+), 16 deletions(-) diff --git a/paddler_second_brain_gui/src/join_cluster_config_data.rs b/paddler_second_brain_gui/src/join_cluster_config_data.rs index 720978d5..8ad74e7d 100644 --- a/paddler_second_brain_gui/src/join_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/join_cluster_config_data.rs @@ -1,5 +1,6 @@ #[derive(Default)] pub struct JoinClusterConfigData { + pub agent_name: String, pub cluster_address: String, pub error: Option, pub slots_count: String, diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index 22491292..7a865306 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -5,6 +5,7 @@ pub enum Message { AgentFailed(String), AgentStopped, Connect, + SetAgentName(String), Disconnect, JoinCluster, RefreshAgentStatus, diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 3de1d561..c4986aac 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -87,6 +87,13 @@ impl SecondBrain { Task::none() } + (CurrentScreen::JoinClusterConfig(mut config), Message::SetAgentName(name)) => { + config.state_data.agent_name = name; + config.state_data.error = None; + self.screen = CurrentScreen::JoinClusterConfig(config); + + Task::none() + } (CurrentScreen::JoinClusterConfig(mut config), Message::SetClusterAddress(address)) => { config.state_data.cluster_address = address; config.state_data.error = None; @@ -114,6 +121,7 @@ impl SecondBrain { } }; + let agent_name = config.state_data.agent_name.clone(); let management_address = config.state_data.cluster_address.clone(); let (agent_shutdown_tx, agent_shutdown_rx) = oneshot::channel::<()>(); @@ -126,6 +134,7 @@ impl SecondBrain { Task::perform( start_agent( + agent_name, management_address, slots, agent_status_tx, diff --git a/paddler_second_brain_gui/src/start_agent.rs b/paddler_second_brain_gui/src/start_agent.rs index a8af9433..4d5c1da8 100644 --- a/paddler_second_brain_gui/src/start_agent.rs +++ b/paddler_second_brain_gui/src/start_agent.rs @@ -5,6 +5,7 @@ use tokio::sync::oneshot; use crate::start_agent_services::start_agent_services; pub async fn start_agent( + agent_name: String, management_address: String, slots: i32, agent_status_tx: mpsc::UnboundedSender, @@ -15,6 +16,7 @@ pub async fn start_agent( std::thread::spawn(move || { let system = actix_web::rt::System::new(); let result = system.block_on(start_agent_services( + agent_name, management_address, slots, agent_status_tx, diff --git a/paddler_second_brain_gui/src/start_agent_services.rs b/paddler_second_brain_gui/src/start_agent_services.rs index 07a18ee3..05259e63 100644 --- a/paddler_second_brain_gui/src/start_agent_services.rs +++ b/paddler_second_brain_gui/src/start_agent_services.rs @@ -19,6 +19,7 @@ use tokio::sync::oneshot; use crate::agent_status_monitor_service::AgentStatusMonitorService; pub async fn start_agent_services( + agent_name: String, management_address: String, slots: i32, agent_status_tx: mpsc::UnboundedSender, @@ -50,7 +51,7 @@ pub async fn start_agent_services( service_manager.add_service(LlamaCppArbiterService { agent_applicable_state: None, agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_name: None, + agent_name: Some(agent_name.clone()), continue_from_conversation_history_request_rx, continue_from_raw_prompt_request_rx, desired_slots_total: slots, @@ -67,7 +68,7 @@ pub async fn start_agent_services( continue_from_raw_prompt_request_tx, generate_embedding_batch_request_tx, model_metadata_holder, - name: None, + name: Some(agent_name), receive_stream_stopper_collection: Default::default(), slot_aggregated_status: slot_aggregated_status_manager .slot_aggregated_status diff --git a/paddler_second_brain_gui/src/view_join_cluster_config.rs b/paddler_second_brain_gui/src/view_join_cluster_config.rs index 70756b86..386f85d6 100644 --- a/paddler_second_brain_gui/src/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_join_cluster_config.rs @@ -1,11 +1,22 @@ +use iced::Center; use iced::Element; use iced::widget::button; use iced::widget::column; +use iced::widget::container; +use iced::widget::row; use iced::widget::text; use iced::widget::text_input; +use crate::font::BOLD; use crate::join_cluster_config_data::JoinClusterConfigData; use crate::message::Message; +use crate::style_button_primary::style_button_primary; +use crate::style_field_container::style_field_container; +use crate::style_field_text_input::style_field_text_input; +use crate::variables::FONT_SIZE_L2; +use crate::variables::SPACING_2X; +use crate::variables::SPACING_BASE; +use crate::variables::SPACING_HALF; pub fn view_join_cluster_config<'content>( data: &'content JoinClusterConfigData, @@ -16,24 +27,69 @@ pub fn view_join_cluster_config<'content>( .map(|slots| slots > 0) .unwrap_or(false); - let connect_button = if !data.cluster_address.is_empty() && is_valid_slots { - button("Connect").on_press(Message::Connect) - } else { - button("Connect") - }; + let confirm_button = + if !data.cluster_address.is_empty() && !data.agent_name.is_empty() && is_valid_slots { + button(text("Connect").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + .on_press(Message::Connect) + } else { + button(text("Connect").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + }; + + let cancel_button = button(text("Cancel").font(BOLD)) + .style(button::text) + .on_press(Message::Cancel); let mut content = column![ - button("Back").on_press(Message::Cancel), - text("Join a cluster"), - text_input( - "Cluster address (e.g. 192.168.1.5:8060)", - &data.cluster_address, + text("Join a cluster").size(FONT_SIZE_L2).font(BOLD), + column![ + container(text("Cluster address").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("", &data.cluster_address) + .on_input(Message::SetClusterAddress) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF), + column![ + container(text("Agent name").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("my-agent", &data.agent_name) + .on_input(Message::SetAgentName) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF), + column![ + container(text("Slots").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("e.g. 1", &data.slots_count) + .on_input(Message::SetSlotsCount) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF), + container( + row![cancel_button, confirm_button] + .align_y(Center) + .spacing(SPACING_BASE), ) - .on_input(Message::SetClusterAddress), - text_input("Slots (e.g. 1)", &data.slots_count).on_input(Message::SetSlotsCount), - connect_button, + .width(300) + .align_x(iced::alignment::Horizontal::Right), ] - .spacing(10); + .spacing(SPACING_2X); if let Some(error) = &data.error { content = content.push(text(error.clone())); From bb227110f44f66fb6de5021799d6d4aafafe195c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 19 Mar 2026 01:17:39 +0100 Subject: [PATCH 16/46] style the running cluster view --- Cargo.lock | 97 ++++++++++++- Cargo.toml | 2 +- .../src/agent_monitor_service.rs | 38 +++-- paddler_second_brain_gui/src/font.rs | 7 + paddler_second_brain_gui/src/main.rs | 6 + .../src/running_cluster_data.rs | 2 +- paddler_second_brain_gui/src/screen.rs | 2 +- paddler_second_brain_gui/src/second_brain.rs | 19 +-- .../src/start_balancer.rs | 4 +- .../src/start_balancer_services.rs | 4 +- .../src/style_agent_container.rs | 20 +++ .../src/style_button_danger.rs | 25 ++++ .../src/style_card_container.rs | 20 +++ paddler_second_brain_gui/src/variables.rs | 6 + .../src/view_join_cluster_config.rs | 3 +- .../src/view_running_cluster.rs | 131 ++++++++++++++++-- .../src/view_start_cluster_config.rs | 3 +- resources/icons/copy.svg | 1 + resources/icons/stop.svg | 1 + 19 files changed, 337 insertions(+), 54 deletions(-) create mode 100644 paddler_second_brain_gui/src/style_agent_container.rs create mode 100644 paddler_second_brain_gui/src/style_button_danger.rs create mode 100644 paddler_second_brain_gui/src/style_card_container.rs create mode 100644 resources/icons/copy.svg create mode 100644 resources/icons/stop.svg diff --git a/Cargo.lock b/Cargo.lock index cbc74f38..87fa39d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2258,6 +2258,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.1" @@ -2772,6 +2782,7 @@ dependencies = [ "iced_graphics", "kurbo 0.10.4", "log", + "resvg 0.45.1", "rustc-hash 2.1.1", "softbuffer", "tiny-skia", @@ -2792,6 +2803,7 @@ dependencies = [ "iced_debug", "iced_graphics", "log", + "resvg 0.45.1", "rustc-hash 2.1.1", "thiserror 2.0.18", "wgpu", @@ -2958,7 +2970,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.14.1", "image-webp", "moxcms", "num-traits", @@ -2982,6 +2994,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imagesize" version = "0.14.0" @@ -3245,6 +3263,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.0" @@ -4405,7 +4434,7 @@ dependencies = [ "paddler_types", "rand 0.9.2", "reqwest", - "resvg", + "resvg 0.46.0", "rust-embed", "serde", "serde_json", @@ -5122,20 +5151,37 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + [[package]] name = "resvg" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ - "gif", + "gif 0.14.1", "image-webp", "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia", - "usvg", + "usvg 0.46.0", "zune-jpeg 0.5.12", ] @@ -5859,6 +5905,16 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "svgtypes" version = "0.16.1" @@ -6497,6 +6553,33 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.46.0" @@ -6507,7 +6590,7 @@ dependencies = [ "data-url", "flate2", "fontdb", - "imagesize", + "imagesize 0.14.0", "kurbo 0.13.0", "log", "pico-args", @@ -6516,7 +6599,7 @@ dependencies = [ "simplecss", "siphasher", "strict-num", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia-path", "unicode-bidi", "unicode-script", diff --git a/Cargo.toml b/Cargo.toml index b1872dad..9d5447bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ serial_test = { version = "3", features = ["file_locks"] } serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" -iced = { version = "0.14", features = ["tokio"] } +iced = { version = "0.14", features = ["svg", "tokio"] } if-addrs = "0.13" statum = "0.6" tempfile = "3.20.0" diff --git a/paddler_second_brain_gui/src/agent_monitor_service.rs b/paddler_second_brain_gui/src/agent_monitor_service.rs index 185b4e74..4aee698e 100644 --- a/paddler_second_brain_gui/src/agent_monitor_service.rs +++ b/paddler_second_brain_gui/src/agent_monitor_service.rs @@ -9,7 +9,25 @@ use tokio::sync::mpsc; pub struct AgentMonitorService { pub agent_controller_pool: Arc, - pub agent_count_tx: mpsc::UnboundedSender, + pub agent_names_tx: mpsc::UnboundedSender>, +} + +fn collect_agent_names(pool: &AgentControllerPool) -> Vec { + let mut names: Vec = pool + .agents + .iter() + .map(|entry| { + entry + .value() + .name + .clone() + .unwrap_or_else(|| entry.key().clone()) + }) + .collect(); + + names.sort(); + + names } #[async_trait] @@ -19,23 +37,13 @@ impl Service for AgentMonitorService { } async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { - let mut previous_count: Option = None; - loop { - let count = self.agent_controller_pool.agents.len(); - - let has_changed = previous_count - .map(|previous| previous != count) - .unwrap_or(true); + let names = collect_agent_names(&self.agent_controller_pool); - if has_changed { - if let Err(send_error) = self.agent_count_tx.send(count) { - log::warn!("Agent count receiver dropped: {send_error}"); - - break; - } + if let Err(send_error) = self.agent_names_tx.send(names) { + log::warn!("Agent names receiver dropped: {send_error}"); - previous_count = Some(count); + break; } tokio::select! { diff --git a/paddler_second_brain_gui/src/font.rs b/paddler_second_brain_gui/src/font.rs index b049f77a..322584f9 100644 --- a/paddler_second_brain_gui/src/font.rs +++ b/paddler_second_brain_gui/src/font.rs @@ -2,6 +2,13 @@ use iced::Font; use iced::font::Family; use iced::font::Weight; +pub const REGULAR: Font = Font { + family: Family::Name("JetBrains Mono"), + weight: Weight::Normal, + stretch: iced::font::Stretch::Normal, + style: iced::font::Style::Normal, +}; + pub const BOLD: Font = Font { family: Family::Name("JetBrains Mono"), weight: Weight::Bold, diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 18666bcc..9385e9be 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -16,7 +16,10 @@ mod start_agent_services; mod start_balancer; mod start_balancer_services; mod start_cluster_config_data; +mod style_agent_container; +mod style_button_danger; mod style_button_primary; +mod style_card_container; mod style_field_container; mod style_field_pick_list; mod style_field_pick_list_menu; @@ -34,6 +37,9 @@ fn main() -> iced::Result { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); iced::application(SecondBrain::new, SecondBrain::update, SecondBrain::view) + .font(include_bytes!( + "../../resources/fonts/JetBrainsMono-Regular.ttf" + )) .font(include_bytes!( "../../resources/fonts/JetBrainsMono-Bold.ttf" )) diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs index 0f9aef7c..021ce2fb 100644 --- a/paddler_second_brain_gui/src/running_cluster_data.rs +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -1,5 +1,5 @@ pub struct RunningClusterData { - pub agent_count: usize, + pub agent_names: Vec, pub cluster_address: String, pub stopping: bool, } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index 1c83ccbb..d0618d8f 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -72,7 +72,7 @@ impl Screen { pub fn cluster_started(self) -> Screen { self.transition_map(|config_data| RunningClusterData { - agent_count: 0, + agent_names: vec![], cluster_address: config_data.balancer_address.clone(), stopping: false, }) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index c4986aac..9364de4b 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -32,7 +32,7 @@ fn drain_latest(receiver: &mut mpsc::UnboundedReceiver) -> Optio } pub struct SecondBrain { - agent_count_rx: Option>, + agent_names_rx: Option>>, agent_shutdown_tx: Option>, agent_status_rx: Option>, screen: CurrentScreen, @@ -58,7 +58,7 @@ impl Drop for SecondBrain { impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { - agent_count_rx: None, + agent_names_rx: None, agent_shutdown_tx: None, agent_status_rx: None, screen: CurrentScreen::default(), @@ -217,9 +217,9 @@ impl SecondBrain { .unwrap_or_default(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let (agent_count_tx, agent_count_rx) = mpsc::unbounded_channel::(); + let (agent_names_tx, agent_names_rx) = mpsc::unbounded_channel::>(); - self.agent_count_rx = Some(agent_count_rx); + self.agent_names_rx = Some(agent_names_rx); self.shutdown_tx = Some(shutdown_tx); config.state_data.starting = true; self.screen = CurrentScreen::StartClusterConfig(config); @@ -230,7 +230,7 @@ impl SecondBrain { management_addr, inference_addr, desired_state, - agent_count_tx, + agent_names_tx, shutdown_rx, ), |result: Result<(), anyhow::Error>| match result { @@ -254,8 +254,8 @@ impl SecondBrain { Task::none() } (CurrentScreen::RunningCluster(mut running), Message::RefreshAgentCount) => { - if let Some(count) = self.agent_count_rx.as_mut().and_then(drain_latest) { - running.state_data.agent_count = count; + if let Some(names) = self.agent_names_rx.as_mut().and_then(drain_latest) { + running.state_data.agent_names = names; } self.screen = CurrentScreen::RunningCluster(running); @@ -267,7 +267,7 @@ impl SecondBrain { { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } - self.agent_count_rx = None; + self.agent_names_rx = None; running.state_data.stopping = true; self.screen = CurrentScreen::RunningCluster(running); @@ -280,7 +280,7 @@ impl SecondBrain { } (CurrentScreen::RunningCluster(running), Message::ClusterFailed(error)) => { log::error!("Cluster failed unexpectedly: {error}"); - self.agent_count_rx = None; + self.agent_names_rx = None; self.shutdown_tx = None; self.screen = CurrentScreen::Home(running.cluster_failed()); @@ -361,6 +361,7 @@ impl SecondBrain { }; column![screen_content] + .max_width(700) .padding(20) .spacing(20) .align_x(Center) diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs index a871cbea..1c65a0d6 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -10,7 +10,7 @@ pub async fn start_balancer( management_addr: SocketAddr, inference_addr: SocketAddr, initial_desired_state: BalancerDesiredState, - agent_count_tx: mpsc::UnboundedSender, + agent_names_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { let (result_tx, result_rx) = oneshot::channel(); @@ -21,7 +21,7 @@ pub async fn start_balancer( management_addr, inference_addr, initial_desired_state, - agent_count_tx, + agent_names_tx, shutdown_rx, )); if let Err(unsent_result) = result_tx.send(result) { diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs index 6845e627..136b29d1 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -28,7 +28,7 @@ pub async fn start_balancer_services( management_addr: SocketAddr, inference_addr: SocketAddr, initial_desired_state: BalancerDesiredState, - agent_count_tx: mpsc::UnboundedSender, + agent_names_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); @@ -85,7 +85,7 @@ pub async fn start_balancer_services( service_manager.add_service(AgentMonitorService { agent_controller_pool: agent_controller_pool.clone(), - agent_count_tx, + agent_names_tx, }); service_manager.add_service(ReconciliationService { diff --git a/paddler_second_brain_gui/src/style_agent_container.rs b/paddler_second_brain_gui/src/style_agent_container.rs new file mode 100644 index 00000000..8b272da8 --- /dev/null +++ b/paddler_second_brain_gui/src/style_agent_container.rs @@ -0,0 +1,20 @@ +use iced::Background; +use iced::Border; +use iced::widget::container; + +use crate::variables::COLOR_AGENT_BACKGROUND; +use crate::variables::COLOR_BORDER; + +pub fn style_agent_container(theme: &iced::Theme) -> container::Style { + let base = container::transparent(theme); + + container::Style { + background: Some(Background::Color(COLOR_AGENT_BACKGROUND)), + border: Border { + color: COLOR_BORDER, + width: 2.0, + radius: 0.into(), + }, + ..base + } +} diff --git a/paddler_second_brain_gui/src/style_button_danger.rs b/paddler_second_brain_gui/src/style_button_danger.rs new file mode 100644 index 00000000..2438a1cf --- /dev/null +++ b/paddler_second_brain_gui/src/style_button_danger.rs @@ -0,0 +1,25 @@ +use iced::Background; +use iced::Border; +use iced::Color; +use iced::widget::button; + +pub fn style_button_danger(theme: &iced::Theme, status: button::Status) -> button::Style { + let base = button::primary(theme, status); + + let background_color = Color::from_rgb( + 0xCC as f32 / 255.0, + 0x33 as f32 / 255.0, + 0x33 as f32 / 255.0, + ); + + button::Style { + background: Some(Background::Color(background_color)), + text_color: Color::WHITE, + border: Border { + color: background_color, + width: 0.0, + radius: 0.into(), + }, + ..base + } +} diff --git a/paddler_second_brain_gui/src/style_card_container.rs b/paddler_second_brain_gui/src/style_card_container.rs new file mode 100644 index 00000000..4130e3b0 --- /dev/null +++ b/paddler_second_brain_gui/src/style_card_container.rs @@ -0,0 +1,20 @@ +use iced::Background; +use iced::Border; +use iced::widget::container; + +use crate::variables::COLOR_BODY_BACKGROUND; +use crate::variables::COLOR_BORDER; + +pub fn style_card_container(theme: &iced::Theme) -> container::Style { + let base = container::transparent(theme); + + container::Style { + background: Some(Background::Color(COLOR_BODY_BACKGROUND)), + border: Border { + color: COLOR_BORDER, + width: 2.0, + radius: 0.into(), + }, + ..base + } +} diff --git a/paddler_second_brain_gui/src/variables.rs b/paddler_second_brain_gui/src/variables.rs index b10278d6..3b8bd9c5 100644 --- a/paddler_second_brain_gui/src/variables.rs +++ b/paddler_second_brain_gui/src/variables.rs @@ -21,4 +21,10 @@ pub const COLOR_BODY_FONT: Color = Color { b: 0.067, a: 1.0, }; +pub const COLOR_AGENT_BACKGROUND: Color = Color { + r: 250.0 / 255.0, + g: 240.0 / 255.0, + b: 230.0 / 255.0, + a: 1.0, +}; pub const COLOR_BORDER: Color = Color::BLACK; diff --git a/paddler_second_brain_gui/src/view_join_cluster_config.rs b/paddler_second_brain_gui/src/view_join_cluster_config.rs index 386f85d6..00667d78 100644 --- a/paddler_second_brain_gui/src/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_join_cluster_config.rs @@ -44,7 +44,8 @@ pub fn view_join_cluster_config<'content>( .on_press(Message::Cancel); let mut content = column![ - text("Join a cluster").size(FONT_SIZE_L2).font(BOLD), + container(text("Join a cluster").size(FONT_SIZE_L2).font(BOLD)) + .padding([0.0, SPACING_BASE]), column![ container(text("Cluster address").font(BOLD)).padding([0.0, SPACING_BASE]), container( diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index 5fce2be0..f0b8a6f6 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -1,36 +1,139 @@ +use iced::Background; +use iced::Border; +use iced::Color; use iced::Element; use iced::widget::button; use iced::widget::column; +use iced::widget::container; use iced::widget::row; +use iced::widget::svg; use iced::widget::text; +use crate::font::BOLD; +use crate::font::REGULAR; use crate::message::Message; use crate::running_cluster_data::RunningClusterData; +use crate::style_agent_container::style_agent_container; +use crate::style_button_danger::style_button_danger; +use crate::style_card_container::style_card_container; +use crate::variables::FONT_SIZE_L2; +use crate::variables::SPACING_2X; +use crate::variables::SPACING_BASE; +use crate::variables::SPACING_HALF; pub fn view_running_cluster<'content>( data: &'content RunningClusterData, ) -> Element<'content, Message> { - let agent_label = match data.agent_count { - 1 => "1 agent connected".to_string(), - count => format!("{count} agents connected"), + let copy_icon = svg(svg::Handle::from_memory( + include_bytes!("../../resources/icons/copy.svg").as_slice(), + )) + .width(16) + .height(16); + + let address_row = container( + row![ + container(text(format!("Balancer address: {}", data.cluster_address)).font(REGULAR)) + .width(iced::Fill), + button( + row![copy_icon, text("Copy address").font(BOLD)] + .spacing(SPACING_BASE / 2.0) + .align_y(iced::Center), + ) + .style(button::text) + .on_press(Message::CopyToClipboard(data.cluster_address.clone())), + ] + .align_y(iced::Center) + .padding(SPACING_BASE), + ) + .style(style_card_container); + + let stop_icon = svg(svg::Handle::from_memory( + include_bytes!("../../resources/icons/stop.svg").as_slice(), + )) + .width(16) + .height(16); + + let status_indicator = container("") + .width(16) + .height(16) + .style(|theme: &iced::Theme| { + let base = container::transparent(theme); + + container::Style { + background: Some(Background::Color(Color::from_rgb( + 0xEE as f32 / 255.0, + 0xFF as f32 / 255.0, + 0xEE as f32 / 255.0, + ))), + border: Border { + color: Color::from_rgb( + 0xCC as f32 / 255.0, + 0xDD as f32 / 255.0, + 0xCC as f32 / 255.0, + ), + width: 2.0, + radius: 8.into(), + }, + ..base + } + }); + + let stop_button = if data.stopping { + button( + row![stop_icon, text("Stopping...").font(BOLD)] + .spacing(SPACING_HALF) + .align_y(iced::Center), + ) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_danger) + } else { + button( + row![stop_icon, text("Stop cluster").font(BOLD)] + .spacing(SPACING_HALF) + .align_y(iced::Center), + ) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_danger) + .on_press(Message::Stop) }; - let mut content = column![ - text("Your cluster").size(20), - text(agent_label), + let status_row = container( row![ - text(data.cluster_address.clone()), - button("Copy").on_press(Message::CopyToClipboard(data.cluster_address.clone())), + container( + row![text("Cluster is running").font(REGULAR), status_indicator,] + .spacing(SPACING_HALF) + .align_y(iced::Center), + ) + .width(iced::Fill), + stop_button, ] - .spacing(10), + .align_y(iced::Center), + ) + .padding([0.0, SPACING_BASE]); + + let mut content = column![ + container(text("Your cluster").size(FONT_SIZE_L2).font(BOLD)).padding([0.0, SPACING_BASE]), + address_row, + status_row, + container(text("Connected agents").font(BOLD)).padding([0.0, SPACING_BASE]), ] - .spacing(10); + .spacing(SPACING_2X); - content = content.push(if data.stopping { - button("Stopping...") + if data.agent_names.is_empty() { + content = content.push( + container(text("Waiting for agents to connect...").font(REGULAR)) + .padding([0.0, SPACING_BASE]), + ); } else { - button("Stop cluster").on_press(Message::Stop) - }); + for agent_name in &data.agent_names { + content = content.push( + container(text(agent_name.clone()).font(REGULAR)) + .width(iced::Fill) + .padding(SPACING_BASE) + .style(style_agent_container), + ); + } + } content.into() } diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index 6bcdce3c..fe0ece9f 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -50,7 +50,8 @@ pub fn view_start_cluster_config<'content>( .on_press(Message::Cancel); let mut content = column![ - text("Start a cluster").size(FONT_SIZE_L2).font(BOLD), + container(text("Start a cluster").size(FONT_SIZE_L2).font(BOLD)) + .padding([0.0, SPACING_BASE]), column![ container(text("Balancer address").font(BOLD)).padding([0.0, SPACING_BASE]), container( diff --git a/resources/icons/copy.svg b/resources/icons/copy.svg new file mode 100644 index 00000000..513e589c --- /dev/null +++ b/resources/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/stop.svg b/resources/icons/stop.svg new file mode 100644 index 00000000..afaf0f26 --- /dev/null +++ b/resources/icons/stop.svg @@ -0,0 +1 @@ + From f4e5bb71f0337ec0fcae8dfc669e1a5b50516221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 19 Mar 2026 02:51:44 +0100 Subject: [PATCH 17/46] style the running agent view, add qwen 3 0.6B preset --- .../src/agent_running_data.rs | 2 + paddler_second_brain_gui/src/main.rs | 1 + paddler_second_brain_gui/src/model_preset.rs | 38 +++-- paddler_second_brain_gui/src/screen.rs | 6 +- .../src/style_download_progress_bar.rs | 18 +++ .../src/view_agent_running.rs | 146 +++++++++++++++--- 6 files changed, 172 insertions(+), 39 deletions(-) create mode 100644 paddler_second_brain_gui/src/style_download_progress_bar.rs diff --git a/paddler_second_brain_gui/src/agent_running_data.rs b/paddler_second_brain_gui/src/agent_running_data.rs index bb252ba2..a151cc79 100644 --- a/paddler_second_brain_gui/src/agent_running_data.rs +++ b/paddler_second_brain_gui/src/agent_running_data.rs @@ -1,5 +1,7 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; pub struct AgentRunningData { + pub agent_name: String, + pub cluster_address: String, pub status: Option, } diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 9385e9be..aaea5a7c 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -20,6 +20,7 @@ mod style_agent_container; mod style_button_danger; mod style_button_primary; mod style_card_container; +mod style_download_progress_bar; mod style_field_container; mod style_field_pick_list; mod style_field_pick_list_menu; diff --git a/paddler_second_brain_gui/src/model_preset.rs b/paddler_second_brain_gui/src/model_preset.rs index f8548de2..6ae23a2d 100644 --- a/paddler_second_brain_gui/src/model_preset.rs +++ b/paddler_second_brain_gui/src/model_preset.rs @@ -15,20 +15,32 @@ pub struct ModelPreset { impl ModelPreset { pub fn available_presets() -> Vec { - vec![ModelPreset { - display_name: "Qwen 3.5 0.8B".to_string(), - model: HuggingFaceModelReference { - repo_id: "unsloth/Qwen3.5-0.8B-GGUF".to_string(), - filename: "Qwen3.5-0.8B-Q4_K_M.gguf".to_string(), - revision: "main".to_string(), + vec![ + ModelPreset { + display_name: "Qwen 3 0.6B".to_string(), + model: HuggingFaceModelReference { + repo_id: "unsloth/Qwen3-0.6B-GGUF".to_string(), + filename: "Qwen3-0.6B-Q8_0.gguf".to_string(), + revision: "main".to_string(), + }, + multimodal_projection: None, + inference_parameters: InferenceParameters::default(), }, - multimodal_projection: Some(HuggingFaceModelReference { - repo_id: "unsloth/Qwen3.5-0.8B-GGUF".to_string(), - filename: "mmproj-F16.gguf".to_string(), - revision: "main".to_string(), - }), - inference_parameters: InferenceParameters::default(), - }] + ModelPreset { + display_name: "Qwen 3.5 0.8B".to_string(), + model: HuggingFaceModelReference { + repo_id: "unsloth/Qwen3.5-0.8B-GGUF".to_string(), + filename: "Qwen3.5-0.8B-Q4_K_M.gguf".to_string(), + revision: "main".to_string(), + }, + multimodal_projection: Some(HuggingFaceModelReference { + repo_id: "unsloth/Qwen3.5-0.8B-GGUF".to_string(), + filename: "mmproj-F16.gguf".to_string(), + revision: "main".to_string(), + }), + inference_parameters: InferenceParameters::default(), + }, + ] } pub fn to_balancer_desired_state(&self) -> BalancerDesiredState { diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index d0618d8f..a3116ad3 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -49,7 +49,11 @@ impl Screen { } pub fn connect(self) -> Screen { - self.transition_with(AgentRunningData { status: None }) + self.transition_map(|config_data| AgentRunningData { + agent_name: config_data.agent_name.clone(), + cluster_address: config_data.cluster_address.clone(), + status: None, + }) } } diff --git a/paddler_second_brain_gui/src/style_download_progress_bar.rs b/paddler_second_brain_gui/src/style_download_progress_bar.rs new file mode 100644 index 00000000..67bf3674 --- /dev/null +++ b/paddler_second_brain_gui/src/style_download_progress_bar.rs @@ -0,0 +1,18 @@ +use iced::Background; +use iced::Border; +use iced::widget::progress_bar; + +use crate::variables::COLOR_BODY_BACKGROUND; +use crate::variables::COLOR_BORDER; + +pub fn style_download_progress_bar(_theme: &iced::Theme) -> progress_bar::Style { + progress_bar::Style { + background: Background::Color(COLOR_BODY_BACKGROUND), + bar: Background::Color(COLOR_BORDER), + border: Border { + color: COLOR_BORDER, + width: 2.0, + radius: 0.into(), + }, + } +} diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/view_agent_running.rs index 08641f35..30c88d0c 100644 --- a/paddler_second_brain_gui/src/view_agent_running.rs +++ b/paddler_second_brain_gui/src/view_agent_running.rs @@ -1,51 +1,147 @@ use iced::Element; use iced::widget::button; use iced::widget::column; +use iced::widget::container; use iced::widget::progress_bar; +use iced::widget::row; +use iced::widget::svg; use iced::widget::text; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; use crate::agent_running_data::AgentRunningData; +use crate::font::BOLD; +use crate::font::REGULAR; use crate::message::Message; +use crate::style_agent_container::style_agent_container; +use crate::style_button_danger::style_button_danger; +use crate::style_download_progress_bar::style_download_progress_bar; +use crate::variables::FONT_SIZE_L2; +use crate::variables::SPACING_2X; +use crate::variables::SPACING_BASE; +use crate::variables::SPACING_HALF; + +fn display_last_path_part(path: &str) -> String { + path.split('/').last().unwrap_or(path).to_string() +} pub fn view_agent_running<'content>( data: &'content AgentRunningData, ) -> Element<'content, Message> { - let mut content = column![button("Disconnect").on_press(Message::Disconnect),].spacing(10); + let stop_icon = svg(svg::Handle::from_memory( + include_bytes!("../../resources/icons/stop.svg").as_slice(), + )) + .width(16) + .height(16); + + let disconnect_button = button( + row![stop_icon, text("Disconnect").font(BOLD)] + .spacing(SPACING_HALF) + .align_y(iced::Center), + ) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_danger) + .on_press(Message::Disconnect); + + let mut name_row = row![container(text(data.agent_name.clone()).font(BOLD)).width(iced::Fill),]; + let mut status_row_left = column![].spacing(SPACING_HALF); + + let mut slots_label: Option = None; match &data.status { - None => { - content = content.push(text("Connecting to cluster...")); - } + None => {} Some(status) => { - if status.download_total > 0 && status.download_current < status.download_total { - let percentage = - (status.download_current as f32 / status.download_total as f32) * 100.0; - let download_label = match &status.download_filename { - Some(filename) => format!("Downloading {filename} ({percentage:.0}%)"), - None => format!("Downloading model ({percentage:.0}%)"), - }; + let is_downloading = + status.download_total > 0 && status.download_current < status.download_total; - content = content.push(text(download_label)); - content = content.push(progress_bar( - 0.0..=status.download_total as f32, - status.download_current as f32, - )); - } else if status.model_path.is_some() { - let status_label = format!( - "Ready ({}/{} slots busy)", - status.slots_processing, status.slots_total, + if is_downloading { + name_row = name_row.push( + progress_bar( + 0.0..=status.download_total as f32, + status.download_current as f32, + ) + .girth(12) + .style(style_download_progress_bar), ); - - content = content.push(text(status_label)); } else { - content = content.push(text("Waiting for model...")); + let model_label = match &status.model_path { + Some(path) => display_last_path_part(path), + None => "No model loaded".to_string(), + }; + + name_row = name_row.push(text(model_label).font(REGULAR)); } + let status_label = if is_downloading { + let percentage = + (status.download_current as f32 / status.download_total as f32) * 100.0; + + format!("Downloading ({percentage:.0}%)") + } else if status.model_path.is_none() { + "Waiting for model...".to_string() + } else { + match &status.state_application_status { + AgentStateApplicationStatus::Applied => "OK".to_string(), + AgentStateApplicationStatus::Fresh => "Pending".to_string(), + AgentStateApplicationStatus::AttemptedAndRetrying => "Retrying".to_string(), + AgentStateApplicationStatus::Stuck => "Retrying, but seems stuck?".to_string(), + AgentStateApplicationStatus::AttemptedAndNotAppliable => { + "Needs your help".to_string() + } + } + }; + + status_row_left = + status_row_left.push(text(format!("Status: {status_label}")).font(REGULAR)); + if !status.issues.is_empty() { - content = content.push(text(format!("{} issues", status.issues.len()))); + status_row_left = status_row_left + .push(text(format!("{} issues", status.issues.len())).font(REGULAR)); } + + slots_label = Some(format!( + "{}/{}/{}", + status.slots_processing, status.slots_total, status.desired_slots_total, + )); } } - content.into() + let mut status_row_content = row![container(status_row_left).width(iced::Fill),]; + + if let Some(label) = slots_label { + status_row_content = status_row_content.push(text(format!("Slots: {label}")).font(REGULAR)); + } + + let card_content = column![name_row, status_row_content,].spacing(SPACING_BASE); + + let agent_card = container(card_content) + .width(iced::Fill) + .padding(SPACING_BASE) + .style(style_agent_container); + + let connection_status = match &data.status { + None => text("Connecting to the balancer...").font(REGULAR), + Some(_) => text(format!( + "Connected to the balancer at {}", + data.cluster_address + )) + .font(REGULAR), + }; + + let status_row = container( + row![ + container(connection_status).width(iced::Fill), + disconnect_button, + ] + .align_y(iced::Center), + ) + .padding([0.0, SPACING_BASE]); + + column![ + container(text("Your agent").size(FONT_SIZE_L2).font(BOLD)).padding([0.0, SPACING_BASE]), + container(text("Agent details").font(BOLD)).padding([0.0, SPACING_BASE]), + agent_card, + status_row, + ] + .spacing(SPACING_2X) + .into() } From f6940ce2c6e7a81a20ea8c4468432c2d3c89bae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 19 Mar 2026 03:30:00 +0100 Subject: [PATCH 18/46] make agent box reusable, fix inline imports issues --- .../src/agent_monitor_service.rs | 38 +++---- .../src/agent_running_data.rs | 25 ++++- paddler_second_brain_gui/src/main.rs | 1 + .../src/running_cluster_data.rs | 4 +- paddler_second_brain_gui/src/screen.rs | 23 +++- paddler_second_brain_gui/src/second_brain.rs | 39 ++++--- .../src/start_balancer.rs | 5 +- .../src/start_balancer_services.rs | 5 +- .../src/style_agent_container.rs | 3 +- .../src/style_button_danger.rs | 3 +- .../src/style_button_primary.rs | 3 +- .../src/style_card_container.rs | 3 +- .../src/style_download_progress_bar.rs | 3 +- .../src/style_field_container.rs | 3 +- .../src/style_field_pick_list.rs | 3 +- .../src/style_field_pick_list_menu.rs | 3 +- .../src/style_field_text_input.rs | 6 +- .../src/view_agent_card.rs | 94 ++++++++++++++++ .../src/view_agent_running.rs | 106 ++---------------- .../src/view_join_cluster_config.rs | 3 +- .../src/view_running_cluster.rs | 75 ++++++------- .../src/view_start_cluster_config.rs | 6 +- 22 files changed, 256 insertions(+), 198 deletions(-) create mode 100644 paddler_second_brain_gui/src/view_agent_card.rs diff --git a/paddler_second_brain_gui/src/agent_monitor_service.rs b/paddler_second_brain_gui/src/agent_monitor_service.rs index 4aee698e..c7e98600 100644 --- a/paddler_second_brain_gui/src/agent_monitor_service.rs +++ b/paddler_second_brain_gui/src/agent_monitor_service.rs @@ -3,31 +3,29 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler::produces_snapshot::ProducesSnapshot; use paddler::service::Service; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use tokio::sync::broadcast; use tokio::sync::mpsc; pub struct AgentMonitorService { pub agent_controller_pool: Arc, - pub agent_names_tx: mpsc::UnboundedSender>, + pub agent_snapshots_tx: mpsc::UnboundedSender>, } -fn collect_agent_names(pool: &AgentControllerPool) -> Vec { - let mut names: Vec = pool - .agents - .iter() - .map(|entry| { - entry - .value() - .name - .clone() - .unwrap_or_else(|| entry.key().clone()) - }) - .collect(); - - names.sort(); - - names +fn collect_agent_snapshots(pool: &AgentControllerPool) -> Result> { + let pool_snapshot = pool.make_snapshot()?; + let mut agents = pool_snapshot.agents; + + agents.sort_by(|left, right| { + let left_name = left.name.as_deref().unwrap_or(&left.id); + let right_name = right.name.as_deref().unwrap_or(&right.id); + + left_name.cmp(right_name) + }); + + Ok(agents) } #[async_trait] @@ -38,10 +36,10 @@ impl Service for AgentMonitorService { async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { loop { - let names = collect_agent_names(&self.agent_controller_pool); + let snapshots = collect_agent_snapshots(&self.agent_controller_pool)?; - if let Err(send_error) = self.agent_names_tx.send(names) { - log::warn!("Agent names receiver dropped: {send_error}"); + if let Err(send_error) = self.agent_snapshots_tx.send(snapshots) { + log::warn!("Agent snapshots receiver dropped: {send_error}"); break; } diff --git a/paddler_second_brain_gui/src/agent_running_data.rs b/paddler_second_brain_gui/src/agent_running_data.rs index a151cc79..454ac4aa 100644 --- a/paddler_second_brain_gui/src/agent_running_data.rs +++ b/paddler_second_brain_gui/src/agent_running_data.rs @@ -1,7 +1,28 @@ +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; pub struct AgentRunningData { - pub agent_name: String, pub cluster_address: String, - pub status: Option, + pub connected: bool, + pub snapshot: AgentControllerSnapshot, +} + +impl AgentRunningData { + pub fn apply_status(&mut self, status: SlotAggregatedStatusSnapshot) { + self.connected = true; + self.snapshot = AgentControllerSnapshot { + desired_slots_total: status.desired_slots_total, + download_current: status.download_current, + download_filename: status.download_filename, + download_total: status.download_total, + id: String::new(), + issues: status.issues, + model_path: status.model_path, + name: self.snapshot.name.clone(), + slots_processing: status.slots_processing, + slots_total: status.slots_total, + state_application_status: status.state_application_status, + uses_chat_template_override: status.uses_chat_template_override, + }; + } } diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index aaea5a7c..e897d1a2 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -26,6 +26,7 @@ mod style_field_pick_list; mod style_field_pick_list_menu; mod style_field_text_input; mod variables; +mod view_agent_card; mod view_agent_running; mod view_home; mod view_join_cluster_config; diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_second_brain_gui/src/running_cluster_data.rs index 021ce2fb..b70d90d9 100644 --- a/paddler_second_brain_gui/src/running_cluster_data.rs +++ b/paddler_second_brain_gui/src/running_cluster_data.rs @@ -1,5 +1,7 @@ +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + pub struct RunningClusterData { - pub agent_names: Vec, + pub agent_snapshots: Vec, pub cluster_address: String, pub stopping: bool, } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index a3116ad3..808044ed 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -1,3 +1,7 @@ +use std::collections::BTreeSet; + +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; use statum::machine; use statum::state; use statum::transition; @@ -50,9 +54,22 @@ impl Screen { pub fn connect(self) -> Screen { self.transition_map(|config_data| AgentRunningData { - agent_name: config_data.agent_name.clone(), cluster_address: config_data.cluster_address.clone(), - status: None, + connected: false, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: String::new(), + issues: BTreeSet::new(), + model_path: None, + name: Some(config_data.agent_name.clone()), + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, }) } } @@ -76,7 +93,7 @@ impl Screen { pub fn cluster_started(self) -> Screen { self.transition_map(|config_data| RunningClusterData { - agent_names: vec![], + agent_snapshots: vec![], cluster_address: config_data.balancer_address.clone(), stopping: false, }) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 9364de4b..aff45487 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -1,4 +1,5 @@ use std::mem; +use std::net::SocketAddr; use std::time::Duration; use iced::Center; @@ -7,6 +8,7 @@ use iced::Subscription; use iced::Task; use iced::time; use iced::widget::column; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -32,7 +34,7 @@ fn drain_latest(receiver: &mut mpsc::UnboundedReceiver) -> Optio } pub struct SecondBrain { - agent_names_rx: Option>>, + agent_snapshots_rx: Option>>, agent_shutdown_tx: Option>, agent_status_rx: Option>, screen: CurrentScreen, @@ -58,7 +60,7 @@ impl Drop for SecondBrain { impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { - agent_names_rx: None, + agent_snapshots_rx: None, agent_shutdown_tx: None, agent_status_rx: None, screen: CurrentScreen::default(), @@ -147,6 +149,12 @@ impl SecondBrain { ) } (CurrentScreen::StartClusterConfig(config), Message::Cancel) => { + if let Some(shutdown_tx) = self.shutdown_tx.take() + && let Err(unsent_signal) = shutdown_tx.send(()) + { + log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); + } + self.agent_snapshots_rx = None; self.screen = CurrentScreen::Home(config.cancel()); Task::none() @@ -179,10 +187,7 @@ impl SecondBrain { Task::none() } (CurrentScreen::StartClusterConfig(mut config), Message::Confirm) => { - let management_addr = match config - .state_data - .balancer_address - .parse::() + let management_addr = match config.state_data.balancer_address.parse::() { Ok(addr) => addr, Err(parse_error) => { @@ -194,10 +199,7 @@ impl SecondBrain { } }; - let inference_addr = match config - .state_data - .inference_address - .parse::() + let inference_addr = match config.state_data.inference_address.parse::() { Ok(addr) => addr, Err(parse_error) => { @@ -217,9 +219,10 @@ impl SecondBrain { .unwrap_or_default(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let (agent_names_tx, agent_names_rx) = mpsc::unbounded_channel::>(); + let (agent_snapshots_tx, agent_snapshots_rx) = + mpsc::unbounded_channel::>(); - self.agent_names_rx = Some(agent_names_rx); + self.agent_snapshots_rx = Some(agent_snapshots_rx); self.shutdown_tx = Some(shutdown_tx); config.state_data.starting = true; self.screen = CurrentScreen::StartClusterConfig(config); @@ -230,7 +233,7 @@ impl SecondBrain { management_addr, inference_addr, desired_state, - agent_names_tx, + agent_snapshots_tx, shutdown_rx, ), |result: Result<(), anyhow::Error>| match result { @@ -254,8 +257,8 @@ impl SecondBrain { Task::none() } (CurrentScreen::RunningCluster(mut running), Message::RefreshAgentCount) => { - if let Some(names) = self.agent_names_rx.as_mut().and_then(drain_latest) { - running.state_data.agent_names = names; + if let Some(snapshots) = self.agent_snapshots_rx.as_mut().and_then(drain_latest) { + running.state_data.agent_snapshots = snapshots; } self.screen = CurrentScreen::RunningCluster(running); @@ -267,7 +270,7 @@ impl SecondBrain { { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } - self.agent_names_rx = None; + self.agent_snapshots_rx = None; running.state_data.stopping = true; self.screen = CurrentScreen::RunningCluster(running); @@ -280,7 +283,7 @@ impl SecondBrain { } (CurrentScreen::RunningCluster(running), Message::ClusterFailed(error)) => { log::error!("Cluster failed unexpectedly: {error}"); - self.agent_names_rx = None; + self.agent_snapshots_rx = None; self.shutdown_tx = None; self.screen = CurrentScreen::Home(running.cluster_failed()); @@ -288,7 +291,7 @@ impl SecondBrain { } (CurrentScreen::AgentRunning(mut running), Message::RefreshAgentStatus) => { if let Some(status) = self.agent_status_rx.as_mut().and_then(drain_latest) { - running.state_data.status = Some(status); + running.state_data.apply_status(status); } self.screen = CurrentScreen::AgentRunning(running); diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs index 1c65a0d6..c3fb7209 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -1,5 +1,6 @@ use std::net::SocketAddr; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -10,7 +11,7 @@ pub async fn start_balancer( management_addr: SocketAddr, inference_addr: SocketAddr, initial_desired_state: BalancerDesiredState, - agent_names_tx: mpsc::UnboundedSender>, + agent_snapshots_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { let (result_tx, result_rx) = oneshot::channel(); @@ -21,7 +22,7 @@ pub async fn start_balancer( management_addr, inference_addr, initial_desired_state, - agent_names_tx, + agent_snapshots_tx, shutdown_rx, )); if let Err(unsent_result) = result_tx.send(result) { diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs index 136b29d1..7b43ce0b 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -17,6 +17,7 @@ use paddler::balancer::state_database::Memory; use paddler::balancer::state_database::StateDatabase; use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; use paddler::service_manager::ServiceManager; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::broadcast; use tokio::sync::mpsc; @@ -28,7 +29,7 @@ pub async fn start_balancer_services( management_addr: SocketAddr, inference_addr: SocketAddr, initial_desired_state: BalancerDesiredState, - agent_names_tx: mpsc::UnboundedSender>, + agent_snapshots_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); @@ -85,7 +86,7 @@ pub async fn start_balancer_services( service_manager.add_service(AgentMonitorService { agent_controller_pool: agent_controller_pool.clone(), - agent_names_tx, + agent_snapshots_tx, }); service_manager.add_service(ReconciliationService { diff --git a/paddler_second_brain_gui/src/style_agent_container.rs b/paddler_second_brain_gui/src/style_agent_container.rs index 8b272da8..55f85b3d 100644 --- a/paddler_second_brain_gui/src/style_agent_container.rs +++ b/paddler_second_brain_gui/src/style_agent_container.rs @@ -1,11 +1,12 @@ use iced::Background; use iced::Border; +use iced::Theme; use iced::widget::container; use crate::variables::COLOR_AGENT_BACKGROUND; use crate::variables::COLOR_BORDER; -pub fn style_agent_container(theme: &iced::Theme) -> container::Style { +pub fn style_agent_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); container::Style { diff --git a/paddler_second_brain_gui/src/style_button_danger.rs b/paddler_second_brain_gui/src/style_button_danger.rs index 2438a1cf..0fe41057 100644 --- a/paddler_second_brain_gui/src/style_button_danger.rs +++ b/paddler_second_brain_gui/src/style_button_danger.rs @@ -1,9 +1,10 @@ use iced::Background; use iced::Border; use iced::Color; +use iced::Theme; use iced::widget::button; -pub fn style_button_danger(theme: &iced::Theme, status: button::Status) -> button::Style { +pub fn style_button_danger(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); let background_color = Color::from_rgb( diff --git a/paddler_second_brain_gui/src/style_button_primary.rs b/paddler_second_brain_gui/src/style_button_primary.rs index e10d9480..5888b187 100644 --- a/paddler_second_brain_gui/src/style_button_primary.rs +++ b/paddler_second_brain_gui/src/style_button_primary.rs @@ -1,11 +1,12 @@ use iced::Background; use iced::Border; +use iced::Theme; use iced::widget::button; use crate::variables::COLOR_BODY_BACKGROUND; use crate::variables::COLOR_BORDER; -pub fn style_button_primary(theme: &iced::Theme, status: button::Status) -> button::Style { +pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); button::Style { diff --git a/paddler_second_brain_gui/src/style_card_container.rs b/paddler_second_brain_gui/src/style_card_container.rs index 4130e3b0..a15daf4a 100644 --- a/paddler_second_brain_gui/src/style_card_container.rs +++ b/paddler_second_brain_gui/src/style_card_container.rs @@ -1,11 +1,12 @@ use iced::Background; use iced::Border; +use iced::Theme; use iced::widget::container; use crate::variables::COLOR_BODY_BACKGROUND; use crate::variables::COLOR_BORDER; -pub fn style_card_container(theme: &iced::Theme) -> container::Style { +pub fn style_card_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); container::Style { diff --git a/paddler_second_brain_gui/src/style_download_progress_bar.rs b/paddler_second_brain_gui/src/style_download_progress_bar.rs index 67bf3674..54f95b16 100644 --- a/paddler_second_brain_gui/src/style_download_progress_bar.rs +++ b/paddler_second_brain_gui/src/style_download_progress_bar.rs @@ -1,11 +1,12 @@ use iced::Background; use iced::Border; +use iced::Theme; use iced::widget::progress_bar; use crate::variables::COLOR_BODY_BACKGROUND; use crate::variables::COLOR_BORDER; -pub fn style_download_progress_bar(_theme: &iced::Theme) -> progress_bar::Style { +pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { progress_bar::Style { background: Background::Color(COLOR_BODY_BACKGROUND), bar: Background::Color(COLOR_BORDER), diff --git a/paddler_second_brain_gui/src/style_field_container.rs b/paddler_second_brain_gui/src/style_field_container.rs index b045a16d..c346a9c6 100644 --- a/paddler_second_brain_gui/src/style_field_container.rs +++ b/paddler_second_brain_gui/src/style_field_container.rs @@ -1,10 +1,11 @@ use iced::Shadow; +use iced::Theme; use iced::Vector; use iced::widget::container; use crate::variables::COLOR_BORDER; -pub fn style_field_container(theme: &iced::Theme) -> container::Style { +pub fn style_field_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); container::Style { diff --git a/paddler_second_brain_gui/src/style_field_pick_list.rs b/paddler_second_brain_gui/src/style_field_pick_list.rs index 7d827d3e..a35525d0 100644 --- a/paddler_second_brain_gui/src/style_field_pick_list.rs +++ b/paddler_second_brain_gui/src/style_field_pick_list.rs @@ -1,12 +1,13 @@ use iced::Background; use iced::Border; +use iced::Theme; use iced::widget::pick_list; use crate::variables::COLOR_BODY_BACKGROUND; use crate::variables::COLOR_BODY_FONT; use crate::variables::COLOR_BORDER; -pub fn style_field_pick_list(theme: &iced::Theme, status: pick_list::Status) -> pick_list::Style { +pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_list::Style { let base = pick_list::default(theme, status); pick_list::Style { diff --git a/paddler_second_brain_gui/src/style_field_pick_list_menu.rs b/paddler_second_brain_gui/src/style_field_pick_list_menu.rs index 61ba7aea..a2b23891 100644 --- a/paddler_second_brain_gui/src/style_field_pick_list_menu.rs +++ b/paddler_second_brain_gui/src/style_field_pick_list_menu.rs @@ -1,13 +1,14 @@ use iced::Background; use iced::Border; use iced::Color; +use iced::Theme; use iced::overlay::menu; use crate::variables::COLOR_BODY_BACKGROUND; use crate::variables::COLOR_BODY_FONT; use crate::variables::COLOR_BORDER; -pub fn style_field_pick_list_menu(theme: &iced::Theme) -> menu::Style { +pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { let base = menu::default(theme); menu::Style { diff --git a/paddler_second_brain_gui/src/style_field_text_input.rs b/paddler_second_brain_gui/src/style_field_text_input.rs index 2fa00f2f..4dfab39b 100644 --- a/paddler_second_brain_gui/src/style_field_text_input.rs +++ b/paddler_second_brain_gui/src/style_field_text_input.rs @@ -1,15 +1,13 @@ use iced::Background; use iced::Border; +use iced::Theme; use iced::widget::text_input; use crate::variables::COLOR_BODY_BACKGROUND; use crate::variables::COLOR_BODY_FONT; use crate::variables::COLOR_BORDER; -pub fn style_field_text_input( - theme: &iced::Theme, - status: text_input::Status, -) -> text_input::Style { +pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text_input::Style { let base = text_input::default(theme, status); text_input::Style { diff --git a/paddler_second_brain_gui/src/view_agent_card.rs b/paddler_second_brain_gui/src/view_agent_card.rs new file mode 100644 index 00000000..3ee36b97 --- /dev/null +++ b/paddler_second_brain_gui/src/view_agent_card.rs @@ -0,0 +1,94 @@ +use iced::Element; +use iced::Fill; +use iced::widget::column; +use iced::widget::container; +use iced::widget::progress_bar; +use iced::widget::row; +use iced::widget::text; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +use crate::font::BOLD; +use crate::font::REGULAR; +use crate::message::Message; +use crate::style_agent_container::style_agent_container; +use crate::style_download_progress_bar::style_download_progress_bar; +use crate::variables::SPACING_BASE; +use crate::variables::SPACING_HALF; + +fn display_last_path_part(path: &str) -> String { + path.split('/').next_back().unwrap_or(path).to_string() +} + +pub fn view_agent_card<'snapshot>( + snapshot: &'snapshot AgentControllerSnapshot, +) -> Element<'snapshot, Message> { + let agent_name = snapshot.name.as_deref().unwrap_or(&snapshot.id); + + let is_downloading = + snapshot.download_total > 0 && snapshot.download_current < snapshot.download_total; + + let mut name_row = row![container(text(agent_name.to_string()).font(BOLD)).width(Fill),]; + + if is_downloading { + name_row = name_row.push( + progress_bar( + 0.0..=snapshot.download_total as f32, + snapshot.download_current as f32, + ) + .girth(12) + .style(style_download_progress_bar), + ); + } else { + let model_label = match &snapshot.model_path { + Some(path) => display_last_path_part(path), + None => "No model loaded".to_string(), + }; + + name_row = name_row.push(text(model_label).font(REGULAR)); + } + + let status_label = if is_downloading { + let percentage = + (snapshot.download_current as f32 / snapshot.download_total as f32) * 100.0; + + format!("Downloading ({percentage:.0}%)") + } else if snapshot.model_path.is_none() { + "Waiting for model...".to_string() + } else { + match &snapshot.state_application_status { + AgentStateApplicationStatus::Applied => "OK".to_string(), + AgentStateApplicationStatus::Fresh => "Pending".to_string(), + AgentStateApplicationStatus::AttemptedAndRetrying => "Retrying".to_string(), + AgentStateApplicationStatus::Stuck => "Retrying, but seems stuck?".to_string(), + AgentStateApplicationStatus::AttemptedAndNotAppliable => "Needs your help".to_string(), + } + }; + + let mut status_row_left = column![].spacing(SPACING_HALF); + + status_row_left = status_row_left.push(text(format!("Status: {status_label}")).font(REGULAR)); + + if !snapshot.issues.is_empty() { + status_row_left = + status_row_left.push(text(format!("{} issues", snapshot.issues.len())).font(REGULAR)); + } + + let slots_label = format!( + "{}/{}/{}", + snapshot.slots_processing, snapshot.slots_total, snapshot.desired_slots_total, + ); + + let status_row_content = row![ + container(status_row_left).width(Fill), + text(format!("Slots: {slots_label}")).font(REGULAR), + ]; + + let card_content = column![name_row, status_row_content,].spacing(SPACING_BASE); + + container(card_content) + .width(Fill) + .padding(SPACING_BASE) + .style(style_agent_container) + .into() +} diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/view_agent_running.rs index 30c88d0c..246db215 100644 --- a/paddler_second_brain_gui/src/view_agent_running.rs +++ b/paddler_second_brain_gui/src/view_agent_running.rs @@ -1,28 +1,23 @@ +use iced::Center; use iced::Element; +use iced::Fill; use iced::widget::button; use iced::widget::column; use iced::widget::container; -use iced::widget::progress_bar; use iced::widget::row; use iced::widget::svg; use iced::widget::text; -use paddler_types::agent_state_application_status::AgentStateApplicationStatus; use crate::agent_running_data::AgentRunningData; use crate::font::BOLD; use crate::font::REGULAR; use crate::message::Message; -use crate::style_agent_container::style_agent_container; use crate::style_button_danger::style_button_danger; -use crate::style_download_progress_bar::style_download_progress_bar; use crate::variables::FONT_SIZE_L2; use crate::variables::SPACING_2X; use crate::variables::SPACING_BASE; use crate::variables::SPACING_HALF; - -fn display_last_path_part(path: &str) -> String { - path.split('/').last().unwrap_or(path).to_string() -} +use crate::view_agent_card::view_agent_card; pub fn view_agent_running<'content>( data: &'content AgentRunningData, @@ -36,110 +31,31 @@ pub fn view_agent_running<'content>( let disconnect_button = button( row![stop_icon, text("Disconnect").font(BOLD)] .spacing(SPACING_HALF) - .align_y(iced::Center), + .align_y(Center), ) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_danger) .on_press(Message::Disconnect); - let mut name_row = row![container(text(data.agent_name.clone()).font(BOLD)).width(iced::Fill),]; - - let mut status_row_left = column![].spacing(SPACING_HALF); - - let mut slots_label: Option = None; - match &data.status { - None => {} - Some(status) => { - let is_downloading = - status.download_total > 0 && status.download_current < status.download_total; - - if is_downloading { - name_row = name_row.push( - progress_bar( - 0.0..=status.download_total as f32, - status.download_current as f32, - ) - .girth(12) - .style(style_download_progress_bar), - ); - } else { - let model_label = match &status.model_path { - Some(path) => display_last_path_part(path), - None => "No model loaded".to_string(), - }; - - name_row = name_row.push(text(model_label).font(REGULAR)); - } - - let status_label = if is_downloading { - let percentage = - (status.download_current as f32 / status.download_total as f32) * 100.0; - - format!("Downloading ({percentage:.0}%)") - } else if status.model_path.is_none() { - "Waiting for model...".to_string() - } else { - match &status.state_application_status { - AgentStateApplicationStatus::Applied => "OK".to_string(), - AgentStateApplicationStatus::Fresh => "Pending".to_string(), - AgentStateApplicationStatus::AttemptedAndRetrying => "Retrying".to_string(), - AgentStateApplicationStatus::Stuck => "Retrying, but seems stuck?".to_string(), - AgentStateApplicationStatus::AttemptedAndNotAppliable => { - "Needs your help".to_string() - } - } - }; - - status_row_left = - status_row_left.push(text(format!("Status: {status_label}")).font(REGULAR)); - - if !status.issues.is_empty() { - status_row_left = status_row_left - .push(text(format!("{} issues", status.issues.len())).font(REGULAR)); - } - - slots_label = Some(format!( - "{}/{}/{}", - status.slots_processing, status.slots_total, status.desired_slots_total, - )); - } - } - - let mut status_row_content = row![container(status_row_left).width(iced::Fill),]; - - if let Some(label) = slots_label { - status_row_content = status_row_content.push(text(format!("Slots: {label}")).font(REGULAR)); - } - - let card_content = column![name_row, status_row_content,].spacing(SPACING_BASE); - - let agent_card = container(card_content) - .width(iced::Fill) - .padding(SPACING_BASE) - .style(style_agent_container); - - let connection_status = match &data.status { - None => text("Connecting to the balancer...").font(REGULAR), - Some(_) => text(format!( + let connection_status = if data.connected { + text(format!( "Connected to the balancer at {}", data.cluster_address )) - .font(REGULAR), + .font(REGULAR) + } else { + text("Connecting to the balancer...").font(REGULAR) }; let status_row = container( - row![ - container(connection_status).width(iced::Fill), - disconnect_button, - ] - .align_y(iced::Center), + row![container(connection_status).width(Fill), disconnect_button,].align_y(Center), ) .padding([0.0, SPACING_BASE]); column![ container(text("Your agent").size(FONT_SIZE_L2).font(BOLD)).padding([0.0, SPACING_BASE]), container(text("Agent details").font(BOLD)).padding([0.0, SPACING_BASE]), - agent_card, + view_agent_card(&data.snapshot), status_row, ] .spacing(SPACING_2X) diff --git a/paddler_second_brain_gui/src/view_join_cluster_config.rs b/paddler_second_brain_gui/src/view_join_cluster_config.rs index 00667d78..5a8c9f15 100644 --- a/paddler_second_brain_gui/src/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_join_cluster_config.rs @@ -1,5 +1,6 @@ use iced::Center; use iced::Element; +use iced::alignment::Horizontal; use iced::widget::button; use iced::widget::column; use iced::widget::container; @@ -88,7 +89,7 @@ pub fn view_join_cluster_config<'content>( .spacing(SPACING_BASE), ) .width(300) - .align_x(iced::alignment::Horizontal::Right), + .align_x(Horizontal::Right), ] .spacing(SPACING_2X); diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index f0b8a6f6..c8985339 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -1,7 +1,10 @@ use iced::Background; use iced::Border; +use iced::Center; use iced::Color; use iced::Element; +use iced::Fill; +use iced::Theme; use iced::widget::button; use iced::widget::column; use iced::widget::container; @@ -13,13 +16,13 @@ use crate::font::BOLD; use crate::font::REGULAR; use crate::message::Message; use crate::running_cluster_data::RunningClusterData; -use crate::style_agent_container::style_agent_container; use crate::style_button_danger::style_button_danger; use crate::style_card_container::style_card_container; use crate::variables::FONT_SIZE_L2; use crate::variables::SPACING_2X; use crate::variables::SPACING_BASE; use crate::variables::SPACING_HALF; +use crate::view_agent_card::view_agent_card; pub fn view_running_cluster<'content>( data: &'content RunningClusterData, @@ -33,16 +36,16 @@ pub fn view_running_cluster<'content>( let address_row = container( row![ container(text(format!("Balancer address: {}", data.cluster_address)).font(REGULAR)) - .width(iced::Fill), + .width(Fill), button( row![copy_icon, text("Copy address").font(BOLD)] .spacing(SPACING_BASE / 2.0) - .align_y(iced::Center), + .align_y(Center), ) .style(button::text) .on_press(Message::CopyToClipboard(data.cluster_address.clone())), ] - .align_y(iced::Center) + .align_y(Center) .padding(SPACING_BASE), ) .style(style_card_container); @@ -53,36 +56,33 @@ pub fn view_running_cluster<'content>( .width(16) .height(16); - let status_indicator = container("") - .width(16) - .height(16) - .style(|theme: &iced::Theme| { - let base = container::transparent(theme); + let status_indicator = container("").width(16).height(16).style(|theme: &Theme| { + let base = container::transparent(theme); - container::Style { - background: Some(Background::Color(Color::from_rgb( - 0xEE as f32 / 255.0, - 0xFF as f32 / 255.0, - 0xEE as f32 / 255.0, - ))), - border: Border { - color: Color::from_rgb( - 0xCC as f32 / 255.0, - 0xDD as f32 / 255.0, - 0xCC as f32 / 255.0, - ), - width: 2.0, - radius: 8.into(), - }, - ..base - } - }); + container::Style { + background: Some(Background::Color(Color::from_rgb( + 0xEE as f32 / 255.0, + 0xFF as f32 / 255.0, + 0xEE as f32 / 255.0, + ))), + border: Border { + color: Color::from_rgb( + 0xCC as f32 / 255.0, + 0xDD as f32 / 255.0, + 0xCC as f32 / 255.0, + ), + width: 2.0, + radius: 8.into(), + }, + ..base + } + }); let stop_button = if data.stopping { button( row![stop_icon, text("Stopping...").font(BOLD)] .spacing(SPACING_HALF) - .align_y(iced::Center), + .align_y(Center), ) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_danger) @@ -90,7 +90,7 @@ pub fn view_running_cluster<'content>( button( row![stop_icon, text("Stop cluster").font(BOLD)] .spacing(SPACING_HALF) - .align_y(iced::Center), + .align_y(Center), ) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_danger) @@ -102,12 +102,12 @@ pub fn view_running_cluster<'content>( container( row![text("Cluster is running").font(REGULAR), status_indicator,] .spacing(SPACING_HALF) - .align_y(iced::Center), + .align_y(Center), ) - .width(iced::Fill), + .width(Fill), stop_button, ] - .align_y(iced::Center), + .align_y(Center), ) .padding([0.0, SPACING_BASE]); @@ -119,19 +119,14 @@ pub fn view_running_cluster<'content>( ] .spacing(SPACING_2X); - if data.agent_names.is_empty() { + if data.agent_snapshots.is_empty() { content = content.push( container(text("Waiting for agents to connect...").font(REGULAR)) .padding([0.0, SPACING_BASE]), ); } else { - for agent_name in &data.agent_names { - content = content.push( - container(text(agent_name.clone()).font(REGULAR)) - .width(iced::Fill) - .padding(SPACING_BASE) - .style(style_agent_container), - ); + for agent_snapshot in &data.agent_snapshots { + content = content.push(view_agent_card(agent_snapshot)); } } diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index fe0ece9f..b1ce3d3e 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -1,5 +1,7 @@ use iced::Center; use iced::Element; +use iced::Fill; +use iced::alignment::Horizontal; use iced::widget::button; use iced::widget::column; use iced::widget::container; @@ -84,7 +86,7 @@ pub fn view_start_cluster_config<'content>( data.selected_model.as_ref(), Message::SelectModel, ) - .width(iced::Fill) + .width(Fill) .padding(SPACING_BASE) .style(style_field_pick_list) .menu_style(style_field_pick_list_menu), @@ -99,7 +101,7 @@ pub fn view_start_cluster_config<'content>( .spacing(SPACING_BASE), ) .width(300) - .align_x(iced::alignment::Horizontal::Right), + .align_x(Horizontal::Right), ] .spacing(SPACING_2X); From 61a7ad88ac98559aba7b9c66d1bd990dc06ba009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 19 Mar 2026 05:02:43 +0100 Subject: [PATCH 19/46] styling updates, do not allow to start balancer and inference address on the same ports --- Cargo.lock | 18 ++++ Cargo.toml | 2 +- .../src/detect_network_interfaces.rs | 7 +- paddler_second_brain_gui/src/main.rs | 3 + paddler_second_brain_gui/src/screen.rs | 9 +- paddler_second_brain_gui/src/second_brain.rs | 86 ++++++++++++++---- paddler_second_brain_gui/src/start_agent.rs | 2 +- .../src/start_agent_services.rs | 6 +- .../src/start_cluster_config_data.rs | 3 +- paddler_second_brain_gui/src/variables.rs | 6 ++ .../src/view_agent_running.rs | 3 +- paddler_second_brain_gui/src/view_home.rs | 61 ++++++++++++- .../src/view_join_cluster_config.rs | 2 +- .../src/view_running_cluster.rs | 5 +- .../src/view_start_cluster_config.rs | 79 +++++++++------- resources/images/create_a_cluster.png | Bin 0 -> 3468 bytes resources/images/join_a_cluster.png | Bin 0 -> 4700 bytes 17 files changed, 219 insertions(+), 73 deletions(-) create mode 100644 resources/images/create_a_cluster.png create mode 100644 resources/images/join_a_cluster.png diff --git a/Cargo.lock b/Cargo.lock index 87fa39d7..34b0b702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2668,6 +2668,7 @@ dependencies = [ "iced_runtime", "iced_widget", "iced_winit", + "image", "thiserror 2.0.18", ] @@ -2727,6 +2728,8 @@ dependencies = [ "half", "iced_core", "iced_futures", + "image", + "kamadak-exif", "log", "raw-window-handle", "rustc-hash 2.1.1", @@ -3236,6 +3239,15 @@ dependencies = [ "uuid-simd", ] +[[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" @@ -3670,6 +3682,12 @@ dependencies = [ "zbus", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "naga" version = "27.0.3" diff --git a/Cargo.toml b/Cargo.toml index 9d5447bb..4ffe4f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ serial_test = { version = "3", features = ["file_locks"] } serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" -iced = { version = "0.14", features = ["svg", "tokio"] } +iced = { version = "0.14", features = ["image", "svg", "tokio"] } if-addrs = "0.13" statum = "0.6" tempfile = "3.20.0" diff --git a/paddler_second_brain_gui/src/detect_network_interfaces.rs b/paddler_second_brain_gui/src/detect_network_interfaces.rs index 9bc1c390..c80a52ff 100644 --- a/paddler_second_brain_gui/src/detect_network_interfaces.rs +++ b/paddler_second_brain_gui/src/detect_network_interfaces.rs @@ -1,5 +1,3 @@ -use std::net::IpAddr; - use crate::network_interface_address::NetworkInterfaceAddress; pub fn detect_network_interfaces() -> Vec { @@ -15,10 +13,7 @@ pub fn detect_network_interfaces() -> Vec { interfaces .into_iter() .filter(|interface| !interface.is_loopback()) - .filter(|interface| match interface.ip() { - IpAddr::V4(ipv4) => ipv4.is_private(), - IpAddr::V6(_) => false, - }) + .filter(|interface| interface.ip().is_ipv4()) .map(|interface| { let ip_address = interface.ip(); diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index e897d1a2..24d57aec 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -33,6 +33,8 @@ mod view_join_cluster_config; mod view_running_cluster; mod view_start_cluster_config; +use iced::Size; + use second_brain::SecondBrain; fn main() -> iced::Result { @@ -45,6 +47,7 @@ fn main() -> iced::Result { .font(include_bytes!( "../../resources/fonts/JetBrainsMono-Bold.ttf" )) + .window_size(Size::new(800.0, 800.0)) .subscription(SecondBrain::subscription) .run() } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index 808044ed..f2aa36b8 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -38,8 +38,9 @@ impl Screen { self.transition_with(StartClusterConfigData { balancer_address: format!("{suggested_address}:8060"), - error: None, + balancer_address_error: None, inference_address: format!("{suggested_address}:8061"), + inference_address_error: None, selected_model: None, starting: false, }) @@ -64,7 +65,11 @@ impl Screen { id: String::new(), issues: BTreeSet::new(), model_path: None, - name: Some(config_data.agent_name.clone()), + name: if config_data.agent_name.is_empty() { + None + } else { + Some(config_data.agent_name.clone()) + }, slots_processing: 0, slots_total: 0, state_application_status: AgentStateApplicationStatus::Fresh, diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index aff45487..a211a89f 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -1,13 +1,16 @@ use std::mem; use std::net::SocketAddr; +use std::net::TcpStream; use std::time::Duration; use iced::Center; use iced::Element; +use iced::Fill; use iced::Subscription; use iced::Task; use iced::time; use iced::widget::column; +use iced::widget::container; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; use tokio::sync::mpsc; @@ -17,12 +20,18 @@ use crate::message::Message; use crate::screen_current::CurrentScreen; use crate::start_agent::start_agent; use crate::start_balancer::start_balancer; +use crate::variables::SPACING_2X; +use crate::variables::SPACING_BASE; use crate::view_agent_running::view_agent_running; use crate::view_home::view_home; use crate::view_join_cluster_config::view_join_cluster_config; use crate::view_running_cluster::view_running_cluster; use crate::view_start_cluster_config::view_start_cluster_config; +fn is_port_in_use(address: &SocketAddr) -> bool { + TcpStream::connect_timeout(address, Duration::from_millis(100)).is_ok() +} + fn drain_latest(receiver: &mut mpsc::UnboundedReceiver) -> Option { let mut latest = None; @@ -123,7 +132,11 @@ impl SecondBrain { } }; - let agent_name = config.state_data.agent_name.clone(); + let agent_name = if config.state_data.agent_name.is_empty() { + None + } else { + Some(config.state_data.agent_name.clone()) + }; let management_address = config.state_data.cluster_address.clone(); let (agent_shutdown_tx, agent_shutdown_rx) = oneshot::channel::<()>(); @@ -161,7 +174,8 @@ impl SecondBrain { } (CurrentScreen::StartClusterConfig(mut config), Message::SelectModel(preset)) => { config.state_data.selected_model = Some(preset); - config.state_data.error = None; + config.state_data.balancer_address_error = None; + config.state_data.inference_address_error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() @@ -171,7 +185,7 @@ impl SecondBrain { Message::SetBalancerAddress(address), ) => { config.state_data.balancer_address = address; - config.state_data.error = None; + config.state_data.balancer_address_error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() @@ -181,36 +195,67 @@ impl SecondBrain { Message::SetInferenceAddress(address), ) => { config.state_data.inference_address = address; - config.state_data.error = None; + config.state_data.inference_address_error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() } (CurrentScreen::StartClusterConfig(mut config), Message::Confirm) => { + config.state_data.balancer_address_error = None; + config.state_data.inference_address_error = None; + let management_addr = match config.state_data.balancer_address.parse::() { - Ok(addr) => addr, + Ok(addr) => Some(addr), Err(parse_error) => { - config.state_data.error = - Some(format!("Invalid balancer address: {parse_error}")); - self.screen = CurrentScreen::StartClusterConfig(config); - - return Task::none(); + config.state_data.balancer_address_error = + Some(format!("Invalid address: {parse_error}")); + None } }; let inference_addr = match config.state_data.inference_address.parse::() { - Ok(addr) => addr, + Ok(addr) => Some(addr), Err(parse_error) => { - config.state_data.error = - Some(format!("Invalid inference address: {parse_error}")); - self.screen = CurrentScreen::StartClusterConfig(config); + config.state_data.inference_address_error = + Some(format!("Invalid address: {parse_error}")); + None + } + }; - return Task::none(); + let management_addr = match management_addr { + Some(addr) if is_port_in_use(&addr) => { + config.state_data.balancer_address_error = Some(format!( + "Port {} is already in use", + addr.port() + )); + None + } + other => other, + }; + + let inference_addr = match inference_addr { + Some(addr) if is_port_in_use(&addr) => { + config.state_data.inference_address_error = Some(format!( + "Port {} is already in use", + addr.port() + )); + None } + other => other, }; + let (management_addr, inference_addr) = + match (management_addr, inference_addr) { + (Some(management), Some(inference)) => (management, inference), + _ => { + self.screen = CurrentScreen::StartClusterConfig(config); + + return Task::none(); + } + }; + let desired_state = config .state_data .selected_model @@ -363,11 +408,12 @@ impl SecondBrain { CurrentScreen::RunningCluster(screen) => view_running_cluster(&screen.state_data), }; - column![screen_content] + let content_column = column![screen_content] .max_width(700) - .padding(20) - .spacing(20) - .align_x(Center) - .into() + .padding([SPACING_2X * 2.0, SPACING_BASE]) + .spacing(SPACING_BASE) + .align_x(Center); + + container(content_column).center_x(Fill).into() } } diff --git a/paddler_second_brain_gui/src/start_agent.rs b/paddler_second_brain_gui/src/start_agent.rs index 4d5c1da8..ea455857 100644 --- a/paddler_second_brain_gui/src/start_agent.rs +++ b/paddler_second_brain_gui/src/start_agent.rs @@ -5,7 +5,7 @@ use tokio::sync::oneshot; use crate::start_agent_services::start_agent_services; pub async fn start_agent( - agent_name: String, + agent_name: Option, management_address: String, slots: i32, agent_status_tx: mpsc::UnboundedSender, diff --git a/paddler_second_brain_gui/src/start_agent_services.rs b/paddler_second_brain_gui/src/start_agent_services.rs index 05259e63..a0647b23 100644 --- a/paddler_second_brain_gui/src/start_agent_services.rs +++ b/paddler_second_brain_gui/src/start_agent_services.rs @@ -19,7 +19,7 @@ use tokio::sync::oneshot; use crate::agent_status_monitor_service::AgentStatusMonitorService; pub async fn start_agent_services( - agent_name: String, + agent_name: Option, management_address: String, slots: i32, agent_status_tx: mpsc::UnboundedSender, @@ -51,7 +51,7 @@ pub async fn start_agent_services( service_manager.add_service(LlamaCppArbiterService { agent_applicable_state: None, agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_name: Some(agent_name.clone()), + agent_name: agent_name.clone(), continue_from_conversation_history_request_rx, continue_from_raw_prompt_request_rx, desired_slots_total: slots, @@ -68,7 +68,7 @@ pub async fn start_agent_services( continue_from_raw_prompt_request_tx, generate_embedding_batch_request_tx, model_metadata_holder, - name: Some(agent_name), + name: agent_name, receive_stream_stopper_collection: Default::default(), slot_aggregated_status: slot_aggregated_status_manager .slot_aggregated_status diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index e3b3590b..dba0863f 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -2,8 +2,9 @@ use crate::model_preset::ModelPreset; pub struct StartClusterConfigData { pub balancer_address: String, - pub error: Option, + pub balancer_address_error: Option, pub inference_address: String, + pub inference_address_error: Option, pub selected_model: Option, pub starting: bool, } diff --git a/paddler_second_brain_gui/src/variables.rs b/paddler_second_brain_gui/src/variables.rs index 3b8bd9c5..8b68e76c 100644 --- a/paddler_second_brain_gui/src/variables.rs +++ b/paddler_second_brain_gui/src/variables.rs @@ -28,3 +28,9 @@ pub const COLOR_AGENT_BACKGROUND: Color = Color { a: 1.0, }; pub const COLOR_BORDER: Color = Color::BLACK; +pub const COLOR_ERROR: Color = Color { + r: 0xCC as f32 / 255.0, + g: 0x33 as f32 / 255.0, + b: 0x33 as f32 / 255.0, + a: 1.0, +}; diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/view_agent_running.rs index 246db215..1c796dc2 100644 --- a/paddler_second_brain_gui/src/view_agent_running.rs +++ b/paddler_second_brain_gui/src/view_agent_running.rs @@ -6,6 +6,7 @@ use iced::widget::column; use iced::widget::container; use iced::widget::row; use iced::widget::svg; +use iced::widget::svg::Handle as SvgHandle; use iced::widget::text; use crate::agent_running_data::AgentRunningData; @@ -22,7 +23,7 @@ use crate::view_agent_card::view_agent_card; pub fn view_agent_running<'content>( data: &'content AgentRunningData, ) -> Element<'content, Message> { - let stop_icon = svg(svg::Handle::from_memory( + let stop_icon = svg(SvgHandle::from_memory( include_bytes!("../../resources/icons/stop.svg").as_slice(), )) .width(16) diff --git a/paddler_second_brain_gui/src/view_home.rs b/paddler_second_brain_gui/src/view_home.rs index 2ba6e49f..9ff8ea92 100644 --- a/paddler_second_brain_gui/src/view_home.rs +++ b/paddler_second_brain_gui/src/view_home.rs @@ -1,14 +1,69 @@ +use std::sync::LazyLock; + +use iced::Center; use iced::Element; use iced::widget::button; use iced::widget::column; +use iced::widget::container; +use iced::widget::image; +use iced::widget::image::Handle as ImageHandle; +use iced::widget::row; +use iced::widget::text; +use crate::font::BOLD; use crate::message::Message; +use crate::style_button_primary::style_button_primary; +use crate::variables::FONT_SIZE_L2; +use crate::variables::SPACING_2X; +use crate::variables::SPACING_BASE; +use crate::variables::SPACING_HALF; + +static CREATE_CLUSTER_IMAGE: LazyLock = LazyLock::new(|| { + ImageHandle::from_bytes( + include_bytes!("../../resources/images/create_a_cluster.png").as_slice(), + ) +}); + +static JOIN_CLUSTER_IMAGE: LazyLock = LazyLock::new(|| { + ImageHandle::from_bytes( + include_bytes!("../../resources/images/join_a_cluster.png").as_slice(), + ) +}); pub fn view_home() -> Element<'static, Message> { + let create_image = image(CREATE_CLUSTER_IMAGE.clone()) + .width(200) + .height(200); + + let join_image = image(JOIN_CLUSTER_IMAGE.clone()) + .width(200) + .height(200); + + let start_button = button(text("Start a cluster").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + .on_press(Message::StartCluster); + + let join_button = button(text("Join a cluster").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + .on_press(Message::JoinCluster); + + let start_column = column![create_image, start_button] + .spacing(SPACING_BASE) + .align_x(Center); + + let join_column = column![join_image, join_button] + .spacing(SPACING_BASE) + .align_x(Center); + + let options_row = row![start_column, join_column].spacing(SPACING_2X); + column![ - button("Start a cluster").on_press(Message::StartCluster), - button("Join a cluster").on_press(Message::JoinCluster), + container(text("Paddler second brain").size(FONT_SIZE_L2).font(BOLD)) + .padding([0.0, SPACING_BASE]), + container(options_row).align_x(Center), ] - .spacing(10) + .spacing(SPACING_2X) .into() } diff --git a/paddler_second_brain_gui/src/view_join_cluster_config.rs b/paddler_second_brain_gui/src/view_join_cluster_config.rs index 5a8c9f15..a85270f8 100644 --- a/paddler_second_brain_gui/src/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_join_cluster_config.rs @@ -29,7 +29,7 @@ pub fn view_join_cluster_config<'content>( .unwrap_or(false); let confirm_button = - if !data.cluster_address.is_empty() && !data.agent_name.is_empty() && is_valid_slots { + if !data.cluster_address.is_empty() && is_valid_slots { button(text("Connect").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index c8985339..82809188 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -10,6 +10,7 @@ use iced::widget::column; use iced::widget::container; use iced::widget::row; use iced::widget::svg; +use iced::widget::svg::Handle as SvgHandle; use iced::widget::text; use crate::font::BOLD; @@ -27,7 +28,7 @@ use crate::view_agent_card::view_agent_card; pub fn view_running_cluster<'content>( data: &'content RunningClusterData, ) -> Element<'content, Message> { - let copy_icon = svg(svg::Handle::from_memory( + let copy_icon = svg(SvgHandle::from_memory( include_bytes!("../../resources/icons/copy.svg").as_slice(), )) .width(16) @@ -50,7 +51,7 @@ pub fn view_running_cluster<'content>( ) .style(style_card_container); - let stop_icon = svg(svg::Handle::from_memory( + let stop_icon = svg(SvgHandle::from_memory( include_bytes!("../../resources/icons/stop.svg").as_slice(), )) .width(16) diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/view_start_cluster_config.rs index b1ce3d3e..7e6f2504 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_start_cluster_config.rs @@ -11,6 +11,7 @@ use iced::widget::text; use iced::widget::text_input; use crate::font::BOLD; +use crate::font::REGULAR; use crate::message::Message; use crate::model_preset::ModelPreset; use crate::start_cluster_config_data::StartClusterConfigData; @@ -19,6 +20,7 @@ use crate::style_field_container::style_field_container; use crate::style_field_pick_list::style_field_pick_list; use crate::style_field_pick_list_menu::style_field_pick_list_menu; use crate::style_field_text_input::style_field_text_input; +use crate::variables::COLOR_ERROR; use crate::variables::FONT_SIZE_L2; use crate::variables::SPACING_2X; use crate::variables::SPACING_BASE; @@ -51,33 +53,51 @@ pub fn view_start_cluster_config<'content>( .style(button::text) .on_press(Message::Cancel); - let mut content = column![ + let mut balancer_field = column![ + container(text("Balancer address").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("IP:port", &data.balancer_address) + .on_input(Message::SetBalancerAddress) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF); + + if let Some(error) = &data.balancer_address_error { + balancer_field = balancer_field.push( + container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .padding([0.0, SPACING_BASE]), + ); + } + + let mut inference_field = column![ + container(text("Inference address").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("IP:port", &data.inference_address) + .on_input(Message::SetInferenceAddress) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(300) + .style(style_field_container), + ] + .spacing(SPACING_HALF); + + if let Some(error) = &data.inference_address_error { + inference_field = inference_field.push( + container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .padding([0.0, SPACING_BASE]), + ); + } + + column![ container(text("Start a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), - column![ - container(text("Balancer address").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("IP:port", &data.balancer_address) - .on_input(Message::SetBalancerAddress) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(300) - .style(style_field_container), - ] - .spacing(SPACING_HALF), - column![ - container(text("Inference address").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("IP:port", &data.inference_address) - .on_input(Message::SetInferenceAddress) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(300) - .style(style_field_container), - ] - .spacing(SPACING_HALF), + balancer_field, + inference_field, column![ container(text("Select a model").font(BOLD)).padding([0.0, SPACING_BASE]), container( @@ -103,11 +123,6 @@ pub fn view_start_cluster_config<'content>( .width(300) .align_x(Horizontal::Right), ] - .spacing(SPACING_2X); - - if let Some(error) = &data.error { - content = content.push(text(error.clone())); - } - - content.into() + .spacing(SPACING_2X) + .into() } diff --git a/resources/images/create_a_cluster.png b/resources/images/create_a_cluster.png new file mode 100644 index 0000000000000000000000000000000000000000..03a28a53c9cf6855f23ce91d340bec1a5f996811 GIT binary patch literal 3468 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 literal 0 HcmV?d00001 diff --git a/resources/images/join_a_cluster.png b/resources/images/join_a_cluster.png new file mode 100644 index 0000000000000000000000000000000000000000..16b1ace860dc1fd5980dcbf062b27d57a843b45f GIT binary patch literal 4700 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 Date: Thu, 19 Mar 2026 05:47:48 +0100 Subject: [PATCH 20/46] show erorrs on homescreen if balancer or agent fails --- paddler_second_brain_gui/src/home_data.rs | 3 ++ paddler_second_brain_gui/src/main.rs | 2 +- paddler_second_brain_gui/src/screen.rs | 27 +++++++------- .../src/screen_current.rs | 8 ++++- paddler_second_brain_gui/src/second_brain.rs | 35 ++++++++----------- paddler_second_brain_gui/src/view_home.rs | 31 +++++++++------- .../src/view_join_cluster_config.rs | 21 ++++++----- 7 files changed, 68 insertions(+), 59 deletions(-) create mode 100644 paddler_second_brain_gui/src/home_data.rs diff --git a/paddler_second_brain_gui/src/home_data.rs b/paddler_second_brain_gui/src/home_data.rs new file mode 100644 index 00000000..ed431f1b --- /dev/null +++ b/paddler_second_brain_gui/src/home_data.rs @@ -0,0 +1,3 @@ +pub struct HomeData { + pub error: Option, +} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 24d57aec..09d32f78 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -3,6 +3,7 @@ mod agent_running_data; mod agent_status_monitor_service; mod detect_network_interfaces; mod font; +mod home_data; mod join_cluster_config_data; mod message; mod model_preset; @@ -34,7 +35,6 @@ mod view_running_cluster; mod view_start_cluster_config; use iced::Size; - use second_brain::SecondBrain; fn main() -> iced::Result { diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index f2aa36b8..9af7171e 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -8,6 +8,7 @@ use statum::transition; use crate::agent_running_data::AgentRunningData; use crate::detect_network_interfaces::detect_network_interfaces; +use crate::home_data::HomeData; use crate::join_cluster_config_data::JoinClusterConfigData; use crate::running_cluster_data::RunningClusterData; use crate::start_cluster_config_data::StartClusterConfigData; @@ -15,7 +16,7 @@ use crate::start_cluster_config_data::StartClusterConfigData; #[state] pub enum ScreenState { AgentRunning(AgentRunningData), - Home, + Home(HomeData), JoinClusterConfig(JoinClusterConfigData), StartClusterConfig(StartClusterConfigData), RunningCluster(RunningClusterData), @@ -50,11 +51,11 @@ impl Screen { #[transition] impl Screen { pub fn cancel(self) -> Screen { - self.transition() + self.transition_with(HomeData { error: None }) } pub fn connect(self) -> Screen { - self.transition_map(|config_data| AgentRunningData { + self.transition_map(|config_data: JoinClusterConfigData| AgentRunningData { cluster_address: config_data.cluster_address.clone(), connected: false, snapshot: AgentControllerSnapshot { @@ -82,40 +83,40 @@ impl Screen { #[transition] impl Screen { pub fn disconnect(self) -> Screen { - self.transition() + self.transition_with(HomeData { error: None }) } - pub fn agent_failed(self) -> Screen { - self.transition() + pub fn agent_failed(self, error: String) -> Screen { + self.transition_with(HomeData { error: Some(error) }) } } #[transition] impl Screen { pub fn cancel(self) -> Screen { - self.transition() + self.transition_with(HomeData { error: None }) } pub fn cluster_started(self) -> Screen { - self.transition_map(|config_data| RunningClusterData { + self.transition_map(|config_data: StartClusterConfigData| RunningClusterData { agent_snapshots: vec![], cluster_address: config_data.balancer_address.clone(), stopping: false, }) } - pub fn cluster_failed(self) -> Screen { - self.transition() + pub fn cluster_failed(self, error: String) -> Screen { + self.transition_with(HomeData { error: Some(error) }) } } #[transition] impl Screen { pub fn cluster_stopped(self) -> Screen { - self.transition() + self.transition_with(HomeData { error: None }) } - pub fn cluster_failed(self) -> Screen { - self.transition() + pub fn cluster_failed(self, error: String) -> Screen { + self.transition_with(HomeData { error: Some(error) }) } } diff --git a/paddler_second_brain_gui/src/screen_current.rs b/paddler_second_brain_gui/src/screen_current.rs index b8fe5384..e2e7998b 100644 --- a/paddler_second_brain_gui/src/screen_current.rs +++ b/paddler_second_brain_gui/src/screen_current.rs @@ -15,6 +15,12 @@ pub enum CurrentScreen { impl Default for CurrentScreen { fn default() -> Self { - CurrentScreen::Home(Screen::::builder().build()) + use crate::home_data::HomeData; + + CurrentScreen::Home( + Screen::::builder() + .state_data(HomeData { error: None }) + .build(), + ) } } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index a211a89f..41d43106 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -226,10 +226,8 @@ impl SecondBrain { let management_addr = match management_addr { Some(addr) if is_port_in_use(&addr) => { - config.state_data.balancer_address_error = Some(format!( - "Port {} is already in use", - addr.port() - )); + config.state_data.balancer_address_error = + Some(format!("Port {} is already in use", addr.port())); None } other => other, @@ -237,24 +235,21 @@ impl SecondBrain { let inference_addr = match inference_addr { Some(addr) if is_port_in_use(&addr) => { - config.state_data.inference_address_error = Some(format!( - "Port {} is already in use", - addr.port() - )); + config.state_data.inference_address_error = + Some(format!("Port {} is already in use", addr.port())); None } other => other, }; - let (management_addr, inference_addr) = - match (management_addr, inference_addr) { - (Some(management), Some(inference)) => (management, inference), - _ => { - self.screen = CurrentScreen::StartClusterConfig(config); + let (management_addr, inference_addr) = match (management_addr, inference_addr) { + (Some(management), Some(inference)) => (management, inference), + _ => { + self.screen = CurrentScreen::StartClusterConfig(config); - return Task::none(); - } - }; + return Task::none(); + } + }; let desired_state = config .state_data @@ -297,7 +292,7 @@ impl SecondBrain { (CurrentScreen::StartClusterConfig(config), Message::ClusterFailed(error)) => { log::error!("Cluster failed to start: {error}"); self.shutdown_tx = None; - self.screen = CurrentScreen::Home(config.cluster_failed()); + self.screen = CurrentScreen::Home(config.cluster_failed(error)); Task::none() } @@ -330,7 +325,7 @@ impl SecondBrain { log::error!("Cluster failed unexpectedly: {error}"); self.agent_snapshots_rx = None; self.shutdown_tx = None; - self.screen = CurrentScreen::Home(running.cluster_failed()); + self.screen = CurrentScreen::Home(running.cluster_failed(error)); Task::none() } @@ -365,7 +360,7 @@ impl SecondBrain { log::error!("Agent failed: {error}"); self.agent_shutdown_tx = None; self.agent_status_rx = None; - self.screen = CurrentScreen::Home(running.agent_failed()); + self.screen = CurrentScreen::Home(running.agent_failed(error)); Task::none() } @@ -398,7 +393,7 @@ impl SecondBrain { pub fn view<'view>(&'view self) -> Element<'view, Message> { let screen_content = match &self.screen { CurrentScreen::AgentRunning(screen) => view_agent_running(&screen.state_data), - CurrentScreen::Home(_) => view_home(), + CurrentScreen::Home(screen) => view_home(&screen.state_data), CurrentScreen::JoinClusterConfig(screen) => { view_join_cluster_config(&screen.state_data) } diff --git a/paddler_second_brain_gui/src/view_home.rs b/paddler_second_brain_gui/src/view_home.rs index 9ff8ea92..1ef819c4 100644 --- a/paddler_second_brain_gui/src/view_home.rs +++ b/paddler_second_brain_gui/src/view_home.rs @@ -11,8 +11,11 @@ use iced::widget::row; use iced::widget::text; use crate::font::BOLD; +use crate::font::REGULAR; +use crate::home_data::HomeData; use crate::message::Message; use crate::style_button_primary::style_button_primary; +use crate::variables::COLOR_ERROR; use crate::variables::FONT_SIZE_L2; use crate::variables::SPACING_2X; use crate::variables::SPACING_BASE; @@ -25,19 +28,13 @@ static CREATE_CLUSTER_IMAGE: LazyLock = LazyLock::new(|| { }); static JOIN_CLUSTER_IMAGE: LazyLock = LazyLock::new(|| { - ImageHandle::from_bytes( - include_bytes!("../../resources/images/join_a_cluster.png").as_slice(), - ) + ImageHandle::from_bytes(include_bytes!("../../resources/images/join_a_cluster.png").as_slice()) }); -pub fn view_home() -> Element<'static, Message> { - let create_image = image(CREATE_CLUSTER_IMAGE.clone()) - .width(200) - .height(200); +pub fn view_home<'content>(data: &'content HomeData) -> Element<'content, Message> { + let create_image = image(CREATE_CLUSTER_IMAGE.clone()).width(200).height(200); - let join_image = image(JOIN_CLUSTER_IMAGE.clone()) - .width(200) - .height(200); + let join_image = image(JOIN_CLUSTER_IMAGE.clone()).width(200).height(200); let start_button = button(text("Start a cluster").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) @@ -59,11 +56,19 @@ pub fn view_home() -> Element<'static, Message> { let options_row = row![start_column, join_column].spacing(SPACING_2X); - column![ + let mut content = column![ container(text("Paddler second brain").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), container(options_row).align_x(Center), ] - .spacing(SPACING_2X) - .into() + .spacing(SPACING_2X); + + if let Some(error) = &data.error { + content = content.push( + container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .padding([0.0, SPACING_BASE]), + ); + } + + content.into() } diff --git a/paddler_second_brain_gui/src/view_join_cluster_config.rs b/paddler_second_brain_gui/src/view_join_cluster_config.rs index a85270f8..ef4d8a71 100644 --- a/paddler_second_brain_gui/src/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/view_join_cluster_config.rs @@ -28,17 +28,16 @@ pub fn view_join_cluster_config<'content>( .map(|slots| slots > 0) .unwrap_or(false); - let confirm_button = - if !data.cluster_address.is_empty() && is_valid_slots { - button(text("Connect").font(BOLD)) - .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_primary) - .on_press(Message::Connect) - } else { - button(text("Connect").font(BOLD)) - .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_primary) - }; + let confirm_button = if !data.cluster_address.is_empty() && is_valid_slots { + button(text("Connect").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + .on_press(Message::Connect) + } else { + button(text("Connect").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + }; let cancel_button = button(text("Cancel").font(BOLD)) .style(button::text) From 0f9a5d23e63ea7fe51748e5225c91d93ee52dcbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 19 Mar 2026 06:12:59 +0100 Subject: [PATCH 21/46] =?UTF-8?q?snapshot=20=E2=86=92=20'content'=20name?= =?UTF-8?q?=20update,=20move=20button=20disconnect=20color=20to=20variable?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- paddler_second_brain_gui/src/main.rs | 2 +- .../src/style_button_danger.rs | 26 ------------------- .../src/style_button_disconnect.rs | 22 ++++++++++++++++ .../src/view_agent_card.rs | 6 ++--- .../src/view_agent_running.rs | 4 +-- .../src/view_running_cluster.rs | 6 ++--- 6 files changed, 31 insertions(+), 35 deletions(-) delete mode 100644 paddler_second_brain_gui/src/style_button_danger.rs create mode 100644 paddler_second_brain_gui/src/style_button_disconnect.rs diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 09d32f78..7a49cccf 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -18,7 +18,7 @@ mod start_balancer; mod start_balancer_services; mod start_cluster_config_data; mod style_agent_container; -mod style_button_danger; +mod style_button_disconnect; mod style_button_primary; mod style_card_container; mod style_download_progress_bar; diff --git a/paddler_second_brain_gui/src/style_button_danger.rs b/paddler_second_brain_gui/src/style_button_danger.rs deleted file mode 100644 index 0fe41057..00000000 --- a/paddler_second_brain_gui/src/style_button_danger.rs +++ /dev/null @@ -1,26 +0,0 @@ -use iced::Background; -use iced::Border; -use iced::Color; -use iced::Theme; -use iced::widget::button; - -pub fn style_button_danger(theme: &Theme, status: button::Status) -> button::Style { - let base = button::primary(theme, status); - - let background_color = Color::from_rgb( - 0xCC as f32 / 255.0, - 0x33 as f32 / 255.0, - 0x33 as f32 / 255.0, - ); - - button::Style { - background: Some(Background::Color(background_color)), - text_color: Color::WHITE, - border: Border { - color: background_color, - width: 0.0, - radius: 0.into(), - }, - ..base - } -} diff --git a/paddler_second_brain_gui/src/style_button_disconnect.rs b/paddler_second_brain_gui/src/style_button_disconnect.rs new file mode 100644 index 00000000..862f3f6e --- /dev/null +++ b/paddler_second_brain_gui/src/style_button_disconnect.rs @@ -0,0 +1,22 @@ +use iced::Background; +use iced::Border; +use iced::Color; +use iced::Theme; +use iced::widget::button; + +use crate::variables::COLOR_ERROR; + +pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button::Style { + let base = button::primary(theme, status); + + button::Style { + background: Some(Background::Color(COLOR_ERROR)), + text_color: Color::WHITE, + border: Border { + color: COLOR_ERROR, + width: 0.0, + radius: 0.into(), + }, + ..base + } +} diff --git a/paddler_second_brain_gui/src/view_agent_card.rs b/paddler_second_brain_gui/src/view_agent_card.rs index 3ee36b97..9e2420dc 100644 --- a/paddler_second_brain_gui/src/view_agent_card.rs +++ b/paddler_second_brain_gui/src/view_agent_card.rs @@ -20,9 +20,9 @@ fn display_last_path_part(path: &str) -> String { path.split('/').next_back().unwrap_or(path).to_string() } -pub fn view_agent_card<'snapshot>( - snapshot: &'snapshot AgentControllerSnapshot, -) -> Element<'snapshot, Message> { +pub fn view_agent_card<'content>( + snapshot: &'content AgentControllerSnapshot, +) -> Element<'content, Message> { let agent_name = snapshot.name.as_deref().unwrap_or(&snapshot.id); let is_downloading = diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/view_agent_running.rs index 1c796dc2..f6423f38 100644 --- a/paddler_second_brain_gui/src/view_agent_running.rs +++ b/paddler_second_brain_gui/src/view_agent_running.rs @@ -13,7 +13,7 @@ use crate::agent_running_data::AgentRunningData; use crate::font::BOLD; use crate::font::REGULAR; use crate::message::Message; -use crate::style_button_danger::style_button_danger; +use crate::style_button_disconnect::style_button_disconnect; use crate::variables::FONT_SIZE_L2; use crate::variables::SPACING_2X; use crate::variables::SPACING_BASE; @@ -35,7 +35,7 @@ pub fn view_agent_running<'content>( .align_y(Center), ) .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_danger) + .style(style_button_disconnect) .on_press(Message::Disconnect); let connection_status = if data.connected { diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/view_running_cluster.rs index 82809188..4c57fe0b 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/view_running_cluster.rs @@ -17,7 +17,7 @@ use crate::font::BOLD; use crate::font::REGULAR; use crate::message::Message; use crate::running_cluster_data::RunningClusterData; -use crate::style_button_danger::style_button_danger; +use crate::style_button_disconnect::style_button_disconnect; use crate::style_card_container::style_card_container; use crate::variables::FONT_SIZE_L2; use crate::variables::SPACING_2X; @@ -86,7 +86,7 @@ pub fn view_running_cluster<'content>( .align_y(Center), ) .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_danger) + .style(style_button_disconnect) } else { button( row![stop_icon, text("Stop cluster").font(BOLD)] @@ -94,7 +94,7 @@ pub fn view_running_cluster<'content>( .align_y(Center), ) .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_danger) + .style(style_button_disconnect) .on_press(Message::Stop) }; From 6a0684effda95ee79e7936d476e20ccf30e4ab30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Mon, 23 Mar 2026 17:40:07 +0100 Subject: [PATCH 22/46] separate backend and ui modules in the desktop app crate --- .../{ => backend}/agent_monitor_service.rs | 0 .../agent_status_monitor_service.rs | 0 paddler_second_brain_gui/src/backend/mod.rs | 7 ++++++ .../src/{ => backend}/start_agent.rs | 2 +- .../src/{ => backend}/start_agent_services.rs | 2 +- .../src/{ => backend}/start_balancer.rs | 2 +- .../{ => backend}/start_balancer_services.rs | 2 +- paddler_second_brain_gui/src/main.rs | 25 ++----------------- paddler_second_brain_gui/src/second_brain.rs | 18 ++++++------- paddler_second_brain_gui/src/{ => ui}/font.rs | 0 paddler_second_brain_gui/src/ui/mod.rs | 18 +++++++++++++ .../src/{ => ui}/style_agent_container.rs | 4 +-- .../src/{ => ui}/style_button_disconnect.rs | 2 +- .../src/{ => ui}/style_button_primary.rs | 4 +-- .../src/{ => ui}/style_card_container.rs | 4 +-- .../{ => ui}/style_download_progress_bar.rs | 4 +-- .../src/{ => ui}/style_field_container.rs | 2 +- .../src/{ => ui}/style_field_pick_list.rs | 6 ++--- .../{ => ui}/style_field_pick_list_menu.rs | 6 ++--- .../src/{ => ui}/style_field_text_input.rs | 6 ++--- .../src/{ => ui}/variables.rs | 0 .../src/{ => ui}/view_agent_card.rs | 12 ++++----- .../src/{ => ui}/view_agent_running.rs | 18 ++++++------- .../src/{ => ui}/view_home.rs | 22 ++++++++-------- .../src/{ => ui}/view_join_cluster_config.rs | 16 ++++++------ .../src/{ => ui}/view_running_cluster.rs | 24 +++++++++--------- .../src/{ => ui}/view_start_cluster_config.rs | 24 +++++++++--------- 27 files changed, 118 insertions(+), 112 deletions(-) rename paddler_second_brain_gui/src/{ => backend}/agent_monitor_service.rs (100%) rename paddler_second_brain_gui/src/{ => backend}/agent_status_monitor_service.rs (100%) create mode 100644 paddler_second_brain_gui/src/backend/mod.rs rename paddler_second_brain_gui/src/{ => backend}/start_agent.rs (94%) rename paddler_second_brain_gui/src/{ => backend}/start_agent_services.rs (98%) rename paddler_second_brain_gui/src/{ => backend}/start_balancer.rs (94%) rename paddler_second_brain_gui/src/{ => backend}/start_balancer_services.rs (98%) rename paddler_second_brain_gui/src/{ => ui}/font.rs (100%) create mode 100644 paddler_second_brain_gui/src/ui/mod.rs rename paddler_second_brain_gui/src/{ => ui}/style_agent_container.rs (84%) rename paddler_second_brain_gui/src/{ => ui}/style_button_disconnect.rs (93%) rename paddler_second_brain_gui/src/{ => ui}/style_button_primary.rs (85%) rename paddler_second_brain_gui/src/{ => ui}/style_card_container.rs (84%) rename paddler_second_brain_gui/src/{ => ui}/style_download_progress_bar.rs (84%) rename paddler_second_brain_gui/src/{ => ui}/style_field_container.rs (91%) rename paddler_second_brain_gui/src/{ => ui}/style_field_pick_list.rs (82%) rename paddler_second_brain_gui/src/{ => ui}/style_field_pick_list_menu.rs (83%) rename paddler_second_brain_gui/src/{ => ui}/style_field_text_input.rs (82%) rename paddler_second_brain_gui/src/{ => ui}/variables.rs (100%) rename paddler_second_brain_gui/src/{ => ui}/view_agent_card.rs (92%) rename paddler_second_brain_gui/src/{ => ui}/view_agent_running.rs (80%) rename paddler_second_brain_gui/src/{ => ui}/view_home.rs (79%) rename paddler_second_brain_gui/src/{ => ui}/view_join_cluster_config.rs (89%) rename paddler_second_brain_gui/src/{ => ui}/view_running_cluster.rs (86%) rename paddler_second_brain_gui/src/{ => ui}/view_start_cluster_config.rs (87%) diff --git a/paddler_second_brain_gui/src/agent_monitor_service.rs b/paddler_second_brain_gui/src/backend/agent_monitor_service.rs similarity index 100% rename from paddler_second_brain_gui/src/agent_monitor_service.rs rename to paddler_second_brain_gui/src/backend/agent_monitor_service.rs diff --git a/paddler_second_brain_gui/src/agent_status_monitor_service.rs b/paddler_second_brain_gui/src/backend/agent_status_monitor_service.rs similarity index 100% rename from paddler_second_brain_gui/src/agent_status_monitor_service.rs rename to paddler_second_brain_gui/src/backend/agent_status_monitor_service.rs diff --git a/paddler_second_brain_gui/src/backend/mod.rs b/paddler_second_brain_gui/src/backend/mod.rs new file mode 100644 index 00000000..442feb65 --- /dev/null +++ b/paddler_second_brain_gui/src/backend/mod.rs @@ -0,0 +1,7 @@ +pub mod start_agent; +pub mod start_balancer; + +mod agent_monitor_service; +mod agent_status_monitor_service; +mod start_agent_services; +mod start_balancer_services; diff --git a/paddler_second_brain_gui/src/start_agent.rs b/paddler_second_brain_gui/src/backend/start_agent.rs similarity index 94% rename from paddler_second_brain_gui/src/start_agent.rs rename to paddler_second_brain_gui/src/backend/start_agent.rs index ea455857..af29c1b7 100644 --- a/paddler_second_brain_gui/src/start_agent.rs +++ b/paddler_second_brain_gui/src/backend/start_agent.rs @@ -2,7 +2,7 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot use tokio::sync::mpsc; use tokio::sync::oneshot; -use crate::start_agent_services::start_agent_services; +use super::start_agent_services::start_agent_services; pub async fn start_agent( agent_name: Option, diff --git a/paddler_second_brain_gui/src/start_agent_services.rs b/paddler_second_brain_gui/src/backend/start_agent_services.rs similarity index 98% rename from paddler_second_brain_gui/src/start_agent_services.rs rename to paddler_second_brain_gui/src/backend/start_agent_services.rs index a0647b23..b197a9b6 100644 --- a/paddler_second_brain_gui/src/start_agent_services.rs +++ b/paddler_second_brain_gui/src/backend/start_agent_services.rs @@ -16,7 +16,7 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot use tokio::sync::mpsc; use tokio::sync::oneshot; -use crate::agent_status_monitor_service::AgentStatusMonitorService; +use super::agent_status_monitor_service::AgentStatusMonitorService; pub async fn start_agent_services( agent_name: Option, diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/backend/start_balancer.rs similarity index 94% rename from paddler_second_brain_gui/src/start_balancer.rs rename to paddler_second_brain_gui/src/backend/start_balancer.rs index c3fb7209..8feccc18 100644 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ b/paddler_second_brain_gui/src/backend/start_balancer.rs @@ -5,7 +5,7 @@ use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::mpsc; use tokio::sync::oneshot; -use crate::start_balancer_services::start_balancer_services; +use super::start_balancer_services::start_balancer_services; pub async fn start_balancer( management_addr: SocketAddr, diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/backend/start_balancer_services.rs similarity index 98% rename from paddler_second_brain_gui/src/start_balancer_services.rs rename to paddler_second_brain_gui/src/backend/start_balancer_services.rs index 7b43ce0b..aa3e7dff 100644 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/backend/start_balancer_services.rs @@ -23,7 +23,7 @@ use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; -use crate::agent_monitor_service::AgentMonitorService; +use super::agent_monitor_service::AgentMonitorService; pub async fn start_balancer_services( management_addr: SocketAddr, diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 7a49cccf..e5db741b 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,8 +1,6 @@ -mod agent_monitor_service; mod agent_running_data; -mod agent_status_monitor_service; +mod backend; mod detect_network_interfaces; -mod font; mod home_data; mod join_cluster_config_data; mod message; @@ -12,27 +10,8 @@ mod running_cluster_data; mod screen; mod screen_current; mod second_brain; -mod start_agent; -mod start_agent_services; -mod start_balancer; -mod start_balancer_services; mod start_cluster_config_data; -mod style_agent_container; -mod style_button_disconnect; -mod style_button_primary; -mod style_card_container; -mod style_download_progress_bar; -mod style_field_container; -mod style_field_pick_list; -mod style_field_pick_list_menu; -mod style_field_text_input; -mod variables; -mod view_agent_card; -mod view_agent_running; -mod view_home; -mod view_join_cluster_config; -mod view_running_cluster; -mod view_start_cluster_config; +mod ui; use iced::Size; use second_brain::SecondBrain; diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 41d43106..da7b0e6e 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -16,17 +16,17 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot use tokio::sync::mpsc; use tokio::sync::oneshot; +use crate::backend::start_agent::start_agent; +use crate::backend::start_balancer::start_balancer; use crate::message::Message; use crate::screen_current::CurrentScreen; -use crate::start_agent::start_agent; -use crate::start_balancer::start_balancer; -use crate::variables::SPACING_2X; -use crate::variables::SPACING_BASE; -use crate::view_agent_running::view_agent_running; -use crate::view_home::view_home; -use crate::view_join_cluster_config::view_join_cluster_config; -use crate::view_running_cluster::view_running_cluster; -use crate::view_start_cluster_config::view_start_cluster_config; +use crate::ui::variables::SPACING_2X; +use crate::ui::variables::SPACING_BASE; +use crate::ui::view_agent_running::view_agent_running; +use crate::ui::view_home::view_home; +use crate::ui::view_join_cluster_config::view_join_cluster_config; +use crate::ui::view_running_cluster::view_running_cluster; +use crate::ui::view_start_cluster_config::view_start_cluster_config; fn is_port_in_use(address: &SocketAddr) -> bool { TcpStream::connect_timeout(address, Duration::from_millis(100)).is_ok() diff --git a/paddler_second_brain_gui/src/font.rs b/paddler_second_brain_gui/src/ui/font.rs similarity index 100% rename from paddler_second_brain_gui/src/font.rs rename to paddler_second_brain_gui/src/ui/font.rs diff --git a/paddler_second_brain_gui/src/ui/mod.rs b/paddler_second_brain_gui/src/ui/mod.rs new file mode 100644 index 00000000..f8d51eff --- /dev/null +++ b/paddler_second_brain_gui/src/ui/mod.rs @@ -0,0 +1,18 @@ +pub mod variables; +pub mod view_agent_running; +pub mod view_home; +pub mod view_join_cluster_config; +pub mod view_running_cluster; +pub mod view_start_cluster_config; + +mod font; +mod style_agent_container; +mod style_button_disconnect; +mod style_button_primary; +mod style_card_container; +mod style_download_progress_bar; +mod style_field_container; +mod style_field_pick_list; +mod style_field_pick_list_menu; +mod style_field_text_input; +mod view_agent_card; diff --git a/paddler_second_brain_gui/src/style_agent_container.rs b/paddler_second_brain_gui/src/ui/style_agent_container.rs similarity index 84% rename from paddler_second_brain_gui/src/style_agent_container.rs rename to paddler_second_brain_gui/src/ui/style_agent_container.rs index 55f85b3d..72609cc4 100644 --- a/paddler_second_brain_gui/src/style_agent_container.rs +++ b/paddler_second_brain_gui/src/ui/style_agent_container.rs @@ -3,8 +3,8 @@ use iced::Border; use iced::Theme; use iced::widget::container; -use crate::variables::COLOR_AGENT_BACKGROUND; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_AGENT_BACKGROUND; +use super::variables::COLOR_BORDER; pub fn style_agent_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); diff --git a/paddler_second_brain_gui/src/style_button_disconnect.rs b/paddler_second_brain_gui/src/ui/style_button_disconnect.rs similarity index 93% rename from paddler_second_brain_gui/src/style_button_disconnect.rs rename to paddler_second_brain_gui/src/ui/style_button_disconnect.rs index 862f3f6e..7973c5ca 100644 --- a/paddler_second_brain_gui/src/style_button_disconnect.rs +++ b/paddler_second_brain_gui/src/ui/style_button_disconnect.rs @@ -4,7 +4,7 @@ use iced::Color; use iced::Theme; use iced::widget::button; -use crate::variables::COLOR_ERROR; +use super::variables::COLOR_ERROR; pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); diff --git a/paddler_second_brain_gui/src/style_button_primary.rs b/paddler_second_brain_gui/src/ui/style_button_primary.rs similarity index 85% rename from paddler_second_brain_gui/src/style_button_primary.rs rename to paddler_second_brain_gui/src/ui/style_button_primary.rs index 5888b187..9ed1bcad 100644 --- a/paddler_second_brain_gui/src/style_button_primary.rs +++ b/paddler_second_brain_gui/src/ui/style_button_primary.rs @@ -3,8 +3,8 @@ use iced::Border; use iced::Theme; use iced::widget::button; -use crate::variables::COLOR_BODY_BACKGROUND; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_BODY_BACKGROUND; +use super::variables::COLOR_BORDER; pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); diff --git a/paddler_second_brain_gui/src/style_card_container.rs b/paddler_second_brain_gui/src/ui/style_card_container.rs similarity index 84% rename from paddler_second_brain_gui/src/style_card_container.rs rename to paddler_second_brain_gui/src/ui/style_card_container.rs index a15daf4a..66a5a254 100644 --- a/paddler_second_brain_gui/src/style_card_container.rs +++ b/paddler_second_brain_gui/src/ui/style_card_container.rs @@ -3,8 +3,8 @@ use iced::Border; use iced::Theme; use iced::widget::container; -use crate::variables::COLOR_BODY_BACKGROUND; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_BODY_BACKGROUND; +use super::variables::COLOR_BORDER; pub fn style_card_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); diff --git a/paddler_second_brain_gui/src/style_download_progress_bar.rs b/paddler_second_brain_gui/src/ui/style_download_progress_bar.rs similarity index 84% rename from paddler_second_brain_gui/src/style_download_progress_bar.rs rename to paddler_second_brain_gui/src/ui/style_download_progress_bar.rs index 54f95b16..915291d7 100644 --- a/paddler_second_brain_gui/src/style_download_progress_bar.rs +++ b/paddler_second_brain_gui/src/ui/style_download_progress_bar.rs @@ -3,8 +3,8 @@ use iced::Border; use iced::Theme; use iced::widget::progress_bar; -use crate::variables::COLOR_BODY_BACKGROUND; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_BODY_BACKGROUND; +use super::variables::COLOR_BORDER; pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { progress_bar::Style { diff --git a/paddler_second_brain_gui/src/style_field_container.rs b/paddler_second_brain_gui/src/ui/style_field_container.rs similarity index 91% rename from paddler_second_brain_gui/src/style_field_container.rs rename to paddler_second_brain_gui/src/ui/style_field_container.rs index c346a9c6..df18be3d 100644 --- a/paddler_second_brain_gui/src/style_field_container.rs +++ b/paddler_second_brain_gui/src/ui/style_field_container.rs @@ -3,7 +3,7 @@ use iced::Theme; use iced::Vector; use iced::widget::container; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_BORDER; pub fn style_field_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); diff --git a/paddler_second_brain_gui/src/style_field_pick_list.rs b/paddler_second_brain_gui/src/ui/style_field_pick_list.rs similarity index 82% rename from paddler_second_brain_gui/src/style_field_pick_list.rs rename to paddler_second_brain_gui/src/ui/style_field_pick_list.rs index a35525d0..b9fe77b8 100644 --- a/paddler_second_brain_gui/src/style_field_pick_list.rs +++ b/paddler_second_brain_gui/src/ui/style_field_pick_list.rs @@ -3,9 +3,9 @@ use iced::Border; use iced::Theme; use iced::widget::pick_list; -use crate::variables::COLOR_BODY_BACKGROUND; -use crate::variables::COLOR_BODY_FONT; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_BODY_BACKGROUND; +use super::variables::COLOR_BODY_FONT; +use super::variables::COLOR_BORDER; pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_list::Style { let base = pick_list::default(theme, status); diff --git a/paddler_second_brain_gui/src/style_field_pick_list_menu.rs b/paddler_second_brain_gui/src/ui/style_field_pick_list_menu.rs similarity index 83% rename from paddler_second_brain_gui/src/style_field_pick_list_menu.rs rename to paddler_second_brain_gui/src/ui/style_field_pick_list_menu.rs index a2b23891..bce4cb4a 100644 --- a/paddler_second_brain_gui/src/style_field_pick_list_menu.rs +++ b/paddler_second_brain_gui/src/ui/style_field_pick_list_menu.rs @@ -4,9 +4,9 @@ use iced::Color; use iced::Theme; use iced::overlay::menu; -use crate::variables::COLOR_BODY_BACKGROUND; -use crate::variables::COLOR_BODY_FONT; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_BODY_BACKGROUND; +use super::variables::COLOR_BODY_FONT; +use super::variables::COLOR_BORDER; pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { let base = menu::default(theme); diff --git a/paddler_second_brain_gui/src/style_field_text_input.rs b/paddler_second_brain_gui/src/ui/style_field_text_input.rs similarity index 82% rename from paddler_second_brain_gui/src/style_field_text_input.rs rename to paddler_second_brain_gui/src/ui/style_field_text_input.rs index 4dfab39b..ff0e4613 100644 --- a/paddler_second_brain_gui/src/style_field_text_input.rs +++ b/paddler_second_brain_gui/src/ui/style_field_text_input.rs @@ -3,9 +3,9 @@ use iced::Border; use iced::Theme; use iced::widget::text_input; -use crate::variables::COLOR_BODY_BACKGROUND; -use crate::variables::COLOR_BODY_FONT; -use crate::variables::COLOR_BORDER; +use super::variables::COLOR_BODY_BACKGROUND; +use super::variables::COLOR_BODY_FONT; +use super::variables::COLOR_BORDER; pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text_input::Style { let base = text_input::default(theme, status); diff --git a/paddler_second_brain_gui/src/variables.rs b/paddler_second_brain_gui/src/ui/variables.rs similarity index 100% rename from paddler_second_brain_gui/src/variables.rs rename to paddler_second_brain_gui/src/ui/variables.rs diff --git a/paddler_second_brain_gui/src/view_agent_card.rs b/paddler_second_brain_gui/src/ui/view_agent_card.rs similarity index 92% rename from paddler_second_brain_gui/src/view_agent_card.rs rename to paddler_second_brain_gui/src/ui/view_agent_card.rs index 9e2420dc..5c638f9e 100644 --- a/paddler_second_brain_gui/src/view_agent_card.rs +++ b/paddler_second_brain_gui/src/ui/view_agent_card.rs @@ -8,13 +8,13 @@ use iced::widget::text; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::agent_state_application_status::AgentStateApplicationStatus; -use crate::font::BOLD; -use crate::font::REGULAR; +use super::font::BOLD; +use super::font::REGULAR; +use super::style_agent_container::style_agent_container; +use super::style_download_progress_bar::style_download_progress_bar; +use super::variables::SPACING_BASE; +use super::variables::SPACING_HALF; use crate::message::Message; -use crate::style_agent_container::style_agent_container; -use crate::style_download_progress_bar::style_download_progress_bar; -use crate::variables::SPACING_BASE; -use crate::variables::SPACING_HALF; fn display_last_path_part(path: &str) -> String { path.split('/').next_back().unwrap_or(path).to_string() diff --git a/paddler_second_brain_gui/src/view_agent_running.rs b/paddler_second_brain_gui/src/ui/view_agent_running.rs similarity index 80% rename from paddler_second_brain_gui/src/view_agent_running.rs rename to paddler_second_brain_gui/src/ui/view_agent_running.rs index f6423f38..8c19e92a 100644 --- a/paddler_second_brain_gui/src/view_agent_running.rs +++ b/paddler_second_brain_gui/src/ui/view_agent_running.rs @@ -9,22 +9,22 @@ use iced::widget::svg; use iced::widget::svg::Handle as SvgHandle; use iced::widget::text; +use super::font::BOLD; +use super::font::REGULAR; +use super::style_button_disconnect::style_button_disconnect; +use super::variables::FONT_SIZE_L2; +use super::variables::SPACING_2X; +use super::variables::SPACING_BASE; +use super::variables::SPACING_HALF; +use super::view_agent_card::view_agent_card; use crate::agent_running_data::AgentRunningData; -use crate::font::BOLD; -use crate::font::REGULAR; use crate::message::Message; -use crate::style_button_disconnect::style_button_disconnect; -use crate::variables::FONT_SIZE_L2; -use crate::variables::SPACING_2X; -use crate::variables::SPACING_BASE; -use crate::variables::SPACING_HALF; -use crate::view_agent_card::view_agent_card; pub fn view_agent_running<'content>( data: &'content AgentRunningData, ) -> Element<'content, Message> { let stop_icon = svg(SvgHandle::from_memory( - include_bytes!("../../resources/icons/stop.svg").as_slice(), + include_bytes!("../../../resources/icons/stop.svg").as_slice(), )) .width(16) .height(16); diff --git a/paddler_second_brain_gui/src/view_home.rs b/paddler_second_brain_gui/src/ui/view_home.rs similarity index 79% rename from paddler_second_brain_gui/src/view_home.rs rename to paddler_second_brain_gui/src/ui/view_home.rs index 1ef819c4..78334caf 100644 --- a/paddler_second_brain_gui/src/view_home.rs +++ b/paddler_second_brain_gui/src/ui/view_home.rs @@ -10,25 +10,27 @@ use iced::widget::image::Handle as ImageHandle; use iced::widget::row; use iced::widget::text; -use crate::font::BOLD; -use crate::font::REGULAR; +use super::font::BOLD; +use super::font::REGULAR; +use super::style_button_primary::style_button_primary; +use super::variables::COLOR_ERROR; +use super::variables::FONT_SIZE_L2; +use super::variables::SPACING_2X; +use super::variables::SPACING_BASE; +use super::variables::SPACING_HALF; use crate::home_data::HomeData; use crate::message::Message; -use crate::style_button_primary::style_button_primary; -use crate::variables::COLOR_ERROR; -use crate::variables::FONT_SIZE_L2; -use crate::variables::SPACING_2X; -use crate::variables::SPACING_BASE; -use crate::variables::SPACING_HALF; static CREATE_CLUSTER_IMAGE: LazyLock = LazyLock::new(|| { ImageHandle::from_bytes( - include_bytes!("../../resources/images/create_a_cluster.png").as_slice(), + include_bytes!("../../../resources/images/create_a_cluster.png").as_slice(), ) }); static JOIN_CLUSTER_IMAGE: LazyLock = LazyLock::new(|| { - ImageHandle::from_bytes(include_bytes!("../../resources/images/join_a_cluster.png").as_slice()) + ImageHandle::from_bytes( + include_bytes!("../../../resources/images/join_a_cluster.png").as_slice(), + ) }); pub fn view_home<'content>(data: &'content HomeData) -> Element<'content, Message> { diff --git a/paddler_second_brain_gui/src/view_join_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs similarity index 89% rename from paddler_second_brain_gui/src/view_join_cluster_config.rs rename to paddler_second_brain_gui/src/ui/view_join_cluster_config.rs index ef4d8a71..9628a1cb 100644 --- a/paddler_second_brain_gui/src/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs @@ -8,16 +8,16 @@ use iced::widget::row; use iced::widget::text; use iced::widget::text_input; -use crate::font::BOLD; +use super::font::BOLD; +use super::style_button_primary::style_button_primary; +use super::style_field_container::style_field_container; +use super::style_field_text_input::style_field_text_input; +use super::variables::FONT_SIZE_L2; +use super::variables::SPACING_2X; +use super::variables::SPACING_BASE; +use super::variables::SPACING_HALF; use crate::join_cluster_config_data::JoinClusterConfigData; use crate::message::Message; -use crate::style_button_primary::style_button_primary; -use crate::style_field_container::style_field_container; -use crate::style_field_text_input::style_field_text_input; -use crate::variables::FONT_SIZE_L2; -use crate::variables::SPACING_2X; -use crate::variables::SPACING_BASE; -use crate::variables::SPACING_HALF; pub fn view_join_cluster_config<'content>( data: &'content JoinClusterConfigData, diff --git a/paddler_second_brain_gui/src/view_running_cluster.rs b/paddler_second_brain_gui/src/ui/view_running_cluster.rs similarity index 86% rename from paddler_second_brain_gui/src/view_running_cluster.rs rename to paddler_second_brain_gui/src/ui/view_running_cluster.rs index 4c57fe0b..2cd262c5 100644 --- a/paddler_second_brain_gui/src/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/ui/view_running_cluster.rs @@ -13,23 +13,23 @@ use iced::widget::svg; use iced::widget::svg::Handle as SvgHandle; use iced::widget::text; -use crate::font::BOLD; -use crate::font::REGULAR; +use super::font::BOLD; +use super::font::REGULAR; +use super::style_button_disconnect::style_button_disconnect; +use super::style_card_container::style_card_container; +use super::variables::FONT_SIZE_L2; +use super::variables::SPACING_2X; +use super::variables::SPACING_BASE; +use super::variables::SPACING_HALF; +use super::view_agent_card::view_agent_card; use crate::message::Message; use crate::running_cluster_data::RunningClusterData; -use crate::style_button_disconnect::style_button_disconnect; -use crate::style_card_container::style_card_container; -use crate::variables::FONT_SIZE_L2; -use crate::variables::SPACING_2X; -use crate::variables::SPACING_BASE; -use crate::variables::SPACING_HALF; -use crate::view_agent_card::view_agent_card; pub fn view_running_cluster<'content>( data: &'content RunningClusterData, ) -> Element<'content, Message> { let copy_icon = svg(SvgHandle::from_memory( - include_bytes!("../../resources/icons/copy.svg").as_slice(), + include_bytes!("../../../resources/icons/copy.svg").as_slice(), )) .width(16) .height(16); @@ -52,7 +52,7 @@ pub fn view_running_cluster<'content>( .style(style_card_container); let stop_icon = svg(SvgHandle::from_memory( - include_bytes!("../../resources/icons/stop.svg").as_slice(), + include_bytes!("../../../resources/icons/stop.svg").as_slice(), )) .width(16) .height(16); @@ -110,7 +110,7 @@ pub fn view_running_cluster<'content>( ] .align_y(Center), ) - .padding([0.0, SPACING_BASE]); + .padding([SPACING_HALF, SPACING_BASE]); let mut content = column![ container(text("Your cluster").size(FONT_SIZE_L2).font(BOLD)).padding([0.0, SPACING_BASE]), diff --git a/paddler_second_brain_gui/src/view_start_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs similarity index 87% rename from paddler_second_brain_gui/src/view_start_cluster_config.rs rename to paddler_second_brain_gui/src/ui/view_start_cluster_config.rs index 7e6f2504..3df31e06 100644 --- a/paddler_second_brain_gui/src/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs @@ -10,21 +10,21 @@ use iced::widget::row; use iced::widget::text; use iced::widget::text_input; -use crate::font::BOLD; -use crate::font::REGULAR; +use super::font::BOLD; +use super::font::REGULAR; +use super::style_button_primary::style_button_primary; +use super::style_field_container::style_field_container; +use super::style_field_pick_list::style_field_pick_list; +use super::style_field_pick_list_menu::style_field_pick_list_menu; +use super::style_field_text_input::style_field_text_input; +use super::variables::COLOR_ERROR; +use super::variables::FONT_SIZE_L2; +use super::variables::SPACING_2X; +use super::variables::SPACING_BASE; +use super::variables::SPACING_HALF; use crate::message::Message; use crate::model_preset::ModelPreset; use crate::start_cluster_config_data::StartClusterConfigData; -use crate::style_button_primary::style_button_primary; -use crate::style_field_container::style_field_container; -use crate::style_field_pick_list::style_field_pick_list; -use crate::style_field_pick_list_menu::style_field_pick_list_menu; -use crate::style_field_text_input::style_field_text_input; -use crate::variables::COLOR_ERROR; -use crate::variables::FONT_SIZE_L2; -use crate::variables::SPACING_2X; -use crate::variables::SPACING_BASE; -use crate::variables::SPACING_HALF; pub fn view_start_cluster_config<'content>( data: &'content StartClusterConfigData, From 331c50bc06f09879834045e0edf02fe5e0c235bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 24 Mar 2026 02:20:13 +0100 Subject: [PATCH 23/46] extract shared service wiring from paddler_cli to paddler_bootstrap --- Cargo.lock | 12 ++++ Cargo.toml | 3 +- paddler_bootstrap/Cargo.toml | 18 +++++ paddler_bootstrap/src/agent.rs | 90 +++++++++++++++++++++++ paddler_bootstrap/src/balancer.rs | 115 ++++++++++++++++++++++++++++++ paddler_bootstrap/src/lib.rs | 2 + paddler_cli/Cargo.toml | 2 + paddler_cli/src/cmd/agent.rs | 80 ++------------------- paddler_cli/src/cmd/balancer.rs | 109 +++++++--------------------- 9 files changed, 274 insertions(+), 157 deletions(-) create mode 100644 paddler_bootstrap/Cargo.toml create mode 100644 paddler_bootstrap/src/agent.rs create mode 100644 paddler_bootstrap/src/balancer.rs create mode 100644 paddler_bootstrap/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 34b0b702..50624c26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4465,6 +4465,17 @@ dependencies = [ "url", ] +[[package]] +name = "paddler_bootstrap" +version = "3.0.1" +dependencies = [ + "anyhow", + "nanoid", + "paddler", + "paddler_types", + "tokio", +] + [[package]] name = "paddler_cli" version = "3.0.1" @@ -4479,6 +4490,7 @@ dependencies = [ "log", "nanoid", "paddler", + "paddler_bootstrap", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 4ffe4f35..82c47ec6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["paddler_cli", "paddler_integration_tests", "paddler", "paddler_client", "paddler_harness", "paddler_model_tests", "paddler_second_brain_gui", "paddler_types"] +members = ["paddler_bootstrap", "paddler_cli", "paddler_integration_tests", "paddler", "paddler_client", "paddler_harness", "paddler_model_tests", "paddler_second_brain_gui", "paddler_types"] resolver = "2" [workspace.package] @@ -62,6 +62,7 @@ tokio-tungstenite = "0.28" thiserror = "2" url = { version = "2.5", features = ["serde"] } paddler = { version = "3.0.1", path = "paddler" } +paddler_bootstrap = { version = "3.0.1", path = "paddler_bootstrap" } paddler_client = { version = "3.0.1", path = "paddler_client" } paddler_harness = { version = "3.0.1", path = "paddler_harness" } paddler_types = { version = "3.0.1", path = "paddler_types", default-features = false } diff --git a/paddler_bootstrap/Cargo.toml b/paddler_bootstrap/Cargo.toml new file mode 100644 index 00000000..dae6a259 --- /dev/null +++ b/paddler_bootstrap/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "paddler_bootstrap" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "Shared service wiring for Paddler CLI and desktop app" +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +nanoid = { workspace = true } +paddler = { workspace = true } +paddler_types = { workspace = true } +tokio = { workspace = true } + +[features] +default = [] +web_admin_panel = ["paddler/web_admin_panel"] diff --git a/paddler_bootstrap/src/agent.rs b/paddler_bootstrap/src/agent.rs new file mode 100644 index 00000000..7fe0ba98 --- /dev/null +++ b/paddler_bootstrap/src/agent.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use nanoid::nanoid; +use paddler::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; +use paddler::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; +use paddler::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; +use paddler::agent::llamacpp_arbiter_service::LlamaCppArbiterService; +use paddler::agent::management_socket_client_service::ManagementSocketClientService; +use paddler::agent::model_metadata_holder::ModelMetadataHolder; +use paddler::agent::reconciliation_service::ReconciliationService; +use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; +use paddler::agent_desired_state::AgentDesiredState; +use paddler::service_manager::ServiceManager; +use paddler::slot_aggregated_status::SlotAggregatedStatus; +use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; +use tokio::sync::mpsc; + +pub struct Agent { + pub service_manager: ServiceManager, + pub slot_aggregated_status: Arc, +} + +impl Agent { + pub fn bootstrap(agent_name: Option, management_address: String, slots: i32) -> Agent { + let (agent_desired_state_tx, agent_desired_state_rx) = + mpsc::unbounded_channel::(); + let ( + continue_from_conversation_history_request_tx, + continue_from_conversation_history_request_rx, + ) = mpsc::unbounded_channel::(); + let (continue_from_raw_prompt_request_tx, continue_from_raw_prompt_request_rx) = + mpsc::unbounded_channel::(); + let (generate_embedding_batch_request_tx, generate_embedding_batch_request_rx) = + mpsc::unbounded_channel::(); + + let agent_applicable_state_holder = Arc::new(AgentApplicableStateHolder::default()); + let model_metadata_holder = Arc::new(ModelMetadataHolder::default()); + let mut service_manager = ServiceManager::default(); + let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(slots)); + + service_manager.add_service(LlamaCppArbiterService { + agent_applicable_state: None, + agent_applicable_state_holder: agent_applicable_state_holder.clone(), + agent_name: agent_name.clone(), + continue_from_conversation_history_request_rx, + continue_from_raw_prompt_request_rx, + desired_slots_total: slots, + generate_embedding_batch_request_rx, + llamacpp_arbiter_handle: None, + model_metadata_holder: model_metadata_holder.clone(), + slot_aggregated_status_manager: slot_aggregated_status_manager.clone(), + }); + + service_manager.add_service(ManagementSocketClientService { + agent_applicable_state_holder: agent_applicable_state_holder.clone(), + agent_desired_state_tx, + continue_from_conversation_history_request_tx, + continue_from_raw_prompt_request_tx, + generate_embedding_batch_request_tx, + model_metadata_holder, + name: agent_name, + receive_stream_stopper_collection: Default::default(), + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + socket_url: format!( + "ws://{}/api/v1/agent_socket/{}", + management_address, + nanoid!() + ), + }); + + service_manager.add_service(ReconciliationService { + agent_applicable_state_holder, + agent_desired_state: None, + agent_desired_state_rx, + is_converted_to_applicable_state: false, + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + }); + + Agent { + service_manager, + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + } + } +} diff --git a/paddler_bootstrap/src/balancer.rs b/paddler_bootstrap/src/balancer.rs new file mode 100644 index 00000000..dbcb431f --- /dev/null +++ b/paddler_bootstrap/src/balancer.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; +use std::time::Duration; + +use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler::balancer::buffered_request_manager::BufferedRequestManager; +use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; +use paddler::balancer::compatibility::openai_service::OpenAIService; +use paddler::balancer::compatibility::openai_service::configuration::Configuration as OpenAIServiceConfiguration; +use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; +use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; +use paddler::balancer::inference_service::InferenceService; +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::management_service::ManagementService; +use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; +use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; +use paddler::balancer::reconciliation_service::ReconciliationService; +use paddler::balancer::state_database::File; +use paddler::balancer::state_database::Memory; +use paddler::balancer::state_database::StateDatabase; +use paddler::balancer::state_database_type::StateDatabaseType; +#[cfg(feature = "web_admin_panel")] +use paddler::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; +use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; +use paddler::service_manager::ServiceManager; +use tokio::sync::broadcast; + +pub struct Balancer { + pub agent_controller_pool: Arc, + pub buffered_request_manager: Arc, + pub service_manager: ServiceManager, + pub state_database: Arc, +} + +impl Balancer { + pub async fn bootstrap( + inference_service_configuration: InferenceServiceConfiguration, + management_service_configuration: ManagementServiceConfiguration, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: Option< + WebAdminPanelServiceConfiguration, + >, + buffered_request_timeout: Duration, + max_buffered_requests: i32, + openai_service_configuration: Option, + state_database_type: StateDatabaseType, + statsd_prefix: String, + ) -> anyhow::Result { + let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); + + let agent_controller_pool = Arc::new(AgentControllerPool::default()); + let balancer_applicable_state_holder = Arc::new(BalancerApplicableStateHolder::default()); + let buffered_request_manager = Arc::new(BufferedRequestManager::new( + agent_controller_pool.clone(), + buffered_request_timeout, + max_buffered_requests, + )); + let chat_template_override_sender_collection = + Arc::new(ChatTemplateOverrideSenderCollection::default()); + let embedding_sender_collection = Arc::new(EmbeddingSenderCollection::default()); + let generate_tokens_sender_collection = Arc::new(GenerateTokensSenderCollection::default()); + let model_metadata_sender_collection = Arc::new(ModelMetadataSenderCollection::default()); + let mut service_manager = ServiceManager::default(); + let state_database: Arc = match state_database_type { + StateDatabaseType::File(path) => { + Arc::new(File::new(balancer_desired_state_tx.clone(), path)) + } + StateDatabaseType::Memory => Arc::new(Memory::new(balancer_desired_state_tx.clone())), + }; + + service_manager.add_service(InferenceService { + balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), + buffered_request_manager: buffered_request_manager.clone(), + configuration: inference_service_configuration.clone(), + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: web_admin_panel_service_configuration.clone(), + }); + + service_manager.add_service(ManagementService { + agent_controller_pool: agent_controller_pool.clone(), + balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), + buffered_request_manager: buffered_request_manager.clone(), + chat_template_override_sender_collection, + configuration: management_service_configuration, + embedding_sender_collection, + generate_tokens_sender_collection, + model_metadata_sender_collection, + state_database: state_database.clone(), + statsd_prefix, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration, + }); + + service_manager.add_service(ReconciliationService { + agent_controller_pool: agent_controller_pool.clone(), + balancer_applicable_state_holder, + balancer_desired_state: state_database.read_balancer_desired_state().await?, + balancer_desired_state_rx, + is_converted_to_applicable_state: false, + }); + + if let Some(openai_configuration) = openai_service_configuration { + service_manager.add_service(OpenAIService { + buffered_request_manager: buffered_request_manager.clone(), + inference_service_configuration, + openai_service_configuration: openai_configuration, + }); + } + + Ok(Balancer { + agent_controller_pool, + buffered_request_manager, + service_manager, + state_database, + }) + } +} diff --git a/paddler_bootstrap/src/lib.rs b/paddler_bootstrap/src/lib.rs new file mode 100644 index 00000000..592aaa0d --- /dev/null +++ b/paddler_bootstrap/src/lib.rs @@ -0,0 +1,2 @@ +pub mod agent; +pub mod balancer; diff --git a/paddler_cli/Cargo.toml b/paddler_cli/Cargo.toml index 157b7ec6..21d692f4 100644 --- a/paddler_cli/Cargo.toml +++ b/paddler_cli/Cargo.toml @@ -16,6 +16,7 @@ env_logger = { workspace = true } log = { workspace = true } nanoid = { workspace = true } paddler = { workspace = true } +paddler_bootstrap = { workspace = true } tokio = { workspace = true } # web dashboard deps @@ -26,4 +27,5 @@ default = [] web_admin_panel = [ "dep:esbuild-metafile", "paddler/web_admin_panel", + "paddler_bootstrap/web_admin_panel", ] diff --git a/paddler_cli/src/cmd/agent.rs b/paddler_cli/src/cmd/agent.rs index d969fecd..2a4f8d21 100644 --- a/paddler_cli/src/cmd/agent.rs +++ b/paddler_cli/src/cmd/agent.rs @@ -1,22 +1,8 @@ -use std::sync::Arc; - use anyhow::Result; use async_trait::async_trait; use clap::Parser; -use nanoid::nanoid; -use paddler::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; -use paddler::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; -use paddler::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; -use paddler::agent::llamacpp_arbiter_service::LlamaCppArbiterService; -use paddler::agent::management_socket_client_service::ManagementSocketClientService; -use paddler::agent::model_metadata_holder::ModelMetadataHolder; -use paddler::agent::reconciliation_service::ReconciliationService; -use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; -use paddler::agent_desired_state::AgentDesiredState; use paddler::resolved_socket_addr::ResolvedSocketAddr; -use paddler::service_manager::ServiceManager; -use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; -use tokio::sync::mpsc; +use paddler_bootstrap::agent::Agent as BootstrappedAgent; use tokio::sync::oneshot; use super::handler::Handler; @@ -40,64 +26,12 @@ pub struct Agent { #[async_trait] impl Handler for Agent { async fn handle(&self, shutdown_rx: oneshot::Receiver<()>) -> Result<()> { - let (agent_desired_state_tx, agent_desired_state_rx) = - mpsc::unbounded_channel::(); - let ( - continue_from_conversation_history_request_tx, - continue_from_conversation_history_request_rx, - ) = mpsc::unbounded_channel::(); - let (continue_from_raw_prompt_request_tx, continue_from_raw_prompt_request_rx) = - mpsc::unbounded_channel::(); - let (generate_embedding_batch_request_tx, generate_embedding_batch_request_rx) = - mpsc::unbounded_channel::(); - - let agent_applicable_state_holder = Arc::new(AgentApplicableStateHolder::default()); - let model_metadata_holder = Arc::new(ModelMetadataHolder::default()); - let mut service_manager = ServiceManager::default(); - let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(self.slots)); - - service_manager.add_service(LlamaCppArbiterService { - agent_applicable_state: None, - agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_name: self.name.clone(), - continue_from_conversation_history_request_rx, - continue_from_raw_prompt_request_rx, - desired_slots_total: self.slots, - generate_embedding_batch_request_rx, - llamacpp_arbiter_handle: None, - model_metadata_holder: model_metadata_holder.clone(), - slot_aggregated_status_manager: slot_aggregated_status_manager.clone(), - }); - - service_manager.add_service(ManagementSocketClientService { - agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_desired_state_tx, - continue_from_conversation_history_request_tx, - continue_from_raw_prompt_request_tx, - generate_embedding_batch_request_tx, - model_metadata_holder, - name: self.name.clone(), - receive_stream_stopper_collection: Default::default(), - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - socket_url: format!( - "ws://{}/api/v1/agent_socket/{}", - self.management_addr.socket_addr, - nanoid!() - ), - }); - - service_manager.add_service(ReconciliationService { - agent_applicable_state_holder, - agent_desired_state: None, - agent_desired_state_rx, - is_converted_to_applicable_state: false, - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - }); + let bootstrapped = BootstrappedAgent::bootstrap( + self.name.clone(), + self.management_addr.socket_addr.to_string(), + self.slots, + ); - service_manager.run_forever(shutdown_rx).await + bootstrapped.service_manager.run_forever(shutdown_rx).await } } diff --git a/paddler_cli/src/cmd/balancer.rs b/paddler_cli/src/cmd/balancer.rs index 40709ce3..bbc54c5d 100644 --- a/paddler_cli/src/cmd/balancer.rs +++ b/paddler_cli/src/cmd/balancer.rs @@ -1,25 +1,11 @@ -use std::sync::Arc; use std::time::Duration; use anyhow::Result; use async_trait::async_trait; use clap::Parser; -use paddler::balancer::agent_controller_pool::AgentControllerPool; -use paddler::balancer::buffered_request_manager::BufferedRequestManager; -use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; -use paddler::balancer::compatibility::openai_service::OpenAIService; use paddler::balancer::compatibility::openai_service::configuration::Configuration as OpenAIServiceConfiguration; -use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; -use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; -use paddler::balancer::inference_service::InferenceService; use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; -use paddler::balancer::management_service::ManagementService; use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; -use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; -use paddler::balancer::reconciliation_service::ReconciliationService; -use paddler::balancer::state_database::File; -use paddler::balancer::state_database::Memory; -use paddler::balancer::state_database::StateDatabase; use paddler::balancer::state_database_type::StateDatabaseType; use paddler::balancer::statsd_service::StatsdService; use paddler::balancer::statsd_service::configuration::Configuration as StatsdServiceConfiguration; @@ -29,10 +15,8 @@ use paddler::balancer::web_admin_panel_service::WebAdminPanelService; use paddler::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; #[cfg(feature = "web_admin_panel")] use paddler::balancer::web_admin_panel_service::template_data::TemplateData; -use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; use paddler::resolved_socket_addr::ResolvedSocketAddr; -use paddler::service_manager::ServiceManager; -use tokio::sync::broadcast; +use paddler_bootstrap::balancer::Balancer as BootstrappedBalancer; use tokio::sync::oneshot; use super::handler::Handler; @@ -118,6 +102,14 @@ impl Balancer { } } + fn get_openai_service_configuration(&self) -> Option { + self.compat_openai_addr + .clone() + .map(|compat_openai_addr| OpenAIServiceConfiguration { + addr: compat_openai_addr.socket_addr, + }) + } + #[cfg(feature = "web_admin_panel")] fn get_web_admin_panel_service_configuration( &self, @@ -143,64 +135,23 @@ impl Balancer { #[async_trait] impl Handler for Balancer { async fn handle(&self, shutdown_rx: oneshot::Receiver<()>) -> Result<()> { - let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); - - let agent_controller_pool = Arc::new(AgentControllerPool::default()); - let balancer_applicable_state_holder = Arc::new(BalancerApplicableStateHolder::default()); - let buffered_request_manager = Arc::new(BufferedRequestManager::new( - agent_controller_pool.clone(), + let mut bootstrapped = BootstrappedBalancer::bootstrap( + self.get_inference_service_configuration(), + self.get_management_service_configuration(), + #[cfg(feature = "web_admin_panel")] + self.get_web_admin_panel_service_configuration(), self.buffered_request_timeout, self.max_buffered_requests, - )); - let chat_template_override_sender_collection = - Arc::new(ChatTemplateOverrideSenderCollection::default()); - let embedding_sender_collection = Arc::new(EmbeddingSenderCollection::default()); - let generate_tokens_sender_collection = Arc::new(GenerateTokensSenderCollection::default()); - let model_metadata_sender_collection = Arc::new(ModelMetadataSenderCollection::default()); - let mut service_manager = ServiceManager::default(); - let state_database: Arc = match &self.state_database { - StateDatabaseType::File(path) => Arc::new(File::new( - balancer_desired_state_tx.clone(), - path.to_owned(), - )), - StateDatabaseType::Memory => Arc::new(Memory::new(balancer_desired_state_tx.clone())), - }; - - service_manager.add_service(InferenceService { - balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), - buffered_request_manager: buffered_request_manager.clone(), - configuration: self.get_inference_service_configuration(), - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration: self.get_web_admin_panel_service_configuration(), - }); - - service_manager.add_service(ManagementService { - agent_controller_pool: agent_controller_pool.clone(), - balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), - buffered_request_manager: buffered_request_manager.clone(), - chat_template_override_sender_collection, - configuration: self.get_management_service_configuration(), - embedding_sender_collection, - generate_tokens_sender_collection, - model_metadata_sender_collection, - state_database: state_database.clone(), - statsd_prefix: self.statsd_prefix.clone(), - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration: self.get_web_admin_panel_service_configuration(), - }); - - service_manager.add_service(ReconciliationService { - agent_controller_pool: agent_controller_pool.clone(), - balancer_applicable_state_holder, - balancer_desired_state: state_database.read_balancer_desired_state().await?, - balancer_desired_state_rx, - is_converted_to_applicable_state: false, - }); + self.get_openai_service_configuration(), + self.state_database.clone(), + self.statsd_prefix.clone(), + ) + .await?; if let Some(statsd_addr) = self.statsd_addr.clone() { - service_manager.add_service(StatsdService { - agent_controller_pool, - buffered_request_manager: buffered_request_manager.clone(), + bootstrapped.service_manager.add_service(StatsdService { + agent_controller_pool: bootstrapped.agent_controller_pool.clone(), + buffered_request_manager: bootstrapped.buffered_request_manager.clone(), configuration: StatsdServiceConfiguration { statsd_addr: statsd_addr.socket_addr, statsd_prefix: self.statsd_prefix.clone(), @@ -211,19 +162,11 @@ impl Handler for Balancer { #[cfg(feature = "web_admin_panel")] if let Some(configuration) = self.get_web_admin_panel_service_configuration() { - service_manager.add_service(WebAdminPanelService { configuration }); - } - - if let Some(compat_openai_addr) = self.compat_openai_addr.clone() { - service_manager.add_service(OpenAIService { - buffered_request_manager, - inference_service_configuration: self.get_inference_service_configuration(), - openai_service_configuration: OpenAIServiceConfiguration { - addr: compat_openai_addr.socket_addr, - }, - }); + bootstrapped + .service_manager + .add_service(WebAdminPanelService { configuration }); } - service_manager.run_forever(shutdown_rx).await + bootstrapped.service_manager.run_forever(shutdown_rx).await } } From c014bb403f2ef2e853d68e8742789f445079e646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 24 Mar 2026 03:24:34 +0100 Subject: [PATCH 24/46] move bootstrap parameters to params structs --- paddler_bootstrap/src/agent.rs | 9 ++++++- paddler_bootstrap/src/agent_params.rs | 5 ++++ paddler_bootstrap/src/balancer.rs | 32 +++++++++++------------- paddler_bootstrap/src/balancer_params.rs | 20 +++++++++++++++ paddler_bootstrap/src/lib.rs | 2 ++ paddler_cli/src/cmd/agent.rs | 11 ++++---- paddler_cli/src/cmd/balancer.rs | 21 ++++++++-------- 7 files changed, 66 insertions(+), 34 deletions(-) create mode 100644 paddler_bootstrap/src/agent_params.rs create mode 100644 paddler_bootstrap/src/balancer_params.rs diff --git a/paddler_bootstrap/src/agent.rs b/paddler_bootstrap/src/agent.rs index 7fe0ba98..9c1c1414 100644 --- a/paddler_bootstrap/src/agent.rs +++ b/paddler_bootstrap/src/agent.rs @@ -15,13 +15,20 @@ use paddler::slot_aggregated_status::SlotAggregatedStatus; use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; use tokio::sync::mpsc; +use super::agent_params::AgentParams; + pub struct Agent { pub service_manager: ServiceManager, pub slot_aggregated_status: Arc, } impl Agent { - pub fn bootstrap(agent_name: Option, management_address: String, slots: i32) -> Agent { + pub fn bootstrap(params: AgentParams) -> Agent { + let AgentParams { + agent_name, + management_address, + slots, + } = params; let (agent_desired_state_tx, agent_desired_state_rx) = mpsc::unbounded_channel::(); let ( diff --git a/paddler_bootstrap/src/agent_params.rs b/paddler_bootstrap/src/agent_params.rs new file mode 100644 index 00000000..8df4dd1d --- /dev/null +++ b/paddler_bootstrap/src/agent_params.rs @@ -0,0 +1,5 @@ +pub struct AgentParams { + pub agent_name: Option, + pub management_address: String, + pub slots: i32, +} diff --git a/paddler_bootstrap/src/balancer.rs b/paddler_bootstrap/src/balancer.rs index dbcb431f..1355d288 100644 --- a/paddler_bootstrap/src/balancer.rs +++ b/paddler_bootstrap/src/balancer.rs @@ -1,29 +1,25 @@ use std::sync::Arc; -use std::time::Duration; use paddler::balancer::agent_controller_pool::AgentControllerPool; use paddler::balancer::buffered_request_manager::BufferedRequestManager; use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; use paddler::balancer::compatibility::openai_service::OpenAIService; -use paddler::balancer::compatibility::openai_service::configuration::Configuration as OpenAIServiceConfiguration; use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; use paddler::balancer::inference_service::InferenceService; -use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; use paddler::balancer::management_service::ManagementService; -use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; use paddler::balancer::reconciliation_service::ReconciliationService; use paddler::balancer::state_database::File; use paddler::balancer::state_database::Memory; use paddler::balancer::state_database::StateDatabase; use paddler::balancer::state_database_type::StateDatabaseType; -#[cfg(feature = "web_admin_panel")] -use paddler::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; use paddler::service_manager::ServiceManager; use tokio::sync::broadcast; +use super::balancer_params::BalancerParams; + pub struct Balancer { pub agent_controller_pool: Arc, pub buffered_request_manager: Arc, @@ -32,18 +28,18 @@ pub struct Balancer { } impl Balancer { - pub async fn bootstrap( - inference_service_configuration: InferenceServiceConfiguration, - management_service_configuration: ManagementServiceConfiguration, - #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: Option< - WebAdminPanelServiceConfiguration, - >, - buffered_request_timeout: Duration, - max_buffered_requests: i32, - openai_service_configuration: Option, - state_database_type: StateDatabaseType, - statsd_prefix: String, - ) -> anyhow::Result { + pub async fn bootstrap(params: BalancerParams) -> anyhow::Result { + let BalancerParams { + buffered_request_timeout, + inference_service_configuration, + management_service_configuration, + max_buffered_requests, + openai_service_configuration, + state_database_type, + statsd_prefix, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration, + } = params; let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); let agent_controller_pool = Arc::new(AgentControllerPool::default()); diff --git a/paddler_bootstrap/src/balancer_params.rs b/paddler_bootstrap/src/balancer_params.rs new file mode 100644 index 00000000..61ce97e7 --- /dev/null +++ b/paddler_bootstrap/src/balancer_params.rs @@ -0,0 +1,20 @@ +use std::time::Duration; + +use paddler::balancer::compatibility::openai_service::configuration::Configuration as OpenAIServiceConfiguration; +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; +use paddler::balancer::state_database_type::StateDatabaseType; +#[cfg(feature = "web_admin_panel")] +use paddler::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; + +pub struct BalancerParams { + pub buffered_request_timeout: Duration, + pub inference_service_configuration: InferenceServiceConfiguration, + pub management_service_configuration: ManagementServiceConfiguration, + pub max_buffered_requests: i32, + pub openai_service_configuration: Option, + pub state_database_type: StateDatabaseType, + pub statsd_prefix: String, + #[cfg(feature = "web_admin_panel")] + pub web_admin_panel_service_configuration: Option, +} diff --git a/paddler_bootstrap/src/lib.rs b/paddler_bootstrap/src/lib.rs index 592aaa0d..76d9f88c 100644 --- a/paddler_bootstrap/src/lib.rs +++ b/paddler_bootstrap/src/lib.rs @@ -1,2 +1,4 @@ pub mod agent; +pub mod agent_params; pub mod balancer; +pub mod balancer_params; diff --git a/paddler_cli/src/cmd/agent.rs b/paddler_cli/src/cmd/agent.rs index 2a4f8d21..c463e1cb 100644 --- a/paddler_cli/src/cmd/agent.rs +++ b/paddler_cli/src/cmd/agent.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use clap::Parser; use paddler::resolved_socket_addr::ResolvedSocketAddr; use paddler_bootstrap::agent::Agent as BootstrappedAgent; +use paddler_bootstrap::agent_params::AgentParams; use tokio::sync::oneshot; use super::handler::Handler; @@ -26,11 +27,11 @@ pub struct Agent { #[async_trait] impl Handler for Agent { async fn handle(&self, shutdown_rx: oneshot::Receiver<()>) -> Result<()> { - let bootstrapped = BootstrappedAgent::bootstrap( - self.name.clone(), - self.management_addr.socket_addr.to_string(), - self.slots, - ); + let bootstrapped = BootstrappedAgent::bootstrap(AgentParams { + agent_name: self.name.clone(), + management_address: self.management_addr.socket_addr.to_string(), + slots: self.slots, + }); bootstrapped.service_manager.run_forever(shutdown_rx).await } diff --git a/paddler_cli/src/cmd/balancer.rs b/paddler_cli/src/cmd/balancer.rs index bbc54c5d..7421ae1e 100644 --- a/paddler_cli/src/cmd/balancer.rs +++ b/paddler_cli/src/cmd/balancer.rs @@ -17,6 +17,7 @@ use paddler::balancer::web_admin_panel_service::configuration::Configuration as use paddler::balancer::web_admin_panel_service::template_data::TemplateData; use paddler::resolved_socket_addr::ResolvedSocketAddr; use paddler_bootstrap::balancer::Balancer as BootstrappedBalancer; +use paddler_bootstrap::balancer_params::BalancerParams; use tokio::sync::oneshot; use super::handler::Handler; @@ -135,17 +136,17 @@ impl Balancer { #[async_trait] impl Handler for Balancer { async fn handle(&self, shutdown_rx: oneshot::Receiver<()>) -> Result<()> { - let mut bootstrapped = BootstrappedBalancer::bootstrap( - self.get_inference_service_configuration(), - self.get_management_service_configuration(), + let mut bootstrapped = BootstrappedBalancer::bootstrap(BalancerParams { + buffered_request_timeout: self.buffered_request_timeout, + inference_service_configuration: self.get_inference_service_configuration(), + management_service_configuration: self.get_management_service_configuration(), + max_buffered_requests: self.max_buffered_requests, + openai_service_configuration: self.get_openai_service_configuration(), + state_database_type: self.state_database.clone(), + statsd_prefix: self.statsd_prefix.clone(), #[cfg(feature = "web_admin_panel")] - self.get_web_admin_panel_service_configuration(), - self.buffered_request_timeout, - self.max_buffered_requests, - self.get_openai_service_configuration(), - self.state_database.clone(), - self.statsd_prefix.clone(), - ) + web_admin_panel_service_configuration: self.get_web_admin_panel_service_configuration(), + }) .await?; if let Some(statsd_addr) = self.statsd_addr.clone() { From eb0c3c6474e9d8d788efb48772ce88b4e0450f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 24 Mar 2026 15:41:31 +0100 Subject: [PATCH 25/46] rename bootstrap types and extract bootstrap into standalone functions --- paddler_bootstrap/src/agent.rs | 97 --------------- paddler_bootstrap/src/balancer.rs | 111 ------------------ ...nt_params.rs => bootstrap_agent_params.rs} | 2 +- ...params.rs => bootstrap_balancer_params.rs} | 2 +- .../src/bootstrapped_agent_handle.rs | 96 +++++++++++++++ .../src/bootstrapped_balancer_handle.rs | 110 +++++++++++++++++ paddler_bootstrap/src/lib.rs | 8 +- paddler_cli/src/cmd/agent.rs | 6 +- paddler_cli/src/cmd/balancer.rs | 6 +- 9 files changed, 218 insertions(+), 220 deletions(-) delete mode 100644 paddler_bootstrap/src/agent.rs delete mode 100644 paddler_bootstrap/src/balancer.rs rename paddler_bootstrap/src/{agent_params.rs => bootstrap_agent_params.rs} (73%) rename paddler_bootstrap/src/{balancer_params.rs => bootstrap_balancer_params.rs} (96%) create mode 100644 paddler_bootstrap/src/bootstrapped_agent_handle.rs create mode 100644 paddler_bootstrap/src/bootstrapped_balancer_handle.rs diff --git a/paddler_bootstrap/src/agent.rs b/paddler_bootstrap/src/agent.rs deleted file mode 100644 index 9c1c1414..00000000 --- a/paddler_bootstrap/src/agent.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::sync::Arc; - -use nanoid::nanoid; -use paddler::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; -use paddler::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; -use paddler::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; -use paddler::agent::llamacpp_arbiter_service::LlamaCppArbiterService; -use paddler::agent::management_socket_client_service::ManagementSocketClientService; -use paddler::agent::model_metadata_holder::ModelMetadataHolder; -use paddler::agent::reconciliation_service::ReconciliationService; -use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; -use paddler::agent_desired_state::AgentDesiredState; -use paddler::service_manager::ServiceManager; -use paddler::slot_aggregated_status::SlotAggregatedStatus; -use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; -use tokio::sync::mpsc; - -use super::agent_params::AgentParams; - -pub struct Agent { - pub service_manager: ServiceManager, - pub slot_aggregated_status: Arc, -} - -impl Agent { - pub fn bootstrap(params: AgentParams) -> Agent { - let AgentParams { - agent_name, - management_address, - slots, - } = params; - let (agent_desired_state_tx, agent_desired_state_rx) = - mpsc::unbounded_channel::(); - let ( - continue_from_conversation_history_request_tx, - continue_from_conversation_history_request_rx, - ) = mpsc::unbounded_channel::(); - let (continue_from_raw_prompt_request_tx, continue_from_raw_prompt_request_rx) = - mpsc::unbounded_channel::(); - let (generate_embedding_batch_request_tx, generate_embedding_batch_request_rx) = - mpsc::unbounded_channel::(); - - let agent_applicable_state_holder = Arc::new(AgentApplicableStateHolder::default()); - let model_metadata_holder = Arc::new(ModelMetadataHolder::default()); - let mut service_manager = ServiceManager::default(); - let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(slots)); - - service_manager.add_service(LlamaCppArbiterService { - agent_applicable_state: None, - agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_name: agent_name.clone(), - continue_from_conversation_history_request_rx, - continue_from_raw_prompt_request_rx, - desired_slots_total: slots, - generate_embedding_batch_request_rx, - llamacpp_arbiter_handle: None, - model_metadata_holder: model_metadata_holder.clone(), - slot_aggregated_status_manager: slot_aggregated_status_manager.clone(), - }); - - service_manager.add_service(ManagementSocketClientService { - agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_desired_state_tx, - continue_from_conversation_history_request_tx, - continue_from_raw_prompt_request_tx, - generate_embedding_batch_request_tx, - model_metadata_holder, - name: agent_name, - receive_stream_stopper_collection: Default::default(), - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - socket_url: format!( - "ws://{}/api/v1/agent_socket/{}", - management_address, - nanoid!() - ), - }); - - service_manager.add_service(ReconciliationService { - agent_applicable_state_holder, - agent_desired_state: None, - agent_desired_state_rx, - is_converted_to_applicable_state: false, - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - }); - - Agent { - service_manager, - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - } - } -} diff --git a/paddler_bootstrap/src/balancer.rs b/paddler_bootstrap/src/balancer.rs deleted file mode 100644 index 1355d288..00000000 --- a/paddler_bootstrap/src/balancer.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::sync::Arc; - -use paddler::balancer::agent_controller_pool::AgentControllerPool; -use paddler::balancer::buffered_request_manager::BufferedRequestManager; -use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; -use paddler::balancer::compatibility::openai_service::OpenAIService; -use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; -use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; -use paddler::balancer::inference_service::InferenceService; -use paddler::balancer::management_service::ManagementService; -use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; -use paddler::balancer::reconciliation_service::ReconciliationService; -use paddler::balancer::state_database::File; -use paddler::balancer::state_database::Memory; -use paddler::balancer::state_database::StateDatabase; -use paddler::balancer::state_database_type::StateDatabaseType; -use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; -use paddler::service_manager::ServiceManager; -use tokio::sync::broadcast; - -use super::balancer_params::BalancerParams; - -pub struct Balancer { - pub agent_controller_pool: Arc, - pub buffered_request_manager: Arc, - pub service_manager: ServiceManager, - pub state_database: Arc, -} - -impl Balancer { - pub async fn bootstrap(params: BalancerParams) -> anyhow::Result { - let BalancerParams { - buffered_request_timeout, - inference_service_configuration, - management_service_configuration, - max_buffered_requests, - openai_service_configuration, - state_database_type, - statsd_prefix, - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration, - } = params; - let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); - - let agent_controller_pool = Arc::new(AgentControllerPool::default()); - let balancer_applicable_state_holder = Arc::new(BalancerApplicableStateHolder::default()); - let buffered_request_manager = Arc::new(BufferedRequestManager::new( - agent_controller_pool.clone(), - buffered_request_timeout, - max_buffered_requests, - )); - let chat_template_override_sender_collection = - Arc::new(ChatTemplateOverrideSenderCollection::default()); - let embedding_sender_collection = Arc::new(EmbeddingSenderCollection::default()); - let generate_tokens_sender_collection = Arc::new(GenerateTokensSenderCollection::default()); - let model_metadata_sender_collection = Arc::new(ModelMetadataSenderCollection::default()); - let mut service_manager = ServiceManager::default(); - let state_database: Arc = match state_database_type { - StateDatabaseType::File(path) => { - Arc::new(File::new(balancer_desired_state_tx.clone(), path)) - } - StateDatabaseType::Memory => Arc::new(Memory::new(balancer_desired_state_tx.clone())), - }; - - service_manager.add_service(InferenceService { - balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), - buffered_request_manager: buffered_request_manager.clone(), - configuration: inference_service_configuration.clone(), - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration: web_admin_panel_service_configuration.clone(), - }); - - service_manager.add_service(ManagementService { - agent_controller_pool: agent_controller_pool.clone(), - balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), - buffered_request_manager: buffered_request_manager.clone(), - chat_template_override_sender_collection, - configuration: management_service_configuration, - embedding_sender_collection, - generate_tokens_sender_collection, - model_metadata_sender_collection, - state_database: state_database.clone(), - statsd_prefix, - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration, - }); - - service_manager.add_service(ReconciliationService { - agent_controller_pool: agent_controller_pool.clone(), - balancer_applicable_state_holder, - balancer_desired_state: state_database.read_balancer_desired_state().await?, - balancer_desired_state_rx, - is_converted_to_applicable_state: false, - }); - - if let Some(openai_configuration) = openai_service_configuration { - service_manager.add_service(OpenAIService { - buffered_request_manager: buffered_request_manager.clone(), - inference_service_configuration, - openai_service_configuration: openai_configuration, - }); - } - - Ok(Balancer { - agent_controller_pool, - buffered_request_manager, - service_manager, - state_database, - }) - } -} diff --git a/paddler_bootstrap/src/agent_params.rs b/paddler_bootstrap/src/bootstrap_agent_params.rs similarity index 73% rename from paddler_bootstrap/src/agent_params.rs rename to paddler_bootstrap/src/bootstrap_agent_params.rs index 8df4dd1d..899aa987 100644 --- a/paddler_bootstrap/src/agent_params.rs +++ b/paddler_bootstrap/src/bootstrap_agent_params.rs @@ -1,4 +1,4 @@ -pub struct AgentParams { +pub struct BootstrapAgentParams { pub agent_name: Option, pub management_address: String, pub slots: i32, diff --git a/paddler_bootstrap/src/balancer_params.rs b/paddler_bootstrap/src/bootstrap_balancer_params.rs similarity index 96% rename from paddler_bootstrap/src/balancer_params.rs rename to paddler_bootstrap/src/bootstrap_balancer_params.rs index 61ce97e7..374c3739 100644 --- a/paddler_bootstrap/src/balancer_params.rs +++ b/paddler_bootstrap/src/bootstrap_balancer_params.rs @@ -7,7 +7,7 @@ use paddler::balancer::state_database_type::StateDatabaseType; #[cfg(feature = "web_admin_panel")] use paddler::balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; -pub struct BalancerParams { +pub struct BootstrapBalancerParams { pub buffered_request_timeout: Duration, pub inference_service_configuration: InferenceServiceConfiguration, pub management_service_configuration: ManagementServiceConfiguration, diff --git a/paddler_bootstrap/src/bootstrapped_agent_handle.rs b/paddler_bootstrap/src/bootstrapped_agent_handle.rs new file mode 100644 index 00000000..5f169450 --- /dev/null +++ b/paddler_bootstrap/src/bootstrapped_agent_handle.rs @@ -0,0 +1,96 @@ +use std::sync::Arc; + +use nanoid::nanoid; +use paddler::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; +use paddler::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; +use paddler::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; +use paddler::agent::llamacpp_arbiter_service::LlamaCppArbiterService; +use paddler::agent::management_socket_client_service::ManagementSocketClientService; +use paddler::agent::model_metadata_holder::ModelMetadataHolder; +use paddler::agent::reconciliation_service::ReconciliationService; +use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; +use paddler::agent_desired_state::AgentDesiredState; +use paddler::service_manager::ServiceManager; +use paddler::slot_aggregated_status::SlotAggregatedStatus; +use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; +use tokio::sync::mpsc; + +use super::bootstrap_agent_params::BootstrapAgentParams; + +pub struct BootstrappedAgentHandle { + pub service_manager: ServiceManager, + pub slot_aggregated_status: Arc, +} + +pub fn bootstrap_agent( + BootstrapAgentParams { + agent_name, + management_address, + slots, + }: BootstrapAgentParams, +) -> BootstrappedAgentHandle { + let (agent_desired_state_tx, agent_desired_state_rx) = + mpsc::unbounded_channel::(); + let ( + continue_from_conversation_history_request_tx, + continue_from_conversation_history_request_rx, + ) = mpsc::unbounded_channel::(); + let (continue_from_raw_prompt_request_tx, continue_from_raw_prompt_request_rx) = + mpsc::unbounded_channel::(); + let (generate_embedding_batch_request_tx, generate_embedding_batch_request_rx) = + mpsc::unbounded_channel::(); + + let agent_applicable_state_holder = Arc::new(AgentApplicableStateHolder::default()); + let model_metadata_holder = Arc::new(ModelMetadataHolder::default()); + let mut service_manager = ServiceManager::default(); + let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(slots)); + + service_manager.add_service(LlamaCppArbiterService { + agent_applicable_state: None, + agent_applicable_state_holder: agent_applicable_state_holder.clone(), + agent_name: agent_name.clone(), + continue_from_conversation_history_request_rx, + continue_from_raw_prompt_request_rx, + desired_slots_total: slots, + generate_embedding_batch_request_rx, + llamacpp_arbiter_handle: None, + model_metadata_holder: model_metadata_holder.clone(), + slot_aggregated_status_manager: slot_aggregated_status_manager.clone(), + }); + + service_manager.add_service(ManagementSocketClientService { + agent_applicable_state_holder: agent_applicable_state_holder.clone(), + agent_desired_state_tx, + continue_from_conversation_history_request_tx, + continue_from_raw_prompt_request_tx, + generate_embedding_batch_request_tx, + model_metadata_holder, + name: agent_name, + receive_stream_stopper_collection: Default::default(), + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + socket_url: format!( + "ws://{}/api/v1/agent_socket/{}", + management_address, + nanoid!() + ), + }); + + service_manager.add_service(ReconciliationService { + agent_applicable_state_holder, + agent_desired_state: None, + agent_desired_state_rx, + is_converted_to_applicable_state: false, + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + }); + + BootstrappedAgentHandle { + service_manager, + slot_aggregated_status: slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + } +} diff --git a/paddler_bootstrap/src/bootstrapped_balancer_handle.rs b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs new file mode 100644 index 00000000..ee85d0cd --- /dev/null +++ b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler::balancer::buffered_request_manager::BufferedRequestManager; +use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; +use paddler::balancer::compatibility::openai_service::OpenAIService; +use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; +use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; +use paddler::balancer::inference_service::InferenceService; +use paddler::balancer::management_service::ManagementService; +use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; +use paddler::balancer::reconciliation_service::ReconciliationService; +use paddler::balancer::state_database::File; +use paddler::balancer::state_database::Memory; +use paddler::balancer::state_database::StateDatabase; +use paddler::balancer::state_database_type::StateDatabaseType; +use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; +use paddler::service_manager::ServiceManager; +use tokio::sync::broadcast; + +use super::bootstrap_balancer_params::BootstrapBalancerParams; + +pub struct BootstrappedBalancerHandle { + pub agent_controller_pool: Arc, + pub buffered_request_manager: Arc, + pub service_manager: ServiceManager, + pub state_database: Arc, +} + +pub async fn bootstrap_balancer( + BootstrapBalancerParams { + buffered_request_timeout, + inference_service_configuration, + management_service_configuration, + max_buffered_requests, + openai_service_configuration, + state_database_type, + statsd_prefix, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration, + }: BootstrapBalancerParams, +) -> anyhow::Result { + let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); + + let agent_controller_pool = Arc::new(AgentControllerPool::default()); + let balancer_applicable_state_holder = Arc::new(BalancerApplicableStateHolder::default()); + let buffered_request_manager = Arc::new(BufferedRequestManager::new( + agent_controller_pool.clone(), + buffered_request_timeout, + max_buffered_requests, + )); + let chat_template_override_sender_collection = + Arc::new(ChatTemplateOverrideSenderCollection::default()); + let embedding_sender_collection = Arc::new(EmbeddingSenderCollection::default()); + let generate_tokens_sender_collection = Arc::new(GenerateTokensSenderCollection::default()); + let model_metadata_sender_collection = Arc::new(ModelMetadataSenderCollection::default()); + let mut service_manager = ServiceManager::default(); + let state_database: Arc = match state_database_type { + StateDatabaseType::File(path) => { + Arc::new(File::new(balancer_desired_state_tx.clone(), path)) + } + StateDatabaseType::Memory => Arc::new(Memory::new(balancer_desired_state_tx.clone())), + }; + + service_manager.add_service(InferenceService { + balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), + buffered_request_manager: buffered_request_manager.clone(), + configuration: inference_service_configuration.clone(), + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: web_admin_panel_service_configuration.clone(), + }); + + service_manager.add_service(ManagementService { + agent_controller_pool: agent_controller_pool.clone(), + balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), + buffered_request_manager: buffered_request_manager.clone(), + chat_template_override_sender_collection, + configuration: management_service_configuration, + embedding_sender_collection, + generate_tokens_sender_collection, + model_metadata_sender_collection, + state_database: state_database.clone(), + statsd_prefix, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration, + }); + + service_manager.add_service(ReconciliationService { + agent_controller_pool: agent_controller_pool.clone(), + balancer_applicable_state_holder, + balancer_desired_state: state_database.read_balancer_desired_state().await?, + balancer_desired_state_rx, + is_converted_to_applicable_state: false, + }); + + if let Some(openai_configuration) = openai_service_configuration { + service_manager.add_service(OpenAIService { + buffered_request_manager: buffered_request_manager.clone(), + inference_service_configuration, + openai_service_configuration: openai_configuration, + }); + } + + Ok(BootstrappedBalancerHandle { + agent_controller_pool, + buffered_request_manager, + service_manager, + state_database, + }) +} diff --git a/paddler_bootstrap/src/lib.rs b/paddler_bootstrap/src/lib.rs index 76d9f88c..5916f33f 100644 --- a/paddler_bootstrap/src/lib.rs +++ b/paddler_bootstrap/src/lib.rs @@ -1,4 +1,4 @@ -pub mod agent; -pub mod agent_params; -pub mod balancer; -pub mod balancer_params; +pub mod bootstrap_agent_params; +pub mod bootstrap_balancer_params; +pub mod bootstrapped_agent_handle; +pub mod bootstrapped_balancer_handle; diff --git a/paddler_cli/src/cmd/agent.rs b/paddler_cli/src/cmd/agent.rs index c463e1cb..303795ee 100644 --- a/paddler_cli/src/cmd/agent.rs +++ b/paddler_cli/src/cmd/agent.rs @@ -2,8 +2,8 @@ use anyhow::Result; use async_trait::async_trait; use clap::Parser; use paddler::resolved_socket_addr::ResolvedSocketAddr; -use paddler_bootstrap::agent::Agent as BootstrappedAgent; -use paddler_bootstrap::agent_params::AgentParams; +use paddler_bootstrap::bootstrap_agent_params::BootstrapAgentParams; +use paddler_bootstrap::bootstrapped_agent_handle::bootstrap_agent; use tokio::sync::oneshot; use super::handler::Handler; @@ -27,7 +27,7 @@ pub struct Agent { #[async_trait] impl Handler for Agent { async fn handle(&self, shutdown_rx: oneshot::Receiver<()>) -> Result<()> { - let bootstrapped = BootstrappedAgent::bootstrap(AgentParams { + let bootstrapped = bootstrap_agent(BootstrapAgentParams { agent_name: self.name.clone(), management_address: self.management_addr.socket_addr.to_string(), slots: self.slots, diff --git a/paddler_cli/src/cmd/balancer.rs b/paddler_cli/src/cmd/balancer.rs index 7421ae1e..737d0ac1 100644 --- a/paddler_cli/src/cmd/balancer.rs +++ b/paddler_cli/src/cmd/balancer.rs @@ -16,8 +16,8 @@ use paddler::balancer::web_admin_panel_service::configuration::Configuration as #[cfg(feature = "web_admin_panel")] use paddler::balancer::web_admin_panel_service::template_data::TemplateData; use paddler::resolved_socket_addr::ResolvedSocketAddr; -use paddler_bootstrap::balancer::Balancer as BootstrappedBalancer; -use paddler_bootstrap::balancer_params::BalancerParams; +use paddler_bootstrap::bootstrap_balancer_params::BootstrapBalancerParams; +use paddler_bootstrap::bootstrapped_balancer_handle::bootstrap_balancer; use tokio::sync::oneshot; use super::handler::Handler; @@ -136,7 +136,7 @@ impl Balancer { #[async_trait] impl Handler for Balancer { async fn handle(&self, shutdown_rx: oneshot::Receiver<()>) -> Result<()> { - let mut bootstrapped = BootstrappedBalancer::bootstrap(BalancerParams { + let mut bootstrapped = bootstrap_balancer(BootstrapBalancerParams { buffered_request_timeout: self.buffered_request_timeout, inference_service_configuration: self.get_inference_service_configuration(), management_service_configuration: self.get_management_service_configuration(), From 7427713e5cfe7b895dbc4e9c1090da5f60297e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 24 Mar 2026 19:26:03 +0100 Subject: [PATCH 26/46] replace manual thread spawning with tokio::task::spawn_blocking in desktop app service bridges --- .../src/backend/start_agent.rs | 20 +++++++------------ .../src/backend/start_balancer.rs | 20 +++++++------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/paddler_second_brain_gui/src/backend/start_agent.rs b/paddler_second_brain_gui/src/backend/start_agent.rs index af29c1b7..8f81f118 100644 --- a/paddler_second_brain_gui/src/backend/start_agent.rs +++ b/paddler_second_brain_gui/src/backend/start_agent.rs @@ -11,23 +11,17 @@ pub async fn start_agent( agent_status_tx: mpsc::UnboundedSender, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { - let (result_tx, result_rx) = oneshot::channel(); - - std::thread::spawn(move || { + tokio::task::spawn_blocking(move || { let system = actix_web::rt::System::new(); - let result = system.block_on(start_agent_services( + + system.block_on(start_agent_services( agent_name, management_address, slots, agent_status_tx, shutdown_rx, - )); - if let Err(unsent_result) = result_tx.send(result) { - log::error!("Failed to send agent result: {unsent_result:?}"); - } - }); - - result_rx - .await - .map_err(|error| anyhow::anyhow!("Agent thread terminated: {error}"))? + )) + }) + .await + .map_err(|error| anyhow::anyhow!("Agent task panicked: {error}"))? } diff --git a/paddler_second_brain_gui/src/backend/start_balancer.rs b/paddler_second_brain_gui/src/backend/start_balancer.rs index 8feccc18..fc4130a8 100644 --- a/paddler_second_brain_gui/src/backend/start_balancer.rs +++ b/paddler_second_brain_gui/src/backend/start_balancer.rs @@ -14,23 +14,17 @@ pub async fn start_balancer( agent_snapshots_tx: mpsc::UnboundedSender>, shutdown_rx: oneshot::Receiver<()>, ) -> anyhow::Result<()> { - let (result_tx, result_rx) = oneshot::channel(); - - std::thread::spawn(move || { + tokio::task::spawn_blocking(move || { let system = actix_web::rt::System::new(); - let result = system.block_on(start_balancer_services( + + system.block_on(start_balancer_services( management_addr, inference_addr, initial_desired_state, agent_snapshots_tx, shutdown_rx, - )); - if let Err(unsent_result) = result_tx.send(result) { - log::error!("Failed to send balancer result: {unsent_result:?}"); - } - }); - - result_rx - .await - .map_err(|error| anyhow::anyhow!("Balancer thread terminated: {error}"))? + )) + }) + .await + .map_err(|error| anyhow::anyhow!("Balancer task panicked: {error}"))? } From 2fba28629b986c6695d82cd70b01568ffa0bdda2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 24 Mar 2026 19:33:01 +0100 Subject: [PATCH 27/46] move files from backend/ to src/ and remove backend/ in the desktop app --- .../src/{backend => }/agent_monitor_service.rs | 0 .../src/{backend => }/agent_status_monitor_service.rs | 0 paddler_second_brain_gui/src/backend/mod.rs | 7 ------- paddler_second_brain_gui/src/main.rs | 7 ++++++- paddler_second_brain_gui/src/second_brain.rs | 4 ++-- paddler_second_brain_gui/src/{backend => }/start_agent.rs | 2 +- .../src/{backend => }/start_agent_services.rs | 2 +- .../src/{backend => }/start_balancer.rs | 2 +- .../src/{backend => }/start_balancer_services.rs | 2 +- 9 files changed, 12 insertions(+), 14 deletions(-) rename paddler_second_brain_gui/src/{backend => }/agent_monitor_service.rs (100%) rename paddler_second_brain_gui/src/{backend => }/agent_status_monitor_service.rs (100%) delete mode 100644 paddler_second_brain_gui/src/backend/mod.rs rename paddler_second_brain_gui/src/{backend => }/start_agent.rs (93%) rename paddler_second_brain_gui/src/{backend => }/start_agent_services.rs (98%) rename paddler_second_brain_gui/src/{backend => }/start_balancer.rs (93%) rename paddler_second_brain_gui/src/{backend => }/start_balancer_services.rs (98%) diff --git a/paddler_second_brain_gui/src/backend/agent_monitor_service.rs b/paddler_second_brain_gui/src/agent_monitor_service.rs similarity index 100% rename from paddler_second_brain_gui/src/backend/agent_monitor_service.rs rename to paddler_second_brain_gui/src/agent_monitor_service.rs diff --git a/paddler_second_brain_gui/src/backend/agent_status_monitor_service.rs b/paddler_second_brain_gui/src/agent_status_monitor_service.rs similarity index 100% rename from paddler_second_brain_gui/src/backend/agent_status_monitor_service.rs rename to paddler_second_brain_gui/src/agent_status_monitor_service.rs diff --git a/paddler_second_brain_gui/src/backend/mod.rs b/paddler_second_brain_gui/src/backend/mod.rs deleted file mode 100644 index 442feb65..00000000 --- a/paddler_second_brain_gui/src/backend/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod start_agent; -pub mod start_balancer; - -mod agent_monitor_service; -mod agent_status_monitor_service; -mod start_agent_services; -mod start_balancer_services; diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index e5db741b..7864ff5b 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,5 +1,6 @@ +mod agent_monitor_service; mod agent_running_data; -mod backend; +mod agent_status_monitor_service; mod detect_network_interfaces; mod home_data; mod join_cluster_config_data; @@ -10,6 +11,10 @@ mod running_cluster_data; mod screen; mod screen_current; mod second_brain; +mod start_agent; +mod start_agent_services; +mod start_balancer; +mod start_balancer_services; mod start_cluster_config_data; mod ui; diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index da7b0e6e..56a16e7d 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -16,10 +16,10 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot use tokio::sync::mpsc; use tokio::sync::oneshot; -use crate::backend::start_agent::start_agent; -use crate::backend::start_balancer::start_balancer; use crate::message::Message; use crate::screen_current::CurrentScreen; +use crate::start_agent::start_agent; +use crate::start_balancer::start_balancer; use crate::ui::variables::SPACING_2X; use crate::ui::variables::SPACING_BASE; use crate::ui::view_agent_running::view_agent_running; diff --git a/paddler_second_brain_gui/src/backend/start_agent.rs b/paddler_second_brain_gui/src/start_agent.rs similarity index 93% rename from paddler_second_brain_gui/src/backend/start_agent.rs rename to paddler_second_brain_gui/src/start_agent.rs index 8f81f118..17a4a017 100644 --- a/paddler_second_brain_gui/src/backend/start_agent.rs +++ b/paddler_second_brain_gui/src/start_agent.rs @@ -2,7 +2,7 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot use tokio::sync::mpsc; use tokio::sync::oneshot; -use super::start_agent_services::start_agent_services; +use crate::start_agent_services::start_agent_services; pub async fn start_agent( agent_name: Option, diff --git a/paddler_second_brain_gui/src/backend/start_agent_services.rs b/paddler_second_brain_gui/src/start_agent_services.rs similarity index 98% rename from paddler_second_brain_gui/src/backend/start_agent_services.rs rename to paddler_second_brain_gui/src/start_agent_services.rs index b197a9b6..a0647b23 100644 --- a/paddler_second_brain_gui/src/backend/start_agent_services.rs +++ b/paddler_second_brain_gui/src/start_agent_services.rs @@ -16,7 +16,7 @@ use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot use tokio::sync::mpsc; use tokio::sync::oneshot; -use super::agent_status_monitor_service::AgentStatusMonitorService; +use crate::agent_status_monitor_service::AgentStatusMonitorService; pub async fn start_agent_services( agent_name: Option, diff --git a/paddler_second_brain_gui/src/backend/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs similarity index 93% rename from paddler_second_brain_gui/src/backend/start_balancer.rs rename to paddler_second_brain_gui/src/start_balancer.rs index fc4130a8..e69176fa 100644 --- a/paddler_second_brain_gui/src/backend/start_balancer.rs +++ b/paddler_second_brain_gui/src/start_balancer.rs @@ -5,7 +5,7 @@ use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::mpsc; use tokio::sync::oneshot; -use super::start_balancer_services::start_balancer_services; +use crate::start_balancer_services::start_balancer_services; pub async fn start_balancer( management_addr: SocketAddr, diff --git a/paddler_second_brain_gui/src/backend/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs similarity index 98% rename from paddler_second_brain_gui/src/backend/start_balancer_services.rs rename to paddler_second_brain_gui/src/start_balancer_services.rs index aa3e7dff..7b43ce0b 100644 --- a/paddler_second_brain_gui/src/backend/start_balancer_services.rs +++ b/paddler_second_brain_gui/src/start_balancer_services.rs @@ -23,7 +23,7 @@ use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; -use super::agent_monitor_service::AgentMonitorService; +use crate::agent_monitor_service::AgentMonitorService; pub async fn start_balancer_services( management_addr: SocketAddr, From ff216b1a274be9a920ec53b208da0e17c918c043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 24 Mar 2026 20:04:05 +0100 Subject: [PATCH 28/46] switch desktop app to use paddler_bootstrap for service wiring --- Cargo.lock | 1 + paddler_second_brain_gui/Cargo.toml | 1 + paddler_second_brain_gui/src/main.rs | 4 - paddler_second_brain_gui/src/second_brain.rs | 97 ++++++++++++++--- paddler_second_brain_gui/src/start_agent.rs | 27 ----- .../src/start_agent_services.rs | 94 ---------------- .../src/start_balancer.rs | 30 ------ .../src/start_balancer_services.rs | 101 ------------------ 8 files changed, 83 insertions(+), 272 deletions(-) delete mode 100644 paddler_second_brain_gui/src/start_agent.rs delete mode 100644 paddler_second_brain_gui/src/start_agent_services.rs delete mode 100644 paddler_second_brain_gui/src/start_balancer.rs delete mode 100644 paddler_second_brain_gui/src/start_balancer_services.rs diff --git a/Cargo.lock b/Cargo.lock index 50624c26..d2822330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4550,6 +4550,7 @@ dependencies = [ "log", "nanoid", "paddler", + "paddler_bootstrap", "paddler_types", "statum", "tokio", diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index 6e45ccf8..17a02328 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -16,6 +16,7 @@ if-addrs = { workspace = true } log = { workspace = true } nanoid = { workspace = true } paddler = { workspace = true } +paddler_bootstrap = { workspace = true } paddler_types = { workspace = true } statum = { workspace = true } tokio = { workspace = true } diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 7864ff5b..0df0cc82 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -11,10 +11,6 @@ mod running_cluster_data; mod screen; mod screen_current; mod second_brain; -mod start_agent; -mod start_agent_services; -mod start_balancer; -mod start_balancer_services; mod start_cluster_config_data; mod ui; diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 56a16e7d..31520877 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -11,15 +11,22 @@ use iced::Task; use iced::time; use iced::widget::column; use iced::widget::container; +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; +use paddler::balancer::state_database_type::StateDatabaseType; +use paddler_bootstrap::bootstrap_agent_params::BootstrapAgentParams; +use paddler_bootstrap::bootstrap_balancer_params::BootstrapBalancerParams; +use paddler_bootstrap::bootstrapped_agent_handle::bootstrap_agent; +use paddler_bootstrap::bootstrapped_balancer_handle::bootstrap_balancer; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; use tokio::sync::mpsc; use tokio::sync::oneshot; +use crate::agent_monitor_service::AgentMonitorService; +use crate::agent_status_monitor_service::AgentStatusMonitorService; use crate::message::Message; use crate::screen_current::CurrentScreen; -use crate::start_agent::start_agent; -use crate::start_balancer::start_balancer; use crate::ui::variables::SPACING_2X; use crate::ui::variables::SPACING_BASE; use crate::ui::view_agent_running::view_agent_running; @@ -148,13 +155,31 @@ impl SecondBrain { self.screen = CurrentScreen::AgentRunning(config.connect()); Task::perform( - start_agent( - agent_name, - management_address, - slots, - agent_status_tx, - agent_shutdown_rx, - ), + async move { + tokio::task::spawn_blocking(move || { + actix_web::rt::System::new().block_on(async { + let mut bootstrapped = bootstrap_agent(BootstrapAgentParams { + agent_name, + management_address, + slots, + }); + + bootstrapped.service_manager.add_service( + AgentStatusMonitorService { + agent_status_tx, + slot_aggregated_status: bootstrapped.slot_aggregated_status, + }, + ); + + bootstrapped + .service_manager + .run_forever(agent_shutdown_rx) + .await + }) + }) + .await + .map_err(|error| anyhow::anyhow!("Agent task panicked: {error}"))? + }, |result: Result<(), anyhow::Error>| match result { Ok(()) => Message::AgentStopped, Err(error) => Message::AgentFailed(error.to_string()), @@ -269,13 +294,53 @@ impl SecondBrain { Task::batch([ Task::perform( - start_balancer( - management_addr, - inference_addr, - desired_state, - agent_snapshots_tx, - shutdown_rx, - ), + async move { + tokio::task::spawn_blocking(move || { + actix_web::rt::System::new().block_on(async { + let mut bootstrapped = + bootstrap_balancer(BootstrapBalancerParams { + buffered_request_timeout: Duration::from_millis(10000), + inference_service_configuration: + InferenceServiceConfiguration { + addr: inference_addr, + cors_allowed_hosts: vec![], + inference_item_timeout: Duration::from_millis( + 30000, + ), + }, + management_service_configuration: + ManagementServiceConfiguration { + addr: management_addr, + cors_allowed_hosts: vec![], + }, + max_buffered_requests: 30, + openai_service_configuration: None, + state_database_type: StateDatabaseType::Memory, + statsd_prefix: "paddler_".to_string(), + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: None, + }) + .await?; + + bootstrapped + .state_database + .store_balancer_desired_state(&desired_state) + .await?; + + bootstrapped + .service_manager + .add_service(AgentMonitorService { + agent_controller_pool: bootstrapped + .agent_controller_pool, + agent_snapshots_tx, + }); + + bootstrapped.service_manager.run_forever(shutdown_rx).await + }) + }) + .await + .map_err(|error| anyhow::anyhow!("Balancer task panicked: {error}"))? + }, |result: Result<(), anyhow::Error>| match result { Ok(()) => Message::ClusterStopped, Err(error) => Message::ClusterFailed(error.to_string()), diff --git a/paddler_second_brain_gui/src/start_agent.rs b/paddler_second_brain_gui/src/start_agent.rs deleted file mode 100644 index 17a4a017..00000000 --- a/paddler_second_brain_gui/src/start_agent.rs +++ /dev/null @@ -1,27 +0,0 @@ -use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; -use tokio::sync::mpsc; -use tokio::sync::oneshot; - -use crate::start_agent_services::start_agent_services; - -pub async fn start_agent( - agent_name: Option, - management_address: String, - slots: i32, - agent_status_tx: mpsc::UnboundedSender, - shutdown_rx: oneshot::Receiver<()>, -) -> anyhow::Result<()> { - tokio::task::spawn_blocking(move || { - let system = actix_web::rt::System::new(); - - system.block_on(start_agent_services( - agent_name, - management_address, - slots, - agent_status_tx, - shutdown_rx, - )) - }) - .await - .map_err(|error| anyhow::anyhow!("Agent task panicked: {error}"))? -} diff --git a/paddler_second_brain_gui/src/start_agent_services.rs b/paddler_second_brain_gui/src/start_agent_services.rs deleted file mode 100644 index a0647b23..00000000 --- a/paddler_second_brain_gui/src/start_agent_services.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::sync::Arc; - -use nanoid::nanoid; -use paddler::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; -use paddler::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; -use paddler::agent::generate_embedding_batch_request::GenerateEmbeddingBatchRequest; -use paddler::agent::llamacpp_arbiter_service::LlamaCppArbiterService; -use paddler::agent::management_socket_client_service::ManagementSocketClientService; -use paddler::agent::model_metadata_holder::ModelMetadataHolder; -use paddler::agent::reconciliation_service::ReconciliationService; -use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; -use paddler::agent_desired_state::AgentDesiredState; -use paddler::service_manager::ServiceManager; -use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; -use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; -use tokio::sync::mpsc; -use tokio::sync::oneshot; - -use crate::agent_status_monitor_service::AgentStatusMonitorService; - -pub async fn start_agent_services( - agent_name: Option, - management_address: String, - slots: i32, - agent_status_tx: mpsc::UnboundedSender, - shutdown_rx: oneshot::Receiver<()>, -) -> anyhow::Result<()> { - let (agent_desired_state_tx, agent_desired_state_rx) = - mpsc::unbounded_channel::(); - let ( - continue_from_conversation_history_request_tx, - continue_from_conversation_history_request_rx, - ) = mpsc::unbounded_channel::(); - let (continue_from_raw_prompt_request_tx, continue_from_raw_prompt_request_rx) = - mpsc::unbounded_channel::(); - let (generate_embedding_batch_request_tx, generate_embedding_batch_request_rx) = - mpsc::unbounded_channel::(); - - let agent_applicable_state_holder = Arc::new(AgentApplicableStateHolder::default()); - let model_metadata_holder = Arc::new(ModelMetadataHolder::default()); - let mut service_manager = ServiceManager::default(); - let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(slots)); - - service_manager.add_service(AgentStatusMonitorService { - agent_status_tx, - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - }); - - service_manager.add_service(LlamaCppArbiterService { - agent_applicable_state: None, - agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_name: agent_name.clone(), - continue_from_conversation_history_request_rx, - continue_from_raw_prompt_request_rx, - desired_slots_total: slots, - generate_embedding_batch_request_rx, - llamacpp_arbiter_handle: None, - model_metadata_holder: model_metadata_holder.clone(), - slot_aggregated_status_manager: slot_aggregated_status_manager.clone(), - }); - - service_manager.add_service(ManagementSocketClientService { - agent_applicable_state_holder: agent_applicable_state_holder.clone(), - agent_desired_state_tx, - continue_from_conversation_history_request_tx, - continue_from_raw_prompt_request_tx, - generate_embedding_batch_request_tx, - model_metadata_holder, - name: agent_name, - receive_stream_stopper_collection: Default::default(), - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - socket_url: format!( - "ws://{}/api/v1/agent_socket/{}", - management_address, - nanoid!() - ), - }); - - service_manager.add_service(ReconciliationService { - agent_applicable_state_holder, - agent_desired_state: None, - agent_desired_state_rx, - is_converted_to_applicable_state: false, - slot_aggregated_status: slot_aggregated_status_manager - .slot_aggregated_status - .clone(), - }); - - service_manager.run_forever(shutdown_rx).await -} diff --git a/paddler_second_brain_gui/src/start_balancer.rs b/paddler_second_brain_gui/src/start_balancer.rs deleted file mode 100644 index e69176fa..00000000 --- a/paddler_second_brain_gui/src/start_balancer.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::net::SocketAddr; - -use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; -use paddler_types::balancer_desired_state::BalancerDesiredState; -use tokio::sync::mpsc; -use tokio::sync::oneshot; - -use crate::start_balancer_services::start_balancer_services; - -pub async fn start_balancer( - management_addr: SocketAddr, - inference_addr: SocketAddr, - initial_desired_state: BalancerDesiredState, - agent_snapshots_tx: mpsc::UnboundedSender>, - shutdown_rx: oneshot::Receiver<()>, -) -> anyhow::Result<()> { - tokio::task::spawn_blocking(move || { - let system = actix_web::rt::System::new(); - - system.block_on(start_balancer_services( - management_addr, - inference_addr, - initial_desired_state, - agent_snapshots_tx, - shutdown_rx, - )) - }) - .await - .map_err(|error| anyhow::anyhow!("Balancer task panicked: {error}"))? -} diff --git a/paddler_second_brain_gui/src/start_balancer_services.rs b/paddler_second_brain_gui/src/start_balancer_services.rs deleted file mode 100644 index 7b43ce0b..00000000 --- a/paddler_second_brain_gui/src/start_balancer_services.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use paddler::balancer::agent_controller_pool::AgentControllerPool; -use paddler::balancer::buffered_request_manager::BufferedRequestManager; -use paddler::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; -use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; -use paddler::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; -use paddler::balancer::inference_service::InferenceService; -use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; -use paddler::balancer::management_service::ManagementService; -use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; -use paddler::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; -use paddler::balancer::reconciliation_service::ReconciliationService; -use paddler::balancer::state_database::Memory; -use paddler::balancer::state_database::StateDatabase; -use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; -use paddler::service_manager::ServiceManager; -use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; -use paddler_types::balancer_desired_state::BalancerDesiredState; -use tokio::sync::broadcast; -use tokio::sync::mpsc; -use tokio::sync::oneshot; - -use crate::agent_monitor_service::AgentMonitorService; - -pub async fn start_balancer_services( - management_addr: SocketAddr, - inference_addr: SocketAddr, - initial_desired_state: BalancerDesiredState, - agent_snapshots_tx: mpsc::UnboundedSender>, - shutdown_rx: oneshot::Receiver<()>, -) -> anyhow::Result<()> { - let (balancer_desired_state_tx, balancer_desired_state_rx) = broadcast::channel(100); - - let agent_controller_pool = Arc::new(AgentControllerPool::default()); - let balancer_applicable_state_holder = Arc::new(BalancerApplicableStateHolder::default()); - let buffered_request_manager = Arc::new(BufferedRequestManager::new( - agent_controller_pool.clone(), - Duration::from_millis(10000), - 30, - )); - let chat_template_override_sender_collection = - Arc::new(ChatTemplateOverrideSenderCollection::default()); - let embedding_sender_collection = Arc::new(EmbeddingSenderCollection::default()); - let generate_tokens_sender_collection = Arc::new(GenerateTokensSenderCollection::default()); - let model_metadata_sender_collection = Arc::new(ModelMetadataSenderCollection::default()); - let mut service_manager = ServiceManager::default(); - let state_database: Arc = - Arc::new(Memory::new(balancer_desired_state_tx.clone())); - - state_database - .store_balancer_desired_state(&initial_desired_state) - .await?; - - service_manager.add_service(InferenceService { - balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), - buffered_request_manager: buffered_request_manager.clone(), - configuration: InferenceServiceConfiguration { - addr: inference_addr, - cors_allowed_hosts: vec![], - inference_item_timeout: Duration::from_millis(30000), - }, - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration: None, - }); - - service_manager.add_service(ManagementService { - agent_controller_pool: agent_controller_pool.clone(), - balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), - buffered_request_manager: buffered_request_manager.clone(), - chat_template_override_sender_collection, - configuration: ManagementServiceConfiguration { - addr: management_addr, - cors_allowed_hosts: vec![], - }, - embedding_sender_collection, - generate_tokens_sender_collection, - model_metadata_sender_collection, - state_database: state_database.clone(), - statsd_prefix: "paddler_".to_string(), - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration: None, - }); - - service_manager.add_service(AgentMonitorService { - agent_controller_pool: agent_controller_pool.clone(), - agent_snapshots_tx, - }); - - service_manager.add_service(ReconciliationService { - agent_controller_pool: agent_controller_pool.clone(), - balancer_applicable_state_holder, - balancer_desired_state: state_database.read_balancer_desired_state().await?, - balancer_desired_state_rx, - is_converted_to_applicable_state: false, - }); - - service_manager.run_forever(shutdown_rx).await -} From 9d719824f4b51a9c92866f2af8b1fc65bb02ceae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 24 Mar 2026 22:29:44 +0100 Subject: [PATCH 29/46] replace GUI polling with direct Notify-based subscriptions --- .../src/agent_monitor_service.rs | 57 ----- .../src/agent_status_monitor_service.rs | 53 ---- paddler_second_brain_gui/src/main.rs | 2 - paddler_second_brain_gui/src/message.rs | 27 +- paddler_second_brain_gui/src/second_brain.rs | 236 ++++++++++++------ .../src/agent_controller_snapshot.rs | 2 +- 6 files changed, 169 insertions(+), 208 deletions(-) delete mode 100644 paddler_second_brain_gui/src/agent_monitor_service.rs delete mode 100644 paddler_second_brain_gui/src/agent_status_monitor_service.rs diff --git a/paddler_second_brain_gui/src/agent_monitor_service.rs b/paddler_second_brain_gui/src/agent_monitor_service.rs deleted file mode 100644 index c7e98600..00000000 --- a/paddler_second_brain_gui/src/agent_monitor_service.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::sync::Arc; - -use anyhow::Result; -use async_trait::async_trait; -use paddler::balancer::agent_controller_pool::AgentControllerPool; -use paddler::produces_snapshot::ProducesSnapshot; -use paddler::service::Service; -use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; -use tokio::sync::broadcast; -use tokio::sync::mpsc; - -pub struct AgentMonitorService { - pub agent_controller_pool: Arc, - pub agent_snapshots_tx: mpsc::UnboundedSender>, -} - -fn collect_agent_snapshots(pool: &AgentControllerPool) -> Result> { - let pool_snapshot = pool.make_snapshot()?; - let mut agents = pool_snapshot.agents; - - agents.sort_by(|left, right| { - let left_name = left.name.as_deref().unwrap_or(&left.id); - let right_name = right.name.as_deref().unwrap_or(&right.id); - - left_name.cmp(right_name) - }); - - Ok(agents) -} - -#[async_trait] -impl Service for AgentMonitorService { - fn name(&self) -> &'static str { - "agent_monitor" - } - - async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { - loop { - let snapshots = collect_agent_snapshots(&self.agent_controller_pool)?; - - if let Err(send_error) = self.agent_snapshots_tx.send(snapshots) { - log::warn!("Agent snapshots receiver dropped: {send_error}"); - - break; - } - - tokio::select! { - _ = self.agent_controller_pool.update_notifier.notified() => {} - _ = shutdown_rx.recv() => { - break; - } - } - } - - Ok(()) - } -} diff --git a/paddler_second_brain_gui/src/agent_status_monitor_service.rs b/paddler_second_brain_gui/src/agent_status_monitor_service.rs deleted file mode 100644 index 1c7d9b56..00000000 --- a/paddler_second_brain_gui/src/agent_status_monitor_service.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::sync::Arc; - -use anyhow::Result; -use async_trait::async_trait; -use paddler::produces_snapshot::ProducesSnapshot; -use paddler::service::Service; -use paddler::slot_aggregated_status::SlotAggregatedStatus; -use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; -use tokio::sync::broadcast; -use tokio::sync::mpsc; - -pub struct AgentStatusMonitorService { - pub agent_status_tx: mpsc::UnboundedSender, - pub slot_aggregated_status: Arc, -} - -#[async_trait] -impl Service for AgentStatusMonitorService { - fn name(&self) -> &'static str { - "agent_status_monitor" - } - - async fn run(&mut self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { - let mut previous_version: Option = None; - - loop { - let snapshot = self.slot_aggregated_status.make_snapshot()?; - - let has_changed = previous_version - .map(|previous| previous != snapshot.version) - .unwrap_or(true); - - if has_changed { - previous_version = Some(snapshot.version); - - if let Err(send_error) = self.agent_status_tx.send(snapshot) { - log::warn!("Agent status receiver dropped: {send_error}"); - - break; - } - } - - tokio::select! { - _ = self.slot_aggregated_status.update_notifier.notified() => {} - _ = shutdown_rx.recv() => { - break; - } - } - } - - Ok(()) - } -} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 0df0cc82..21cdd0f3 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,6 +1,4 @@ -mod agent_monitor_service; mod agent_running_data; -mod agent_status_monitor_service; mod detect_network_interfaces; mod home_data; mod join_cluster_config_data; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index 7a865306..af92e255 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -1,26 +1,29 @@ +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + use crate::model_preset::ModelPreset; #[derive(Debug, Clone)] pub enum Message { AgentFailed(String), + AgentSnapshotsUpdated(Vec), + AgentStatusUpdated(SlotAggregatedStatusSnapshot), AgentStopped, + Cancel, + ClusterFailed(String), + ClusterStarted, + ClusterStopped, + Confirm, Connect, - SetAgentName(String), + CopyToClipboard(String), Disconnect, JoinCluster, - RefreshAgentStatus, + SelectModel(ModelPreset), + SetAgentName(String), + SetBalancerAddress(String), SetClusterAddress(String), + SetInferenceAddress(String), SetSlotsCount(String), StartCluster, - Cancel, - CopyToClipboard(String), - SetBalancerAddress(String), - SetInferenceAddress(String), - SelectModel(ModelPreset), - Confirm, - ClusterStarted, - ClusterFailed(String), Stop, - ClusterStopped, - RefreshAgentCount, } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 31520877..08715078 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -1,6 +1,7 @@ use std::mem; use std::net::SocketAddr; use std::net::TcpStream; +use std::sync::Arc; use std::time::Duration; use iced::Center; @@ -8,23 +9,22 @@ use iced::Element; use iced::Fill; use iced::Subscription; use iced::Task; -use iced::time; +use iced::futures::SinkExt; use iced::widget::column; use iced::widget::container; +use paddler::balancer::agent_controller_pool::AgentControllerPool; use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; use paddler::balancer::state_database_type::StateDatabaseType; +use paddler::produces_snapshot::ProducesSnapshot; +use paddler::slot_aggregated_status::SlotAggregatedStatus; use paddler_bootstrap::bootstrap_agent_params::BootstrapAgentParams; use paddler_bootstrap::bootstrap_balancer_params::BootstrapBalancerParams; use paddler_bootstrap::bootstrapped_agent_handle::bootstrap_agent; use paddler_bootstrap::bootstrapped_balancer_handle::bootstrap_balancer; -use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; -use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; -use tokio::sync::mpsc; use tokio::sync::oneshot; +use tokio::sync::watch; -use crate::agent_monitor_service::AgentMonitorService; -use crate::agent_status_monitor_service::AgentStatusMonitorService; use crate::message::Message; use crate::screen_current::CurrentScreen; use crate::ui::variables::SPACING_2X; @@ -39,20 +39,24 @@ fn is_port_in_use(address: &SocketAddr) -> bool { TcpStream::connect_timeout(address, Duration::from_millis(100)).is_ok() } -fn drain_latest(receiver: &mut mpsc::UnboundedReceiver) -> Option { - let mut latest = None; +fn collect_sorted_agent_snapshots( + pool: &AgentControllerPool, +) -> anyhow::Result> { + let pool_snapshot = pool.make_snapshot()?; + let mut agents = pool_snapshot.agents; - while let Ok(value) = receiver.try_recv() { - latest = Some(value); - } + agents.sort_by(|left, right| { + let left_name = left.name.as_deref().unwrap_or(&left.id); + let right_name = right.name.as_deref().unwrap_or(&right.id); + + left_name.cmp(right_name) + }); - latest + Ok(agents) } pub struct SecondBrain { - agent_snapshots_rx: Option>>, agent_shutdown_tx: Option>, - agent_status_rx: Option>, screen: CurrentScreen, shutdown_tx: Option>, } @@ -76,9 +80,7 @@ impl Drop for SecondBrain { impl SecondBrain { pub fn new() -> (Self, Task) { let second_brain = Self { - agent_snapshots_rx: None, agent_shutdown_tx: None, - agent_status_rx: None, screen: CurrentScreen::default(), shutdown_tx: None, }; @@ -147,44 +149,87 @@ impl SecondBrain { let management_address = config.state_data.cluster_address.clone(); let (agent_shutdown_tx, agent_shutdown_rx) = oneshot::channel::<()>(); - let (agent_status_tx, agent_status_rx) = - mpsc::unbounded_channel::(); + let (status_watch_tx, status_watch_rx) = + watch::channel::>>(None); self.agent_shutdown_tx = Some(agent_shutdown_tx); - self.agent_status_rx = Some(agent_status_rx); self.screen = CurrentScreen::AgentRunning(config.connect()); - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - actix_web::rt::System::new().block_on(async { - let mut bootstrapped = bootstrap_agent(BootstrapAgentParams { - agent_name, - management_address, - slots, - }); - - bootstrapped.service_manager.add_service( - AgentStatusMonitorService { - agent_status_tx, - slot_aggregated_status: bootstrapped.slot_aggregated_status, - }, - ); - - bootstrapped - .service_manager - .run_forever(agent_shutdown_rx) - .await + Task::batch([ + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + actix_web::rt::System::new().block_on(async { + let bootstrapped = bootstrap_agent(BootstrapAgentParams { + agent_name, + management_address, + slots, + }); + + if status_watch_tx + .send(Some(bootstrapped.slot_aggregated_status.clone())) + .is_err() + { + return Err(anyhow::anyhow!( + "Monitor stream was dropped before receiving status" + )); + } + + bootstrapped + .service_manager + .run_forever(agent_shutdown_rx) + .await + }) }) - }) - .await - .map_err(|error| anyhow::anyhow!("Agent task panicked: {error}"))? - }, - |result: Result<(), anyhow::Error>| match result { - Ok(()) => Message::AgentStopped, - Err(error) => Message::AgentFailed(error.to_string()), - }, - ) + .await + .map_err(|error| anyhow::anyhow!("Agent task panicked: {error}"))? + }, + |result: Result<(), anyhow::Error>| match result { + Ok(()) => Message::AgentStopped, + Err(error) => Message::AgentFailed(error.to_string()), + }, + ), + Task::stream(iced::stream::channel(1, async move |mut output| { + let mut watch_rx = status_watch_rx; + + let slot_aggregated_status = loop { + if watch_rx.changed().await.is_err() { + return; + } + if let Some(status) = watch_rx.borrow_and_update().clone() { + break status; + } + }; + + loop { + match slot_aggregated_status.make_snapshot() { + Ok(snapshot) => { + if output + .send(Message::AgentStatusUpdated(snapshot)) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to make agent status snapshot: {error}"); + + return; + } + } + + tokio::select! { + () = slot_aggregated_status.update_notifier.notified() => {} + changed_result = watch_rx.changed() => { + if changed_result.is_err() { + return; + } + } + } + } + })), + ]) } (CurrentScreen::StartClusterConfig(config), Message::Cancel) => { if let Some(shutdown_tx) = self.shutdown_tx.take() @@ -192,7 +237,6 @@ impl SecondBrain { { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } - self.agent_snapshots_rx = None; self.screen = CurrentScreen::Home(config.cancel()); Task::none() @@ -284,10 +328,9 @@ impl SecondBrain { .unwrap_or_default(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let (agent_snapshots_tx, agent_snapshots_rx) = - mpsc::unbounded_channel::>(); + let (pool_watch_tx, pool_watch_rx) = + watch::channel::>>(None); - self.agent_snapshots_rx = Some(agent_snapshots_rx); self.shutdown_tx = Some(shutdown_tx); config.state_data.starting = true; self.screen = CurrentScreen::StartClusterConfig(config); @@ -297,7 +340,7 @@ impl SecondBrain { async move { tokio::task::spawn_blocking(move || { actix_web::rt::System::new().block_on(async { - let mut bootstrapped = + let bootstrapped = bootstrap_balancer(BootstrapBalancerParams { buffered_request_timeout: Duration::from_millis(10000), inference_service_configuration: @@ -327,13 +370,14 @@ impl SecondBrain { .store_balancer_desired_state(&desired_state) .await?; - bootstrapped - .service_manager - .add_service(AgentMonitorService { - agent_controller_pool: bootstrapped - .agent_controller_pool, - agent_snapshots_tx, - }); + if pool_watch_tx + .send(Some(bootstrapped.agent_controller_pool.clone())) + .is_err() + { + return Err(anyhow::anyhow!( + "Monitor stream was dropped before receiving pool" + )); + } bootstrapped.service_manager.run_forever(shutdown_rx).await }) @@ -347,6 +391,46 @@ impl SecondBrain { }, ), Task::done(Message::ClusterStarted), + Task::stream(iced::stream::channel(1, async move |mut output| { + let mut watch_rx = pool_watch_rx; + + let agent_controller_pool = loop { + if watch_rx.changed().await.is_err() { + return; + } + if let Some(pool) = watch_rx.borrow_and_update().clone() { + break pool; + } + }; + + loop { + match collect_sorted_agent_snapshots(&agent_controller_pool) { + Ok(snapshots) => { + if output + .send(Message::AgentSnapshotsUpdated(snapshots)) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to collect agent snapshots: {error}"); + + return; + } + } + + tokio::select! { + () = agent_controller_pool.update_notifier.notified() => {} + changed_result = watch_rx.changed() => { + if changed_result.is_err() { + return; + } + } + } + } + })), ]) } (CurrentScreen::StartClusterConfig(config), Message::ClusterStarted) => { @@ -361,10 +445,11 @@ impl SecondBrain { Task::none() } - (CurrentScreen::RunningCluster(mut running), Message::RefreshAgentCount) => { - if let Some(snapshots) = self.agent_snapshots_rx.as_mut().and_then(drain_latest) { - running.state_data.agent_snapshots = snapshots; - } + ( + CurrentScreen::RunningCluster(mut running), + Message::AgentSnapshotsUpdated(snapshots), + ) => { + running.state_data.agent_snapshots = snapshots; self.screen = CurrentScreen::RunningCluster(running); Task::none() @@ -375,7 +460,6 @@ impl SecondBrain { { log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); } - self.agent_snapshots_rx = None; running.state_data.stopping = true; self.screen = CurrentScreen::RunningCluster(running); @@ -388,16 +472,13 @@ impl SecondBrain { } (CurrentScreen::RunningCluster(running), Message::ClusterFailed(error)) => { log::error!("Cluster failed unexpectedly: {error}"); - self.agent_snapshots_rx = None; self.shutdown_tx = None; self.screen = CurrentScreen::Home(running.cluster_failed(error)); Task::none() } - (CurrentScreen::AgentRunning(mut running), Message::RefreshAgentStatus) => { - if let Some(status) = self.agent_status_rx.as_mut().and_then(drain_latest) { - running.state_data.apply_status(status); - } + (CurrentScreen::AgentRunning(mut running), Message::AgentStatusUpdated(status)) => { + running.state_data.apply_status(status); self.screen = CurrentScreen::AgentRunning(running); Task::none() @@ -408,7 +489,6 @@ impl SecondBrain { { log::error!("Failed to send agent shutdown signal: {unsent_signal:?}"); } - self.agent_status_rx = None; self.screen = CurrentScreen::Home(running.disconnect()); Task::none() @@ -416,7 +496,6 @@ impl SecondBrain { (CurrentScreen::AgentRunning(running), Message::AgentStopped) => { log::info!("Agent stopped"); self.agent_shutdown_tx = None; - self.agent_status_rx = None; self.screen = CurrentScreen::Home(running.disconnect()); Task::none() @@ -424,7 +503,6 @@ impl SecondBrain { (CurrentScreen::AgentRunning(running), Message::AgentFailed(error)) => { log::error!("Agent failed: {error}"); self.agent_shutdown_tx = None; - self.agent_status_rx = None; self.screen = CurrentScreen::Home(running.agent_failed(error)); Task::none() @@ -444,15 +522,7 @@ impl SecondBrain { } pub fn subscription(&self) -> Subscription { - match &self.screen { - CurrentScreen::AgentRunning(_) => { - time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentStatus) - } - CurrentScreen::RunningCluster(_) => { - time::every(Duration::from_secs(1)).map(|_| Message::RefreshAgentCount) - } - _ => Subscription::none(), - } + Subscription::none() } pub fn view<'view>(&'view self) -> Element<'view, Message> { diff --git a/paddler_types/src/agent_controller_snapshot.rs b/paddler_types/src/agent_controller_snapshot.rs index e5818dc2..d28b0fe0 100644 --- a/paddler_types/src/agent_controller_snapshot.rs +++ b/paddler_types/src/agent_controller_snapshot.rs @@ -6,7 +6,7 @@ use serde::Serialize; use crate::agent_issue::AgentIssue; use crate::agent_state_application_status::AgentStateApplicationStatus; -#[derive(Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AgentControllerSnapshot { pub desired_slots_total: i32, From 05deef8e7ebbb12b7c49002f9271dbd17b155ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 25 Mar 2026 14:34:42 +0100 Subject: [PATCH 30/46] improve form validation UX (show erorrs after form submit attempt) --- .../src/join_cluster_config_data.rs | 3 +- paddler_second_brain_gui/src/screen.rs | 1 + paddler_second_brain_gui/src/second_brain.rs | 146 +++++++++++++----- .../src/start_cluster_config_data.rs | 1 + .../src/ui/view_join_cluster_config.rs | 107 +++++++------ .../src/ui/view_start_cluster_config.rs | 61 ++++---- 6 files changed, 201 insertions(+), 118 deletions(-) diff --git a/paddler_second_brain_gui/src/join_cluster_config_data.rs b/paddler_second_brain_gui/src/join_cluster_config_data.rs index 8ad74e7d..87516d83 100644 --- a/paddler_second_brain_gui/src/join_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/join_cluster_config_data.rs @@ -2,6 +2,7 @@ pub struct JoinClusterConfigData { pub agent_name: String, pub cluster_address: String, - pub error: Option, + pub cluster_address_error: Option, pub slots_count: String, + pub slots_error: Option, } diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index 9af7171e..0df56198 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -42,6 +42,7 @@ impl Screen { balancer_address_error: None, inference_address: format!("{suggested_address}:8061"), inference_address_error: None, + model_error: None, selected_model: None, starting: false, }) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 08715078..1fa04eef 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -109,38 +109,92 @@ impl SecondBrain { } (CurrentScreen::JoinClusterConfig(mut config), Message::SetAgentName(name)) => { config.state_data.agent_name = name; - config.state_data.error = None; self.screen = CurrentScreen::JoinClusterConfig(config); Task::none() } (CurrentScreen::JoinClusterConfig(mut config), Message::SetClusterAddress(address)) => { config.state_data.cluster_address = address; - config.state_data.error = None; + config.state_data.cluster_address_error = None; self.screen = CurrentScreen::JoinClusterConfig(config); Task::none() } (CurrentScreen::JoinClusterConfig(mut config), Message::SetSlotsCount(slots)) => { - config.state_data.slots_count = slots; - config.state_data.error = None; + if slots.is_empty() || slots.chars().all(|character| character.is_ascii_digit()) { + config.state_data.slots_count = slots; + config.state_data.slots_error = None; + } self.screen = CurrentScreen::JoinClusterConfig(config); Task::none() } - (CurrentScreen::JoinClusterConfig(config), Message::Connect) => { - let slots = match config.state_data.slots_count.parse::() { - Ok(slots) if slots > 0 => slots, - _ => { - let mut config = config; - config.state_data.error = - Some("Enter a valid number of slots.".to_string()); - self.screen = CurrentScreen::JoinClusterConfig(config); - - return Task::none(); + (CurrentScreen::JoinClusterConfig(mut config), Message::Connect) => { + config.state_data.cluster_address_error = None; + config.state_data.slots_error = None; + + if config.state_data.cluster_address.is_empty() { + config.state_data.cluster_address_error = + Some("Cluster address is required.".to_string()); + } else if config + .state_data + .cluster_address + .parse::() + .is_err() + { + config.state_data.cluster_address_error = + Some("Invalid address, expected format: IP:port".to_string()); + } + + let slots = if config.state_data.slots_count.is_empty() { + config.state_data.slots_error = + Some("Number of slots is required.".to_string()); + None + } else { + match config.state_data.slots_count.parse::() { + Ok(slots) if slots > 0 => Some(slots), + Ok(non_positive_slots) => { + log::debug!( + "User entered non-positive slot count: {non_positive_slots}" + ); + config.state_data.slots_error = Some( + "Invalid number of slots (the number should be greater than zero)." + .to_string(), + ); + None + } + Err(error) => { + let message = match error.kind() { + std::num::IntErrorKind::PosOverflow => { + "Number of slots is too large." + } + unexpected_kind => { + log::error!( + "Unexpected slots parse error: {unexpected_kind:?}" + ); + "Invalid number of slots." + } + }; + config.state_data.slots_error = Some(message.to_string()); + None + } } }; + if config.state_data.cluster_address_error.is_some() + || config.state_data.slots_error.is_some() + { + self.screen = CurrentScreen::JoinClusterConfig(config); + + return Task::none(); + } + + let Some(slots) = slots else { + self.screen = CurrentScreen::JoinClusterConfig(config); + + return Task::none(); + }; + let agent_name = if config.state_data.agent_name.is_empty() { None } else { @@ -243,8 +297,7 @@ impl SecondBrain { } (CurrentScreen::StartClusterConfig(mut config), Message::SelectModel(preset)) => { config.state_data.selected_model = Some(preset); - config.state_data.balancer_address_error = None; - config.state_data.inference_address_error = None; + config.state_data.model_error = None; self.screen = CurrentScreen::StartClusterConfig(config); Task::none() @@ -272,25 +325,34 @@ impl SecondBrain { (CurrentScreen::StartClusterConfig(mut config), Message::Confirm) => { config.state_data.balancer_address_error = None; config.state_data.inference_address_error = None; + config.state_data.model_error = None; - let management_addr = match config.state_data.balancer_address.parse::() - { - Ok(addr) => Some(addr), - Err(parse_error) => { - config.state_data.balancer_address_error = - Some(format!("Invalid address: {parse_error}")); - None - } + if config.state_data.selected_model.is_none() { + config.state_data.model_error = Some("Please select a model.".to_string()); + } + + let management_addr = if config.state_data.balancer_address.is_empty() { + config.state_data.balancer_address_error = + Some("Balancer address is required.".to_string()); + None + } else if let Ok(addr) = config.state_data.balancer_address.parse::() { + Some(addr) + } else { + config.state_data.balancer_address_error = + Some("Invalid address, expected format: IP:port".to_string()); + None }; - let inference_addr = match config.state_data.inference_address.parse::() - { - Ok(addr) => Some(addr), - Err(parse_error) => { - config.state_data.inference_address_error = - Some(format!("Invalid address: {parse_error}")); - None - } + let inference_addr = if config.state_data.inference_address.is_empty() { + config.state_data.inference_address_error = + Some("Inference address is required.".to_string()); + None + } else if let Ok(addr) = config.state_data.inference_address.parse::() { + Some(addr) + } else { + config.state_data.inference_address_error = + Some("Invalid address, expected format: IP:port".to_string()); + None }; let management_addr = match management_addr { @@ -311,13 +373,21 @@ impl SecondBrain { other => other, }; - let (management_addr, inference_addr) = match (management_addr, inference_addr) { - (Some(management), Some(inference)) => (management, inference), - _ => { - self.screen = CurrentScreen::StartClusterConfig(config); + if config.state_data.model_error.is_some() + || config.state_data.balancer_address_error.is_some() + || config.state_data.inference_address_error.is_some() + { + self.screen = CurrentScreen::StartClusterConfig(config); - return Task::none(); - } + return Task::none(); + } + + let (Some(management_addr), Some(inference_addr)) = + (management_addr, inference_addr) + else { + self.screen = CurrentScreen::StartClusterConfig(config); + + return Task::none(); }; let desired_state = config diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index dba0863f..baba3bc5 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -5,6 +5,7 @@ pub struct StartClusterConfigData { pub balancer_address_error: Option, pub inference_address: String, pub inference_address_error: Option, + pub model_error: Option, pub selected_model: Option, pub starting: bool, } diff --git a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs index 9628a1cb..3d8c1359 100644 --- a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs @@ -9,9 +9,11 @@ use iced::widget::text; use iced::widget::text_input; use super::font::BOLD; +use super::font::REGULAR; use super::style_button_primary::style_button_primary; use super::style_field_container::style_field_container; use super::style_field_text_input::style_field_text_input; +use super::variables::COLOR_ERROR; use super::variables::FONT_SIZE_L2; use super::variables::SPACING_2X; use super::variables::SPACING_BASE; @@ -22,79 +24,82 @@ use crate::message::Message; pub fn view_join_cluster_config<'content>( data: &'content JoinClusterConfigData, ) -> Element<'content, Message> { - let is_valid_slots = data - .slots_count - .parse::() - .map(|slots| slots > 0) - .unwrap_or(false); - - let confirm_button = if !data.cluster_address.is_empty() && is_valid_slots { - button(text("Connect").font(BOLD)) - .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_primary) - .on_press(Message::Connect) - } else { - button(text("Connect").font(BOLD)) - .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_primary) - }; + let confirm_button = button(text("Connect").font(BOLD)) + .padding([SPACING_HALF, SPACING_BASE]) + .style(style_button_primary) + .on_press(Message::Connect); let cancel_button = button(text("Cancel").font(BOLD)) .style(button::text) .on_press(Message::Cancel); - let mut content = column![ + let mut cluster_address_field = column![ + container(text("Cluster address").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("IP:port", &data.cluster_address) + .on_input(Message::SetClusterAddress) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(400) + .style(style_field_container), + ] + .spacing(SPACING_HALF); + + if let Some(error) = &data.cluster_address_error { + cluster_address_field = cluster_address_field.push( + container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .width(400) + .padding([0.0, SPACING_BASE]), + ); + } + + let mut slots_field = column![ + container(text("Slots").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + text_input("e.g. 1", &data.slots_count) + .on_input(Message::SetSlotsCount) + .padding(SPACING_BASE) + .style(style_field_text_input), + ) + .width(400) + .style(style_field_container), + ] + .spacing(SPACING_HALF); + + if let Some(error) = &data.slots_error { + slots_field = slots_field.push( + container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .width(400) + .padding([0.0, SPACING_BASE]), + ); + } + + column![ container(text("Join a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), + cluster_address_field, column![ - container(text("Cluster address").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("", &data.cluster_address) - .on_input(Message::SetClusterAddress) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(300) - .style(style_field_container), - ] - .spacing(SPACING_HALF), - column![ - container(text("Agent name").font(BOLD)).padding([0.0, SPACING_BASE]), + container(text("Agent name (optional)").font(BOLD)).padding([0.0, SPACING_BASE]), container( text_input("my-agent", &data.agent_name) .on_input(Message::SetAgentName) .padding(SPACING_BASE) .style(style_field_text_input), ) - .width(300) - .style(style_field_container), - ] - .spacing(SPACING_HALF), - column![ - container(text("Slots").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("e.g. 1", &data.slots_count) - .on_input(Message::SetSlotsCount) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(300) + .width(400) .style(style_field_container), ] .spacing(SPACING_HALF), + slots_field, container( row![cancel_button, confirm_button] .align_y(Center) .spacing(SPACING_BASE), ) - .width(300) + .width(400) .align_x(Horizontal::Right), ] - .spacing(SPACING_2X); - - if let Some(error) = &data.error { - content = content.push(text(error.clone())); - } - - content.into() + .spacing(SPACING_2X) + .into() } diff --git a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs index 3df31e06..7bd9d1d3 100644 --- a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs @@ -35,18 +35,11 @@ pub fn view_start_cluster_config<'content>( button(text("Starting...").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) - } else if data.selected_model.is_some() - && !data.balancer_address.is_empty() - && !data.inference_address.is_empty() - { - button(text("Start a cluster").font(BOLD)) - .padding([SPACING_HALF, SPACING_BASE]) - .style(style_button_primary) - .on_press(Message::Confirm) } else { button(text("Start a cluster").font(BOLD)) .padding([SPACING_HALF, SPACING_BASE]) .style(style_button_primary) + .on_press(Message::Confirm) }; let cancel_button = button(text("Cancel").font(BOLD)) @@ -61,7 +54,7 @@ pub fn view_start_cluster_config<'content>( .padding(SPACING_BASE) .style(style_field_text_input), ) - .width(300) + .width(400) .style(style_field_container), ] .spacing(SPACING_HALF); @@ -69,6 +62,7 @@ pub fn view_start_cluster_config<'content>( if let Some(error) = &data.balancer_address_error { balancer_field = balancer_field.push( container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .width(400) .padding([0.0, SPACING_BASE]), ); } @@ -81,7 +75,7 @@ pub fn view_start_cluster_config<'content>( .padding(SPACING_BASE) .style(style_field_text_input), ) - .width(300) + .width(400) .style(style_field_container), ] .spacing(SPACING_HALF); @@ -89,6 +83,33 @@ pub fn view_start_cluster_config<'content>( if let Some(error) = &data.inference_address_error { inference_field = inference_field.push( container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .width(400) + .padding([0.0, SPACING_BASE]), + ); + } + + let mut model_field = column![ + container(text("Select a model").font(BOLD)).padding([0.0, SPACING_BASE]), + container( + pick_list( + available_models, + data.selected_model.as_ref(), + Message::SelectModel, + ) + .width(Fill) + .padding(SPACING_BASE) + .style(style_field_pick_list) + .menu_style(style_field_pick_list_menu), + ) + .width(400) + .style(style_field_container), + ] + .spacing(SPACING_HALF); + + if let Some(error) = &data.model_error { + model_field = model_field.push( + container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .width(400) .padding([0.0, SPACING_BASE]), ); } @@ -98,29 +119,13 @@ pub fn view_start_cluster_config<'content>( .padding([0.0, SPACING_BASE]), balancer_field, inference_field, - column![ - container(text("Select a model").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - pick_list( - available_models, - data.selected_model.as_ref(), - Message::SelectModel, - ) - .width(Fill) - .padding(SPACING_BASE) - .style(style_field_pick_list) - .menu_style(style_field_pick_list_menu), - ) - .width(300) - .style(style_field_container), - ] - .spacing(SPACING_HALF), + model_field, container( row![cancel_button, confirm_button] .align_y(Center) .spacing(SPACING_BASE), ) - .width(300) + .width(400) .align_x(Horizontal::Right), ] .spacing(SPACING_2X) From ea04c0df05ff36d254668ca6c2338d0beb17025a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 25 Mar 2026 15:13:00 +0100 Subject: [PATCH 31/46] show nothing in the UI when agent name is empty instead of showing afgent ID --- paddler_second_brain_gui/src/second_brain.rs | 8 ++++---- paddler_second_brain_gui/src/ui/view_agent_card.rs | 14 +++++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 1fa04eef..48efb97f 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -45,11 +45,11 @@ fn collect_sorted_agent_snapshots( let pool_snapshot = pool.make_snapshot()?; let mut agents = pool_snapshot.agents; - agents.sort_by(|left, right| { - let left_name = left.name.as_deref().unwrap_or(&left.id); - let right_name = right.name.as_deref().unwrap_or(&right.id); + agents.sort_by(|current_agent, other_agent| { + let current_name = current_agent.name.as_deref().unwrap_or(¤t_agent.id); + let other_name = other_agent.name.as_deref().unwrap_or(&other_agent.id); - left_name.cmp(right_name) + current_name.cmp(other_name) }); Ok(agents) diff --git a/paddler_second_brain_gui/src/ui/view_agent_card.rs b/paddler_second_brain_gui/src/ui/view_agent_card.rs index 5c638f9e..8e3761b6 100644 --- a/paddler_second_brain_gui/src/ui/view_agent_card.rs +++ b/paddler_second_brain_gui/src/ui/view_agent_card.rs @@ -23,12 +23,20 @@ fn display_last_path_part(path: &str) -> String { pub fn view_agent_card<'content>( snapshot: &'content AgentControllerSnapshot, ) -> Element<'content, Message> { - let agent_name = snapshot.name.as_deref().unwrap_or(&snapshot.id); - let is_downloading = snapshot.download_total > 0 && snapshot.download_current < snapshot.download_total; - let mut name_row = row![container(text(agent_name.to_string()).font(BOLD)).width(Fill),]; + let mut name_row = row![]; + + match &snapshot.name { + Some(agent_name) => { + name_row = + name_row.push(container(text(agent_name.to_string()).font(BOLD)).width(Fill)); + } + None => { + name_row = name_row.push(container("").width(Fill)); + } + }; if is_downloading { name_row = name_row.push( From dd4d76069b90056faf22dfae735716420c9d5214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 26 Mar 2026 00:05:03 +0100 Subject: [PATCH 32/46] use Duration::from_secs instead of from_millis --- paddler_second_brain_gui/src/second_brain.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 4a6be52e..f03b2b85 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -414,14 +414,12 @@ impl SecondBrain { actix_web::rt::System::new().block_on(async { let bootstrapped = bootstrap_balancer(BootstrapBalancerParams { - buffered_request_timeout: Duration::from_millis(10000), + buffered_request_timeout: Duration::from_secs(10), inference_service_configuration: InferenceServiceConfiguration { addr: inference_addr, cors_allowed_hosts: vec![], - inference_item_timeout: Duration::from_millis( - 30000, - ), + inference_item_timeout: Duration::from_secs(30), }, management_service_configuration: ManagementServiceConfiguration { From ece5922a2d7f79b905fd46f6cb3c4b5969944bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 26 Mar 2026 01:29:03 +0100 Subject: [PATCH 33/46] apply rustfmt changes --- paddler_second_brain_gui/src/second_brain.rs | 8 +++--- .../src/ui/view_agent_card.rs | 26 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index f03b2b85..1157ba60 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -148,8 +148,7 @@ impl SecondBrain { } let slots = if config.state_data.slots_count.is_empty() { - config.state_data.slots_error = - Some("Number of slots is required.".to_owned()); + config.state_data.slots_error = Some("Number of slots is required.".to_owned()); None } else { match config.state_data.slots_count.parse::() { @@ -592,7 +591,10 @@ impl SecondBrain { } } - #[expect(clippy::unused_self, reason = "signature required by iced application API")] + #[expect( + clippy::unused_self, + reason = "signature required by iced application API" + )] pub fn subscription(&self) -> Subscription { Subscription::none() } diff --git a/paddler_second_brain_gui/src/ui/view_agent_card.rs b/paddler_second_brain_gui/src/ui/view_agent_card.rs index 8d3e2ffc..0845665b 100644 --- a/paddler_second_brain_gui/src/ui/view_agent_card.rs +++ b/paddler_second_brain_gui/src/ui/view_agent_card.rs @@ -17,10 +17,7 @@ use super::variables::SPACING_HALF; use crate::message::Message; fn display_last_path_part(path: &str) -> String { - path.split('/') - .next_back() - .unwrap_or(path) - .to_owned() + path.split('/').next_back().unwrap_or(path).to_owned() } pub fn view_agent_card(snapshot: &AgentControllerSnapshot) -> Element<'_, Message> { @@ -31,8 +28,7 @@ pub fn view_agent_card(snapshot: &AgentControllerSnapshot) -> Element<'_, Messag match &snapshot.name { Some(agent_name) => { - name_row = - name_row.push(container(text(agent_name.clone()).font(BOLD)).width(Fill)); + name_row = name_row.push(container(text(agent_name.clone()).font(BOLD)).width(Fill)); } None => { name_row = name_row.push(container("").width(Fill)); @@ -41,7 +37,10 @@ pub fn view_agent_card(snapshot: &AgentControllerSnapshot) -> Element<'_, Messag if is_downloading { name_row = name_row.push( - #[expect(clippy::cast_precision_loss, reason = "download sizes fit in f32 mantissa")] + #[expect( + clippy::cast_precision_loss, + reason = "download sizes fit in f32 mantissa" + )] progress_bar( 0.0..=snapshot.download_total as f32, snapshot.download_current as f32, @@ -50,16 +49,19 @@ pub fn view_agent_card(snapshot: &AgentControllerSnapshot) -> Element<'_, Messag .style(style_download_progress_bar), ); } else { - let model_label = snapshot - .model_path - .as_ref() - .map_or_else(|| "No model loaded".to_owned(), |path| display_last_path_part(path)); + let model_label = snapshot.model_path.as_ref().map_or_else( + || "No model loaded".to_owned(), + |path| display_last_path_part(path), + ); name_row = name_row.push(text(model_label).font(REGULAR)); } let status_label = if is_downloading { - #[expect(clippy::cast_precision_loss, reason = "download sizes fit in f32 mantissa")] + #[expect( + clippy::cast_precision_loss, + reason = "download sizes fit in f32 mantissa" + )] let percentage = (snapshot.download_current as f32 / snapshot.download_total as f32) * 100.0; From 9f8aeea4ce3f7339b4dfddc1e676f5ae3af7c0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 26 Mar 2026 19:43:38 +0100 Subject: [PATCH 34/46] add cuda feature to paddler_cli --- package-lock.json | 13 +++++++++++++ paddler_cli/Cargo.toml | 1 + 2 files changed, 14 insertions(+) diff --git a/package-lock.json b/package-lock.json index cf1988f2..447776ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -222,6 +222,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -245,6 +246,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -268,6 +270,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1296,6 +1299,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1362,6 +1366,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -1763,6 +1768,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2868,6 +2874,7 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5426,6 +5433,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5538,6 +5546,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -5588,6 +5597,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5677,6 +5687,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5686,6 +5697,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7180,6 +7192,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/paddler_cli/Cargo.toml b/paddler_cli/Cargo.toml index b02e8f87..ed349379 100644 --- a/paddler_cli/Cargo.toml +++ b/paddler_cli/Cargo.toml @@ -27,6 +27,7 @@ workspace = true [features] default = [] +cuda = ["paddler/cuda"] web_admin_panel = [ "dep:esbuild-metafile", "paddler/web_admin_panel", From 1094ce959d5d44dcda766b6f6eff017aa12133fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 26 Mar 2026 20:36:38 +0100 Subject: [PATCH 35/46] switch port validation from TcpStream to TcpListener --- paddler_second_brain_gui/src/second_brain.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 1157ba60..767f25f8 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -1,6 +1,6 @@ use std::mem; use std::net::SocketAddr; -use std::net::TcpStream; +use std::net::TcpListener; use std::sync::Arc; use std::time::Duration; @@ -37,7 +37,7 @@ use crate::ui::view_running_cluster::view_running_cluster; use crate::ui::view_start_cluster_config::view_start_cluster_config; fn is_port_in_use(address: &SocketAddr) -> bool { - TcpStream::connect_timeout(address, Duration::from_millis(100)).is_ok() + TcpListener::bind(address).is_err() } fn collect_sorted_agent_snapshots( From a80bb4403146817406c1cef12c5992883f1798ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Fri, 27 Mar 2026 15:26:55 +0100 Subject: [PATCH 36/46] restructure the code to follow Iced scaling patterns; start the balancer with an empty state, rename balancer to cluster in user-facing code --- .../src/agent_running_handler.rs | 27 + paddler_second_brain_gui/src/home_handler.rs | 21 + .../src/join_cluster_config_handler.rs | 110 +++ paddler_second_brain_gui/src/main.rs | 5 + paddler_second_brain_gui/src/message.rs | 36 +- .../src/running_cluster_handler.rs | 34 + paddler_second_brain_gui/src/screen.rs | 6 +- paddler_second_brain_gui/src/second_brain.rs | 764 ++++++++---------- .../src/start_cluster_config_data.rs | 4 +- .../src/start_cluster_config_handler.rs | 137 ++++ .../src/ui/view_agent_card.rs | 6 +- .../src/ui/view_agent_running.rs | 6 +- paddler_second_brain_gui/src/ui/view_home.rs | 2 +- .../src/ui/view_join_cluster_config.rs | 2 +- .../src/ui/view_running_cluster.rs | 4 +- .../src/ui/view_start_cluster_config.rs | 10 +- 16 files changed, 692 insertions(+), 482 deletions(-) create mode 100644 paddler_second_brain_gui/src/agent_running_handler.rs create mode 100644 paddler_second_brain_gui/src/home_handler.rs create mode 100644 paddler_second_brain_gui/src/join_cluster_config_handler.rs create mode 100644 paddler_second_brain_gui/src/running_cluster_handler.rs create mode 100644 paddler_second_brain_gui/src/start_cluster_config_handler.rs diff --git a/paddler_second_brain_gui/src/agent_running_handler.rs b/paddler_second_brain_gui/src/agent_running_handler.rs new file mode 100644 index 00000000..78bfb814 --- /dev/null +++ b/paddler_second_brain_gui/src/agent_running_handler.rs @@ -0,0 +1,27 @@ +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + +use crate::agent_running_data::AgentRunningData; + +#[derive(Debug, Clone)] +pub enum Message { + AgentStatusUpdated(SlotAggregatedStatusSnapshot), + Disconnect, +} + +pub enum Action { + None, + Disconnect, +} + +impl AgentRunningData { + pub fn update(&mut self, message: Message) -> Action { + match message { + Message::AgentStatusUpdated(status) => { + self.apply_status(status); + + Action::None + } + Message::Disconnect => Action::Disconnect, + } + } +} diff --git a/paddler_second_brain_gui/src/home_handler.rs b/paddler_second_brain_gui/src/home_handler.rs new file mode 100644 index 00000000..6611b9d5 --- /dev/null +++ b/paddler_second_brain_gui/src/home_handler.rs @@ -0,0 +1,21 @@ +use crate::home_data::HomeData; + +#[derive(Debug, Clone, Copy)] +pub enum Message { + StartCluster, + JoinCluster, +} + +pub enum Action { + StartCluster, + JoinCluster, +} + +impl HomeData { + pub const fn update(message: Message) -> Action { + match message { + Message::StartCluster => Action::StartCluster, + Message::JoinCluster => Action::JoinCluster, + } + } +} diff --git a/paddler_second_brain_gui/src/join_cluster_config_handler.rs b/paddler_second_brain_gui/src/join_cluster_config_handler.rs new file mode 100644 index 00000000..eb386d8e --- /dev/null +++ b/paddler_second_brain_gui/src/join_cluster_config_handler.rs @@ -0,0 +1,110 @@ +use std::net::SocketAddr; + +use crate::join_cluster_config_data::JoinClusterConfigData; + +#[derive(Debug, Clone)] +pub enum Message { + SetAgentName(String), + SetClusterAddress(String), + SetSlotsCount(String), + Connect, + Cancel, +} + +pub enum Action { + None, + Cancel, + ConnectAgent { + agent_name: Option, + management_address: String, + slots: i32, + }, +} + +impl JoinClusterConfigData { + pub fn update(&mut self, message: Message) -> Action { + match message { + Message::SetAgentName(name) => { + self.agent_name = name; + + Action::None + } + Message::SetClusterAddress(address) => { + self.cluster_address = address; + self.cluster_address_error = None; + + Action::None + } + Message::SetSlotsCount(slots) => { + if slots.is_empty() || slots.chars().all(|character| character.is_ascii_digit()) { + self.slots_count = slots; + self.slots_error = None; + } + + Action::None + } + Message::Connect => self.validate_and_connect(), + Message::Cancel => Action::Cancel, + } + } + + fn validate_and_connect(&mut self) -> Action { + self.cluster_address_error = None; + self.slots_error = None; + + if self.cluster_address.is_empty() { + self.cluster_address_error = Some("Cluster address is required.".to_owned()); + } else if self.cluster_address.parse::().is_err() { + self.cluster_address_error = + Some("Invalid address, expected format: IP:port".to_owned()); + } + + let slots = if self.slots_count.is_empty() { + self.slots_error = Some("Number of slots is required.".to_owned()); + None + } else { + match self.slots_count.parse::() { + Ok(slots) if slots > 0 => Some(slots), + Ok(non_positive_slots) => { + log::debug!("User entered non-positive slot count: {non_positive_slots}"); + self.slots_error = Some( + "Invalid number of slots (the number should be greater than zero)." + .to_owned(), + ); + None + } + Err(error) => { + let message = match error.kind() { + std::num::IntErrorKind::PosOverflow => "Number of slots is too large.", + unexpected_kind => { + log::error!("Unexpected slots parse error: {unexpected_kind:?}"); + "Invalid number of slots." + } + }; + self.slots_error = Some(message.to_owned()); + None + } + } + }; + + if self.cluster_address_error.is_some() || self.slots_error.is_some() { + return Action::None; + } + + let Some(slots) = slots else { + return Action::None; + }; + + let agent_name = if self.agent_name.is_empty() { + None + } else { + Some(self.agent_name.clone()) + }; + + Action::ConnectAgent { + agent_name, + management_address: self.cluster_address.clone(), + slots, + } + } +} diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 8a0555d9..1d25faea 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,16 +1,21 @@ mod agent_running_data; +mod agent_running_handler; mod detect_network_interfaces; mod home_data; +mod home_handler; mod join_cluster_config_data; +mod join_cluster_config_handler; mod message; mod model_preset; mod network_interface_address; mod running_cluster_data; +mod running_cluster_handler; #[expect(unsafe_code, reason = "statum macros generate link_section statics")] mod screen; mod screen_current; mod second_brain; mod start_cluster_config_data; +mod start_cluster_config_handler; mod ui; use iced::Size; diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index af92e255..5f8d88b6 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -1,29 +1,19 @@ -use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; -use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; - -use crate::model_preset::ModelPreset; +use crate::agent_running_handler; +use crate::home_handler; +use crate::join_cluster_config_handler; +use crate::running_cluster_handler; +use crate::start_cluster_config_handler; #[derive(Debug, Clone)] pub enum Message { - AgentFailed(String), - AgentSnapshotsUpdated(Vec), - AgentStatusUpdated(SlotAggregatedStatusSnapshot), - AgentStopped, - Cancel, - ClusterFailed(String), + Home(home_handler::Message), + StartClusterConfig(start_cluster_config_handler::Message), + JoinClusterConfig(join_cluster_config_handler::Message), + RunningCluster(running_cluster_handler::Message), + AgentRunning(agent_running_handler::Message), ClusterStarted, ClusterStopped, - Confirm, - Connect, - CopyToClipboard(String), - Disconnect, - JoinCluster, - SelectModel(ModelPreset), - SetAgentName(String), - SetBalancerAddress(String), - SetClusterAddress(String), - SetInferenceAddress(String), - SetSlotsCount(String), - StartCluster, - Stop, + ClusterFailed(String), + AgentStopped, + AgentFailed(String), } diff --git a/paddler_second_brain_gui/src/running_cluster_handler.rs b/paddler_second_brain_gui/src/running_cluster_handler.rs new file mode 100644 index 00000000..32a29061 --- /dev/null +++ b/paddler_second_brain_gui/src/running_cluster_handler.rs @@ -0,0 +1,34 @@ +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + +use crate::running_cluster_data::RunningClusterData; + +#[derive(Debug, Clone)] +pub enum Message { + AgentSnapshotsUpdated(Vec), + Stop, + CopyToClipboard(String), +} + +pub enum Action { + None, + Stop, + CopyToClipboard(String), +} + +impl RunningClusterData { + pub fn update(&mut self, message: Message) -> Action { + match message { + Message::AgentSnapshotsUpdated(snapshots) => { + self.agent_snapshots = snapshots; + + Action::None + } + Message::Stop => { + self.stopping = true; + + Action::Stop + } + Message::CopyToClipboard(content) => Action::CopyToClipboard(content), + } + } +} diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_second_brain_gui/src/screen.rs index 145b45b9..d3ae1145 100644 --- a/paddler_second_brain_gui/src/screen.rs +++ b/paddler_second_brain_gui/src/screen.rs @@ -38,8 +38,8 @@ impl Screen { .unwrap_or_default(); self.transition_with(StartClusterConfigData { - balancer_address: format!("{suggested_address}:8060"), - balancer_address_error: None, + cluster_address: format!("{suggested_address}:8060"), + cluster_address_error: None, inference_address: format!("{suggested_address}:8061"), inference_address_error: None, model_error: None, @@ -105,7 +105,7 @@ impl Screen { pub fn cluster_started(self) -> Screen { self.transition_map(|config_data: StartClusterConfigData| RunningClusterData { agent_snapshots: vec![], - cluster_address: config_data.balancer_address, + cluster_address: config_data.cluster_address, stopping: false, }) } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 767f25f8..9f865f64 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -1,6 +1,5 @@ use std::mem; use std::net::SocketAddr; -use std::net::TcpListener; use std::sync::Arc; use std::time::Duration; @@ -22,12 +21,20 @@ use paddler_bootstrap::bootstrap_agent_params::BootstrapAgentParams; use paddler_bootstrap::bootstrap_balancer_params::BootstrapBalancerParams; use paddler_bootstrap::bootstrapped_agent_handle::bootstrap_agent; use paddler_bootstrap::bootstrapped_balancer_handle::bootstrap_balancer; +use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::oneshot; use tokio::sync::watch; +use crate::agent_running_handler; +use crate::home_data::HomeData; +use crate::home_handler; +use crate::join_cluster_config_handler; use crate::message::Message; -use crate::model_preset::ModelPreset; +use crate::running_cluster_handler; +use crate::screen::AgentRunning; +use crate::screen::Screen; use crate::screen_current::CurrentScreen; +use crate::start_cluster_config_handler; use crate::ui::variables::SPACING_2X; use crate::ui::variables::SPACING_BASE; use crate::ui::view_agent_running::view_agent_running; @@ -36,10 +43,6 @@ use crate::ui::view_join_cluster_config::view_join_cluster_config; use crate::ui::view_running_cluster::view_running_cluster; use crate::ui::view_start_cluster_config::view_start_cluster_config; -fn is_port_in_use(address: &SocketAddr) -> bool { - TcpListener::bind(address).is_err() -} - fn collect_sorted_agent_snapshots( pool: &AgentControllerPool, ) -> anyhow::Result> { @@ -93,415 +96,74 @@ impl SecondBrain { let screen = mem::take(&mut self.screen); match (screen, message) { - (CurrentScreen::Home(home), Message::JoinCluster) => { - self.screen = CurrentScreen::JoinClusterConfig(home.join_cluster()); - - Task::none() - } - (CurrentScreen::Home(home), Message::StartCluster) => { - self.screen = CurrentScreen::StartClusterConfig(home.start_cluster()); + (CurrentScreen::Home(home), Message::Home(msg)) => { + let action = HomeData::update(msg); - Task::none() - } - (CurrentScreen::JoinClusterConfig(config), Message::Cancel) => { - self.screen = CurrentScreen::Home(config.cancel()); - - Task::none() - } - (CurrentScreen::JoinClusterConfig(mut config), Message::SetAgentName(name)) => { - config.state_data.agent_name = name; - self.screen = CurrentScreen::JoinClusterConfig(config); - - Task::none() - } - (CurrentScreen::JoinClusterConfig(mut config), Message::SetClusterAddress(address)) => { - config.state_data.cluster_address = address; - config.state_data.cluster_address_error = None; - self.screen = CurrentScreen::JoinClusterConfig(config); - - Task::none() - } - (CurrentScreen::JoinClusterConfig(mut config), Message::SetSlotsCount(slots)) => { - if slots.is_empty() || slots.chars().all(|character| character.is_ascii_digit()) { - config.state_data.slots_count = slots; - config.state_data.slots_error = None; - } - self.screen = CurrentScreen::JoinClusterConfig(config); - - Task::none() - } - (CurrentScreen::JoinClusterConfig(mut config), Message::Connect) => { - config.state_data.cluster_address_error = None; - config.state_data.slots_error = None; - - if config.state_data.cluster_address.is_empty() { - config.state_data.cluster_address_error = - Some("Cluster address is required.".to_owned()); - } else if config - .state_data - .cluster_address - .parse::() - .is_err() - { - config.state_data.cluster_address_error = - Some("Invalid address, expected format: IP:port".to_owned()); - } + match action { + home_handler::Action::StartCluster => { + self.screen = CurrentScreen::StartClusterConfig(home.start_cluster()); - let slots = if config.state_data.slots_count.is_empty() { - config.state_data.slots_error = Some("Number of slots is required.".to_owned()); - None - } else { - match config.state_data.slots_count.parse::() { - Ok(slots) if slots > 0 => Some(slots), - Ok(non_positive_slots) => { - log::debug!( - "User entered non-positive slot count: {non_positive_slots}" - ); - config.state_data.slots_error = Some( - "Invalid number of slots (the number should be greater than zero)." - .to_owned(), - ); - None - } - Err(error) => { - let message = match error.kind() { - std::num::IntErrorKind::PosOverflow => { - "Number of slots is too large." - } - unexpected_kind => { - log::error!( - "Unexpected slots parse error: {unexpected_kind:?}" - ); - "Invalid number of slots." - } - }; - config.state_data.slots_error = Some(message.to_owned()); - None - } + Task::none() } - }; - - if config.state_data.cluster_address_error.is_some() - || config.state_data.slots_error.is_some() - { - self.screen = CurrentScreen::JoinClusterConfig(config); - - return Task::none(); - } - - let Some(slots) = slots else { - self.screen = CurrentScreen::JoinClusterConfig(config); - - return Task::none(); - }; - - let agent_name = if config.state_data.agent_name.is_empty() { - None - } else { - Some(config.state_data.agent_name.clone()) - }; - let management_address = config.state_data.cluster_address.clone(); - - let (agent_shutdown_tx, agent_shutdown_rx) = oneshot::channel::<()>(); - let (status_watch_tx, status_watch_rx) = - watch::channel::>>(None); - - self.agent_shutdown_tx = Some(agent_shutdown_tx); - self.screen = CurrentScreen::AgentRunning(config.connect()); - - Task::batch([ - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - actix_web::rt::System::new().block_on(async { - let bootstrapped = bootstrap_agent(BootstrapAgentParams { - agent_name, - management_address, - slots, - }); - - if status_watch_tx - .send(Some(bootstrapped.slot_aggregated_status.clone())) - .is_err() - { - return Err(anyhow::anyhow!( - "Monitor stream was dropped before receiving status" - )); - } - - bootstrapped - .service_manager - .run_forever(agent_shutdown_rx) - .await - }) - }) - .await - .map_err(|error| anyhow::anyhow!("Agent task panicked: {error}"))? - }, - |result: Result<(), anyhow::Error>| match result { - Ok(()) => Message::AgentStopped, - Err(error) => Message::AgentFailed(error.to_string()), - }, - ), - Task::stream(iced::stream::channel(1, async move |mut output| { - let mut watch_rx = status_watch_rx; - - let slot_aggregated_status = loop { - if watch_rx.changed().await.is_err() { - return; - } - let borrowed = watch_rx.borrow_and_update().clone(); - if let Some(status) = borrowed { - break status; - } - }; - - loop { - match slot_aggregated_status.make_snapshot() { - Ok(snapshot) => { - if output - .send(Message::AgentStatusUpdated(snapshot)) - .await - .is_err() - { - return; - } - } - Err(error) => { - log::error!("Failed to make agent status snapshot: {error}"); - - return; - } - } + home_handler::Action::JoinCluster => { + self.screen = CurrentScreen::JoinClusterConfig(home.join_cluster()); - tokio::select! { - () = slot_aggregated_status.update_notifier.notified() => {} - changed_result = watch_rx.changed() => { - if changed_result.is_err() { - return; - } - } - } - } - })), - ]) - } - (CurrentScreen::StartClusterConfig(config), Message::Cancel) => { - if let Some(shutdown_tx) = self.shutdown_tx.take() - && let Err(unsent_signal) = shutdown_tx.send(()) - { - log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); + Task::none() + } } - self.screen = CurrentScreen::Home(config.cancel()); - - Task::none() } - (CurrentScreen::StartClusterConfig(mut config), Message::SelectModel(preset)) => { - config.state_data.selected_model = Some(preset); - config.state_data.model_error = None; - self.screen = CurrentScreen::StartClusterConfig(config); + (CurrentScreen::JoinClusterConfig(mut config), Message::JoinClusterConfig(msg)) => { + let action = config.state_data.update(msg); - Task::none() - } - ( - CurrentScreen::StartClusterConfig(mut config), - Message::SetBalancerAddress(address), - ) => { - config.state_data.balancer_address = address; - config.state_data.balancer_address_error = None; - self.screen = CurrentScreen::StartClusterConfig(config); + match action { + join_cluster_config_handler::Action::None => { + self.screen = CurrentScreen::JoinClusterConfig(config); - Task::none() - } - ( - CurrentScreen::StartClusterConfig(mut config), - Message::SetInferenceAddress(address), - ) => { - config.state_data.inference_address = address; - config.state_data.inference_address_error = None; - self.screen = CurrentScreen::StartClusterConfig(config); - - Task::none() - } - (CurrentScreen::StartClusterConfig(mut config), Message::Confirm) => { - config.state_data.balancer_address_error = None; - config.state_data.inference_address_error = None; - config.state_data.model_error = None; + Task::none() + } + join_cluster_config_handler::Action::Cancel => { + self.screen = CurrentScreen::Home(config.cancel()); - if config.state_data.selected_model.is_none() { - config.state_data.model_error = Some("Please select a model.".to_owned()); + Task::none() + } + join_cluster_config_handler::Action::ConnectAgent { + agent_name, + management_address, + slots, + } => self.spawn_agent(config.connect(), agent_name, management_address, slots), } + } + (CurrentScreen::StartClusterConfig(mut config), Message::StartClusterConfig(msg)) => { + let action = config.state_data.update(msg); - let management_addr = if config.state_data.balancer_address.is_empty() { - config.state_data.balancer_address_error = - Some("Balancer address is required.".to_owned()); - None - } else if let Ok(addr) = config.state_data.balancer_address.parse::() { - Some(addr) - } else { - config.state_data.balancer_address_error = - Some("Invalid address, expected format: IP:port".to_owned()); - None - }; - - let inference_addr = if config.state_data.inference_address.is_empty() { - config.state_data.inference_address_error = - Some("Inference address is required.".to_owned()); - None - } else if let Ok(addr) = config.state_data.inference_address.parse::() { - Some(addr) - } else { - config.state_data.inference_address_error = - Some("Invalid address, expected format: IP:port".to_owned()); - None - }; + match action { + start_cluster_config_handler::Action::None => { + self.screen = CurrentScreen::StartClusterConfig(config); - let management_addr = match management_addr { - Some(addr) if is_port_in_use(&addr) => { - config.state_data.balancer_address_error = - Some(format!("Port {} is already in use", addr.port())); - None + Task::none() } - other => other, - }; + start_cluster_config_handler::Action::Cancel => { + if let Some(shutdown_tx) = self.shutdown_tx.take() + && let Err(unsent_signal) = shutdown_tx.send(()) + { + log::error!( + "Failed to send cluster shutdown signal: {unsent_signal:?}" + ); + } + self.screen = CurrentScreen::Home(config.cancel()); - let inference_addr = match inference_addr { - Some(addr) if is_port_in_use(&addr) => { - config.state_data.inference_address_error = - Some(format!("Port {} is already in use", addr.port())); - None + Task::none() + } + start_cluster_config_handler::Action::StartCluster { + management_addr, + inference_addr, + desired_state, + } => { + self.screen = CurrentScreen::StartClusterConfig(config); + + self.spawn_cluster(management_addr, inference_addr, desired_state) } - other => other, - }; - - if config.state_data.model_error.is_some() - || config.state_data.balancer_address_error.is_some() - || config.state_data.inference_address_error.is_some() - { - self.screen = CurrentScreen::StartClusterConfig(config); - - return Task::none(); } - - let (Some(management_addr), Some(inference_addr)) = - (management_addr, inference_addr) - else { - self.screen = CurrentScreen::StartClusterConfig(config); - - return Task::none(); - }; - - let desired_state = config - .state_data - .selected_model - .as_ref() - .map(ModelPreset::to_balancer_desired_state) - .unwrap_or_default(); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let (pool_watch_tx, pool_watch_rx) = - watch::channel::>>(None); - - self.shutdown_tx = Some(shutdown_tx); - config.state_data.starting = true; - self.screen = CurrentScreen::StartClusterConfig(config); - - Task::batch([ - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - actix_web::rt::System::new().block_on(async { - let bootstrapped = - bootstrap_balancer(BootstrapBalancerParams { - buffered_request_timeout: Duration::from_secs(10), - inference_service_configuration: - InferenceServiceConfiguration { - addr: inference_addr, - cors_allowed_hosts: vec![], - inference_item_timeout: Duration::from_secs(30), - }, - management_service_configuration: - ManagementServiceConfiguration { - addr: management_addr, - cors_allowed_hosts: vec![], - }, - max_buffered_requests: 30, - openai_service_configuration: None, - state_database_type: StateDatabaseType::Memory, - statsd_prefix: "paddler_".to_owned(), - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration: None, - }) - .await?; - - bootstrapped - .state_database - .store_balancer_desired_state(&desired_state) - .await?; - - if pool_watch_tx - .send(Some(bootstrapped.agent_controller_pool.clone())) - .is_err() - { - return Err(anyhow::anyhow!( - "Monitor stream was dropped before receiving pool" - )); - } - - bootstrapped.service_manager.run_forever(shutdown_rx).await - }) - }) - .await - .map_err(|error| anyhow::anyhow!("Balancer task panicked: {error}"))? - }, - |result: Result<(), anyhow::Error>| match result { - Ok(()) => Message::ClusterStopped, - Err(error) => Message::ClusterFailed(error.to_string()), - }, - ), - Task::done(Message::ClusterStarted), - Task::stream(iced::stream::channel(1, async move |mut output| { - let mut watch_rx = pool_watch_rx; - - let agent_controller_pool = loop { - if watch_rx.changed().await.is_err() { - return; - } - let borrowed = watch_rx.borrow_and_update().clone(); - if let Some(pool) = borrowed { - break pool; - } - }; - - loop { - match collect_sorted_agent_snapshots(&agent_controller_pool) { - Ok(snapshots) => { - if output - .send(Message::AgentSnapshotsUpdated(snapshots)) - .await - .is_err() - { - return; - } - } - Err(error) => { - log::error!("Failed to collect agent snapshots: {error}"); - - return; - } - } - - tokio::select! { - () = agent_controller_pool.update_notifier.notified() => {} - changed_result = watch_rx.changed() => { - if changed_result.is_err() { - return; - } - } - } - } - })), - ]) } (CurrentScreen::StartClusterConfig(config), Message::ClusterStarted) => { self.screen = CurrentScreen::RunningCluster(config.cluster_started()); @@ -515,25 +177,33 @@ impl SecondBrain { Task::none() } - ( - CurrentScreen::RunningCluster(mut running), - Message::AgentSnapshotsUpdated(snapshots), - ) => { - running.state_data.agent_snapshots = snapshots; - self.screen = CurrentScreen::RunningCluster(running); + (CurrentScreen::RunningCluster(mut running), Message::RunningCluster(msg)) => { + let action = running.state_data.update(msg); - Task::none() - } - (CurrentScreen::RunningCluster(mut running), Message::Stop) => { - if let Some(shutdown_tx) = self.shutdown_tx.take() - && let Err(unsent_signal) = shutdown_tx.send(()) - { - log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); - } - running.state_data.stopping = true; - self.screen = CurrentScreen::RunningCluster(running); + match action { + running_cluster_handler::Action::None => { + self.screen = CurrentScreen::RunningCluster(running); - Task::none() + Task::none() + } + running_cluster_handler::Action::Stop => { + if let Some(shutdown_tx) = self.shutdown_tx.take() + && let Err(unsent_signal) = shutdown_tx.send(()) + { + log::error!( + "Failed to send cluster shutdown signal: {unsent_signal:?}" + ); + } + self.screen = CurrentScreen::RunningCluster(running); + + Task::none() + } + running_cluster_handler::Action::CopyToClipboard(content) => { + self.screen = CurrentScreen::RunningCluster(running); + + iced::clipboard::write::(content).discard() + } + } } (CurrentScreen::RunningCluster(running), Message::ClusterStopped) => { self.screen = CurrentScreen::Home(running.cluster_stopped()); @@ -547,21 +217,26 @@ impl SecondBrain { Task::none() } - (CurrentScreen::AgentRunning(mut running), Message::AgentStatusUpdated(status)) => { - running.state_data.apply_status(status); - self.screen = CurrentScreen::AgentRunning(running); + (CurrentScreen::AgentRunning(mut running), Message::AgentRunning(msg)) => { + let action = running.state_data.update(msg); - Task::none() - } - (CurrentScreen::AgentRunning(running), Message::Disconnect) => { - if let Some(agent_shutdown_tx) = self.agent_shutdown_tx.take() - && let Err(unsent_signal) = agent_shutdown_tx.send(()) - { - log::error!("Failed to send agent shutdown signal: {unsent_signal:?}"); - } - self.screen = CurrentScreen::Home(running.disconnect()); + match action { + agent_running_handler::Action::None => { + self.screen = CurrentScreen::AgentRunning(running); - Task::none() + Task::none() + } + agent_running_handler::Action::Disconnect => { + if let Some(agent_shutdown_tx) = self.agent_shutdown_tx.take() + && let Err(unsent_signal) = agent_shutdown_tx.send(()) + { + log::error!("Failed to send agent shutdown signal: {unsent_signal:?}"); + } + self.screen = CurrentScreen::Home(running.disconnect()); + + Task::none() + } + } } (CurrentScreen::AgentRunning(running), Message::AgentStopped) => { log::info!("Agent stopped"); @@ -577,11 +252,6 @@ impl SecondBrain { Task::none() } - (screen, Message::CopyToClipboard(content)) => { - self.screen = screen; - - iced::clipboard::write::(content).discard() - } (screen, message) => { log::warn!("Unhandled message {message:?} for current screen"); self.screen = screen; @@ -601,15 +271,19 @@ impl SecondBrain { pub fn view(&self) -> Element<'_, Message> { let screen_content = match &self.screen { - CurrentScreen::AgentRunning(screen) => view_agent_running(&screen.state_data), - CurrentScreen::Home(screen) => view_home(&screen.state_data), + CurrentScreen::AgentRunning(screen) => { + view_agent_running(&screen.state_data).map(Message::AgentRunning) + } + CurrentScreen::Home(screen) => view_home(&screen.state_data).map(Message::Home), CurrentScreen::JoinClusterConfig(screen) => { - view_join_cluster_config(&screen.state_data) + view_join_cluster_config(&screen.state_data).map(Message::JoinClusterConfig) } CurrentScreen::StartClusterConfig(screen) => { - view_start_cluster_config(&screen.state_data) + view_start_cluster_config(&screen.state_data).map(Message::StartClusterConfig) + } + CurrentScreen::RunningCluster(screen) => { + view_running_cluster(&screen.state_data).map(Message::RunningCluster) } - CurrentScreen::RunningCluster(screen) => view_running_cluster(&screen.state_data), }; let content_column = column![screen_content] @@ -620,4 +294,216 @@ impl SecondBrain { container(content_column).center_x(Fill).into() } + + fn spawn_agent( + &mut self, + screen: Screen, + agent_name: Option, + management_address: String, + slots: i32, + ) -> Task { + let (agent_shutdown_tx, agent_shutdown_rx) = oneshot::channel::<()>(); + let (status_watch_tx, status_watch_rx) = + watch::channel::>>(None); + + self.agent_shutdown_tx = Some(agent_shutdown_tx); + self.screen = CurrentScreen::AgentRunning(screen); + + Task::batch([ + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + actix_web::rt::System::new().block_on(async { + let bootstrapped = bootstrap_agent(BootstrapAgentParams { + agent_name, + management_address, + slots, + }); + + if status_watch_tx + .send(Some(bootstrapped.slot_aggregated_status.clone())) + .is_err() + { + return Err(anyhow::anyhow!( + "Monitor stream was dropped before receiving status" + )); + } + + bootstrapped + .service_manager + .run_forever(agent_shutdown_rx) + .await + }) + }) + .await + .map_err(|error| anyhow::anyhow!("Agent task panicked: {error}"))? + }, + |result: Result<(), anyhow::Error>| match result { + Ok(()) => Message::AgentStopped, + Err(error) => Message::AgentFailed(error.to_string()), + }, + ), + Task::stream(iced::stream::channel(1, async move |mut output| { + let mut watch_rx = status_watch_rx; + + let slot_aggregated_status = loop { + if watch_rx.changed().await.is_err() { + return; + } + let borrowed = watch_rx.borrow_and_update().clone(); + if let Some(status) = borrowed { + break status; + } + }; + + loop { + match slot_aggregated_status.make_snapshot() { + Ok(snapshot) => { + if output + .send(Message::AgentRunning( + agent_running_handler::Message::AgentStatusUpdated(snapshot), + )) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to make agent status snapshot: {error}"); + + return; + } + } + + tokio::select! { + () = slot_aggregated_status.update_notifier.notified() => {} + changed_result = watch_rx.changed() => { + if changed_result.is_err() { + return; + } + } + } + } + })), + ]) + } + + fn spawn_cluster( + &mut self, + management_addr: SocketAddr, + inference_addr: SocketAddr, + desired_state: BalancerDesiredState, + ) -> Task { + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let (pool_watch_tx, pool_watch_rx) = + watch::channel::>>(None); + + self.shutdown_tx = Some(shutdown_tx); + + Task::batch([ + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + actix_web::rt::System::new().block_on(async { + let bootstrapped = bootstrap_balancer(BootstrapBalancerParams { + buffered_request_timeout: Duration::from_secs(10), + inference_service_configuration: InferenceServiceConfiguration { + addr: inference_addr, + cors_allowed_hosts: vec![], + inference_item_timeout: Duration::from_secs(30), + }, + management_service_configuration: ManagementServiceConfiguration { + addr: management_addr, + cors_allowed_hosts: vec![], + }, + max_buffered_requests: 30, + openai_service_configuration: None, + state_database_type: StateDatabaseType::Memory, + statsd_prefix: "paddler_".to_owned(), + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: None, + }) + .await?; + + if pool_watch_tx + .send(Some(bootstrapped.agent_controller_pool.clone())) + .is_err() + { + return Err(anyhow::anyhow!( + "Monitor stream was dropped before receiving pool" + )); + } + + let state_database = bootstrapped.state_database.clone(); + + let service_handle = actix_web::rt::spawn( + bootstrapped.service_manager.run_forever(shutdown_rx), + ); + + state_database + .store_balancer_desired_state(&desired_state) + .await?; + + service_handle.await.map_err(|error| { + anyhow::anyhow!("Service manager task failed: {error}") + })? + }) + }) + .await + .map_err(|error| anyhow::anyhow!("Balancer task panicked: {error}"))? + }, + |result: Result<(), anyhow::Error>| match result { + Ok(()) => Message::ClusterStopped, + Err(error) => Message::ClusterFailed(error.to_string()), + }, + ), + Task::done(Message::ClusterStarted), + Task::stream(iced::stream::channel(1, async move |mut output| { + let mut watch_rx = pool_watch_rx; + + let agent_controller_pool = loop { + if watch_rx.changed().await.is_err() { + return; + } + let borrowed = watch_rx.borrow_and_update().clone(); + if let Some(pool) = borrowed { + break pool; + } + }; + + loop { + match collect_sorted_agent_snapshots(&agent_controller_pool) { + Ok(snapshots) => { + if output + .send(Message::RunningCluster( + running_cluster_handler::Message::AgentSnapshotsUpdated( + snapshots, + ), + )) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to collect agent snapshots: {error}"); + + return; + } + } + + tokio::select! { + () = agent_controller_pool.update_notifier.notified() => {} + changed_result = watch_rx.changed() => { + if changed_result.is_err() { + return; + } + } + } + } + })), + ]) + } } diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_second_brain_gui/src/start_cluster_config_data.rs index baba3bc5..8a302add 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_data.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_data.rs @@ -1,8 +1,8 @@ use crate::model_preset::ModelPreset; pub struct StartClusterConfigData { - pub balancer_address: String, - pub balancer_address_error: Option, + pub cluster_address: String, + pub cluster_address_error: Option, pub inference_address: String, pub inference_address_error: Option, pub model_error: Option, diff --git a/paddler_second_brain_gui/src/start_cluster_config_handler.rs b/paddler_second_brain_gui/src/start_cluster_config_handler.rs new file mode 100644 index 00000000..8013a0a8 --- /dev/null +++ b/paddler_second_brain_gui/src/start_cluster_config_handler.rs @@ -0,0 +1,137 @@ +use std::net::SocketAddr; +use std::net::TcpListener; + +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::model_preset::ModelPreset; +use crate::start_cluster_config_data::StartClusterConfigData; + +fn is_port_in_use(address: &SocketAddr) -> bool { + TcpListener::bind(address).is_err() +} + +#[derive(Debug, Clone)] +pub enum Message { + SetClusterAddress(String), + SetInferenceAddress(String), + SelectModel(ModelPreset), + Confirm, + Cancel, +} + +#[expect( + clippy::large_enum_variant, + reason = "ephemeral value, immediately consumed" +)] +pub enum Action { + None, + Cancel, + StartCluster { + management_addr: SocketAddr, + inference_addr: SocketAddr, + desired_state: BalancerDesiredState, + }, +} + +impl StartClusterConfigData { + pub fn update(&mut self, message: Message) -> Action { + match message { + Message::SelectModel(preset) => { + self.selected_model = Some(preset); + self.model_error = None; + + Action::None + } + Message::SetClusterAddress(address) => { + self.cluster_address = address; + self.cluster_address_error = None; + + Action::None + } + Message::SetInferenceAddress(address) => { + self.inference_address = address; + self.inference_address_error = None; + + Action::None + } + Message::Confirm => self.validate_and_confirm(), + Message::Cancel => Action::Cancel, + } + } + + fn validate_and_confirm(&mut self) -> Action { + self.cluster_address_error = None; + self.inference_address_error = None; + self.model_error = None; + + if self.selected_model.is_none() { + self.model_error = Some("Please select a model.".to_owned()); + } + + let management_addr = if self.cluster_address.is_empty() { + self.cluster_address_error = Some("Cluster address is required.".to_owned()); + None + } else if let Ok(addr) = self.cluster_address.parse::() { + Some(addr) + } else { + self.cluster_address_error = + Some("Invalid address, expected format: IP:port".to_owned()); + None + }; + + let inference_addr = if self.inference_address.is_empty() { + self.inference_address_error = Some("Inference address is required.".to_owned()); + None + } else if let Ok(addr) = self.inference_address.parse::() { + Some(addr) + } else { + self.inference_address_error = + Some("Invalid address, expected format: IP:port".to_owned()); + None + }; + + let management_addr = match management_addr { + Some(addr) if is_port_in_use(&addr) => { + self.cluster_address_error = + Some(format!("Port {} is already in use", addr.port())); + None + } + other => other, + }; + + let inference_addr = match inference_addr { + Some(addr) if is_port_in_use(&addr) => { + self.inference_address_error = + Some(format!("Port {} is already in use", addr.port())); + None + } + other => other, + }; + + if self.model_error.is_some() + || self.cluster_address_error.is_some() + || self.inference_address_error.is_some() + { + return Action::None; + } + + let (Some(management_addr), Some(inference_addr)) = (management_addr, inference_addr) + else { + return Action::None; + }; + + let desired_state = self + .selected_model + .as_ref() + .map(ModelPreset::to_balancer_desired_state) + .unwrap_or_default(); + + self.starting = true; + + Action::StartCluster { + management_addr, + inference_addr, + desired_state, + } + } +} diff --git a/paddler_second_brain_gui/src/ui/view_agent_card.rs b/paddler_second_brain_gui/src/ui/view_agent_card.rs index 0845665b..269e3922 100644 --- a/paddler_second_brain_gui/src/ui/view_agent_card.rs +++ b/paddler_second_brain_gui/src/ui/view_agent_card.rs @@ -14,13 +14,13 @@ use super::style_agent_container::style_agent_container; use super::style_download_progress_bar::style_download_progress_bar; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; -use crate::message::Message; - fn display_last_path_part(path: &str) -> String { path.split('/').next_back().unwrap_or(path).to_owned() } -pub fn view_agent_card(snapshot: &AgentControllerSnapshot) -> Element<'_, Message> { +pub fn view_agent_card( + snapshot: &AgentControllerSnapshot, +) -> Element<'_, TMessage> { let is_downloading = snapshot.download_total > 0 && snapshot.download_current < snapshot.download_total; diff --git a/paddler_second_brain_gui/src/ui/view_agent_running.rs b/paddler_second_brain_gui/src/ui/view_agent_running.rs index cee32605..0f918ac7 100644 --- a/paddler_second_brain_gui/src/ui/view_agent_running.rs +++ b/paddler_second_brain_gui/src/ui/view_agent_running.rs @@ -18,7 +18,7 @@ use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; use super::view_agent_card::view_agent_card; use crate::agent_running_data::AgentRunningData; -use crate::message::Message; +use crate::agent_running_handler::Message; pub fn view_agent_running(data: &AgentRunningData) -> Element<'_, Message> { let stop_icon = svg(SvgHandle::from_memory( @@ -38,12 +38,12 @@ pub fn view_agent_running(data: &AgentRunningData) -> Element<'_, Message> { let connection_status = if data.connected { text(format!( - "Connected to the balancer at {}", + "Connected to the cluster at {}", data.cluster_address )) .font(REGULAR) } else { - text("Connecting to the balancer...").font(REGULAR) + text("Connecting to the cluster...").font(REGULAR) }; let status_row = container( diff --git a/paddler_second_brain_gui/src/ui/view_home.rs b/paddler_second_brain_gui/src/ui/view_home.rs index 6ba1de41..d1ddd7e4 100644 --- a/paddler_second_brain_gui/src/ui/view_home.rs +++ b/paddler_second_brain_gui/src/ui/view_home.rs @@ -19,7 +19,7 @@ use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; use crate::home_data::HomeData; -use crate::message::Message; +use crate::home_handler::Message; static CREATE_CLUSTER_IMAGE: LazyLock = LazyLock::new(|| { ImageHandle::from_bytes( diff --git a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs index fd0d6c12..c4b3a701 100644 --- a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs @@ -19,7 +19,7 @@ use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; use crate::join_cluster_config_data::JoinClusterConfigData; -use crate::message::Message; +use crate::join_cluster_config_handler::Message; pub fn view_join_cluster_config(data: &JoinClusterConfigData) -> Element<'_, Message> { let confirm_button = button(text("Connect").font(BOLD)) diff --git a/paddler_second_brain_gui/src/ui/view_running_cluster.rs b/paddler_second_brain_gui/src/ui/view_running_cluster.rs index e644986e..40ce9094 100644 --- a/paddler_second_brain_gui/src/ui/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/ui/view_running_cluster.rs @@ -22,8 +22,8 @@ use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; use super::view_agent_card::view_agent_card; -use crate::message::Message; use crate::running_cluster_data::RunningClusterData; +use crate::running_cluster_handler::Message; pub fn view_running_cluster(data: &RunningClusterData) -> Element<'_, Message> { let copy_icon = svg(SvgHandle::from_memory( @@ -34,7 +34,7 @@ pub fn view_running_cluster(data: &RunningClusterData) -> Element<'_, Message> { let address_row = container( row![ - container(text(format!("Balancer address: {}", data.cluster_address)).font(REGULAR)) + container(text(format!("Cluster address: {}", data.cluster_address)).font(REGULAR)) .width(Fill), button( row![copy_icon, text("Copy address").font(BOLD)] diff --git a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs index c70b05a3..5cb1afed 100644 --- a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs @@ -22,9 +22,9 @@ use super::variables::FONT_SIZE_L2; use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; -use crate::message::Message; use crate::model_preset::ModelPreset; use crate::start_cluster_config_data::StartClusterConfigData; +use crate::start_cluster_config_handler::Message; pub fn view_start_cluster_config(data: &StartClusterConfigData) -> Element<'_, Message> { let available_models = ModelPreset::available_presets(); @@ -45,10 +45,10 @@ pub fn view_start_cluster_config(data: &StartClusterConfigData) -> Element<'_, M .on_press(Message::Cancel); let mut balancer_field = column![ - container(text("Balancer address").font(BOLD)).padding([0.0, SPACING_BASE]), + container(text("Cluster address").font(BOLD)).padding([0.0, SPACING_BASE]), container( - text_input("IP:port", &data.balancer_address) - .on_input(Message::SetBalancerAddress) + text_input("IP:port", &data.cluster_address) + .on_input(Message::SetClusterAddress) .padding(SPACING_BASE) .style(style_field_text_input), ) @@ -57,7 +57,7 @@ pub fn view_start_cluster_config(data: &StartClusterConfigData) -> Element<'_, M ] .spacing(SPACING_HALF); - if let Some(error) = &data.balancer_address_error { + if let Some(error) = &data.cluster_address_error { balancer_field = balancer_field.push( container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) .width(400) From ca0e6be167d924ec3f8714b6d7a841656d23e305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Fri, 27 Mar 2026 17:26:13 +0100 Subject: [PATCH 37/46] extract a shared form field component --- Cargo.lock | 2 - paddler_second_brain_gui/Cargo.toml | 2 - paddler_second_brain_gui/src/ui/mod.rs | 1 + paddler_second_brain_gui/src/ui/variables.rs | 4 +- .../src/ui/view_form_field.rs | 33 ++++++ .../src/ui/view_join_cluster_config.rs | 79 ++++---------- .../src/ui/view_start_cluster_config.rs | 103 ++++++------------ 7 files changed, 91 insertions(+), 133 deletions(-) create mode 100644 paddler_second_brain_gui/src/ui/view_form_field.rs diff --git a/Cargo.lock b/Cargo.lock index 182a1078..20251a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4598,12 +4598,10 @@ version = "3.0.1" dependencies = [ "actix-web", "anyhow", - "async-trait", "env_logger", "iced", "if-addrs", "log", - "nanoid", "paddler", "paddler_bootstrap", "paddler_types", diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index 64151396..a4a69fa3 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -9,12 +9,10 @@ license.workspace = true [dependencies] actix-web = { workspace = true } anyhow = { workspace = true } -async-trait = { workspace = true } env_logger = { workspace = true } iced = { workspace = true } if-addrs = { workspace = true } log = { workspace = true } -nanoid = { workspace = true } paddler = { workspace = true } paddler_bootstrap = { workspace = true } paddler_types = { workspace = true } diff --git a/paddler_second_brain_gui/src/ui/mod.rs b/paddler_second_brain_gui/src/ui/mod.rs index f8d51eff..6db041bb 100644 --- a/paddler_second_brain_gui/src/ui/mod.rs +++ b/paddler_second_brain_gui/src/ui/mod.rs @@ -16,3 +16,4 @@ mod style_field_pick_list; mod style_field_pick_list_menu; mod style_field_text_input; mod view_agent_card; +mod view_form_field; diff --git a/paddler_second_brain_gui/src/ui/variables.rs b/paddler_second_brain_gui/src/ui/variables.rs index 42691425..b59b620e 100644 --- a/paddler_second_brain_gui/src/ui/variables.rs +++ b/paddler_second_brain_gui/src/ui/variables.rs @@ -2,8 +2,8 @@ use iced::Color; // Font sizes -pub const FONT_SIZE_BASE: f32 = 14.0; -pub const FONT_SIZE_L1: f32 = 1.5 * FONT_SIZE_BASE; +const FONT_SIZE_BASE: f32 = 14.0; +const FONT_SIZE_L1: f32 = 1.5 * FONT_SIZE_BASE; pub const FONT_SIZE_L2: f32 = 1.5 * FONT_SIZE_L1; // Spacing diff --git a/paddler_second_brain_gui/src/ui/view_form_field.rs b/paddler_second_brain_gui/src/ui/view_form_field.rs new file mode 100644 index 00000000..25c67eee --- /dev/null +++ b/paddler_second_brain_gui/src/ui/view_form_field.rs @@ -0,0 +1,33 @@ +use iced::Element; +use iced::widget::column; +use iced::widget::container; +use iced::widget::text; + +use super::font::BOLD; +use super::font::REGULAR; +use super::style_field_container::style_field_container; +use super::variables::COLOR_ERROR; +use super::variables::SPACING_BASE; +use super::variables::SPACING_HALF; + +pub fn view_form_field<'element, TMessage: 'static>( + label: &str, + input: Element<'element, TMessage>, + error: Option<&String>, +) -> Element<'element, TMessage> { + let mut field = column![ + container(text(label.to_owned()).font(BOLD)).padding([0.0, SPACING_BASE]), + container(input).width(400).style(style_field_container), + ] + .spacing(SPACING_HALF); + + if let Some(error) = error { + field = field.push( + container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) + .width(400) + .padding([0.0, SPACING_BASE]), + ); + } + + field.into() +} diff --git a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs index c4b3a701..f461e028 100644 --- a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs @@ -9,15 +9,13 @@ use iced::widget::text; use iced::widget::text_input; use super::font::BOLD; -use super::font::REGULAR; use super::style_button_primary::style_button_primary; -use super::style_field_container::style_field_container; use super::style_field_text_input::style_field_text_input; -use super::variables::COLOR_ERROR; use super::variables::FONT_SIZE_L2; use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; +use super::view_form_field::view_form_field; use crate::join_cluster_config_data::JoinClusterConfigData; use crate::join_cluster_config_handler::Message; @@ -31,65 +29,34 @@ pub fn view_join_cluster_config(data: &JoinClusterConfigData) -> Element<'_, Mes .style(button::text) .on_press(Message::Cancel); - let mut cluster_address_field = column![ - container(text("Cluster address").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("IP:port", &data.cluster_address) - .on_input(Message::SetClusterAddress) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(400) - .style(style_field_container), - ] - .spacing(SPACING_HALF); - - if let Some(error) = &data.cluster_address_error { - cluster_address_field = cluster_address_field.push( - container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) - .width(400) - .padding([0.0, SPACING_BASE]), - ); - } + let cluster_address_input = text_input("IP:port", &data.cluster_address) + .on_input(Message::SetClusterAddress) + .padding(SPACING_BASE) + .style(style_field_text_input) + .into(); - let mut slots_field = column![ - container(text("Slots").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("e.g. 1", &data.slots_count) - .on_input(Message::SetSlotsCount) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(400) - .style(style_field_container), - ] - .spacing(SPACING_HALF); + let agent_name_input = text_input("my-agent", &data.agent_name) + .on_input(Message::SetAgentName) + .padding(SPACING_BASE) + .style(style_field_text_input) + .into(); - if let Some(error) = &data.slots_error { - slots_field = slots_field.push( - container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) - .width(400) - .padding([0.0, SPACING_BASE]), - ); - } + let slots_input = text_input("e.g. 1", &data.slots_count) + .on_input(Message::SetSlotsCount) + .padding(SPACING_BASE) + .style(style_field_text_input) + .into(); column![ container(text("Join a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), - cluster_address_field, - column![ - container(text("Agent name (optional)").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("my-agent", &data.agent_name) - .on_input(Message::SetAgentName) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(400) - .style(style_field_container), - ] - .spacing(SPACING_HALF), - slots_field, + view_form_field( + "Cluster address", + cluster_address_input, + data.cluster_address_error.as_ref() + ), + view_form_field("Agent name (optional)", agent_name_input, None), + view_form_field("Slots", slots_input, data.slots_error.as_ref()), container( row![cancel_button, confirm_button] .align_y(Center) diff --git a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs index 5cb1afed..57e9534b 100644 --- a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs @@ -11,17 +11,15 @@ use iced::widget::text; use iced::widget::text_input; use super::font::BOLD; -use super::font::REGULAR; use super::style_button_primary::style_button_primary; -use super::style_field_container::style_field_container; use super::style_field_pick_list::style_field_pick_list; use super::style_field_pick_list_menu::style_field_pick_list_menu; use super::style_field_text_input::style_field_text_input; -use super::variables::COLOR_ERROR; use super::variables::FONT_SIZE_L2; use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; +use super::view_form_field::view_form_field; use crate::model_preset::ModelPreset; use crate::start_cluster_config_data::StartClusterConfigData; use crate::start_cluster_config_handler::Message; @@ -44,80 +42,43 @@ pub fn view_start_cluster_config(data: &StartClusterConfigData) -> Element<'_, M .style(button::text) .on_press(Message::Cancel); - let mut balancer_field = column![ - container(text("Cluster address").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("IP:port", &data.cluster_address) - .on_input(Message::SetClusterAddress) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(400) - .style(style_field_container), - ] - .spacing(SPACING_HALF); + let cluster_address_input = text_input("IP:port", &data.cluster_address) + .on_input(Message::SetClusterAddress) + .padding(SPACING_BASE) + .style(style_field_text_input) + .into(); - if let Some(error) = &data.cluster_address_error { - balancer_field = balancer_field.push( - container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) - .width(400) - .padding([0.0, SPACING_BASE]), - ); - } - - let mut inference_field = column![ - container(text("Inference address").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - text_input("IP:port", &data.inference_address) - .on_input(Message::SetInferenceAddress) - .padding(SPACING_BASE) - .style(style_field_text_input), - ) - .width(400) - .style(style_field_container), - ] - .spacing(SPACING_HALF); - - if let Some(error) = &data.inference_address_error { - inference_field = inference_field.push( - container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) - .width(400) - .padding([0.0, SPACING_BASE]), - ); - } - - let mut model_field = column![ - container(text("Select a model").font(BOLD)).padding([0.0, SPACING_BASE]), - container( - pick_list( - available_models, - data.selected_model.as_ref(), - Message::SelectModel, - ) - .width(Fill) - .padding(SPACING_BASE) - .style(style_field_pick_list) - .menu_style(style_field_pick_list_menu), - ) - .width(400) - .style(style_field_container), - ] - .spacing(SPACING_HALF); + let inference_address_input = text_input("IP:port", &data.inference_address) + .on_input(Message::SetInferenceAddress) + .padding(SPACING_BASE) + .style(style_field_text_input) + .into(); - if let Some(error) = &data.model_error { - model_field = model_field.push( - container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) - .width(400) - .padding([0.0, SPACING_BASE]), - ); - } + let model_input = pick_list( + available_models, + data.selected_model.as_ref(), + Message::SelectModel, + ) + .width(Fill) + .padding(SPACING_BASE) + .style(style_field_pick_list) + .menu_style(style_field_pick_list_menu) + .into(); column![ container(text("Start a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), - balancer_field, - inference_field, - model_field, + view_form_field( + "Cluster address", + cluster_address_input, + data.cluster_address_error.as_ref() + ), + view_form_field( + "Inference address", + inference_address_input, + data.inference_address_error.as_ref() + ), + view_form_field("Select a model", model_input, data.model_error.as_ref()), container( row![cancel_button, confirm_button] .align_y(Center) From dc4fb25284db458c878d3732f129d7a3d0bce25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 15 Apr 2026 21:50:47 +0200 Subject: [PATCH 38/46] update makefile an integration test to point to paddler_cli as the binary --- Makefile | 14 +++++++------- package-lock.json | 13 +++++++++++++ paddler_integration_tests/src/lib.rs | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 53325ebf..1da90515 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,8 @@ node_modules: package-lock.json npm install --from-lockfile touch node_modules -target/debug/paddler: $(shell find paddler/src paddler_types/src paddler_client/src -name '*.rs') - cargo build -p paddler +target/debug/paddler_cli: $(shell find paddler/src paddler_cli/src paddler_types/src paddler_client/src -name '*.rs') + cargo build -p paddler_cli # ----------------------------------------------------------------------------- # Phony targets @@ -41,19 +41,19 @@ jarmuz-static: node_modules .PHONY: release release: jarmuz-static - cargo build --release -p paddler --features web_admin_panel + cargo build --release -p paddler_cli --features web_admin_panel .PHONY: release.cuda release.cuda: jarmuz-static - cargo build --release -p paddler --features web_admin_panel,cuda + cargo build --release -p paddler_cli --features web_admin_panel,cuda .PHONY: release.vulkan release.vulkan: jarmuz-static - cargo build --release -p paddler --features web_admin_panel,vulkan + cargo build --release -p paddler_cli --features web_admin_panel,vulkan .PHONY: build build: jarmuz-static - cargo build -p paddler --features web_admin_panel + cargo build -p paddler_cli --features web_admin_panel .PHONY: test test: test.unit test.models test.integration @@ -67,7 +67,7 @@ test.unit: jarmuz-static cargo test --features web_admin_panel .PHONY: test.integration -test.integration: target/debug/paddler +test.integration: target/debug/paddler_cli cargo test -p paddler_integration_tests --features tests_that_use_compiled_paddler,tests_that_use_llms -- --nocapture --test-threads=1 .PHONY: watch diff --git a/package-lock.json b/package-lock.json index cf1988f2..447776ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -222,6 +222,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -245,6 +246,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -268,6 +270,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1296,6 +1299,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1362,6 +1366,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -1763,6 +1768,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2868,6 +2874,7 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5426,6 +5433,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5538,6 +5546,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -5588,6 +5597,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5677,6 +5687,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5686,6 +5697,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7180,6 +7192,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/paddler_integration_tests/src/lib.rs b/paddler_integration_tests/src/lib.rs index 89da2553..4000ac28 100644 --- a/paddler_integration_tests/src/lib.rs +++ b/paddler_integration_tests/src/lib.rs @@ -21,7 +21,7 @@ pub const WAIT_FOR_STATE_CHANGE_TIMEOUT: Duration = Duration::from_secs(30); pub const WAIT_FOR_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_millis(10); static PADDLER_BINARY_PATH: LazyLock = LazyLock::new(|| { - std::env::var("PADDLER_BINARY_PATH").unwrap_or_else(|_| "../target/debug/paddler".to_owned()) + std::env::var("PADDLER_BINARY_PATH").unwrap_or_else(|_| "../target/debug/paddler_cli".to_owned()) }); pub fn paddler_command() -> Command { From 351c4d5485b5b6b099329ff67a49654c65566226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 15 Apr 2026 23:31:44 +0200 Subject: [PATCH 39/46] add code styling fixes --- paddler_integration_tests/src/lib.rs | 3 +- .../{screen_current.rs => current_screen.rs} | 0 paddler_second_brain_gui/src/main.rs | 2 +- paddler_second_brain_gui/src/second_brain.rs | 45 ++++++------------- paddler_second_brain_gui/src/ui/mod.rs | 1 + .../src/ui/style_status_indicator.rs | 19 ++++++++ paddler_second_brain_gui/src/ui/variables.rs | 4 ++ .../src/ui/view_agent_card.rs | 6 ++- .../src/ui/view_form_field.rs | 3 +- .../src/ui/view_join_cluster_config.rs | 30 ++++++++----- .../src/ui/view_running_cluster.rs | 24 +++------- .../src/ui/view_start_cluster_config.rs | 38 +++++++++------- 12 files changed, 93 insertions(+), 82 deletions(-) rename paddler_second_brain_gui/src/{screen_current.rs => current_screen.rs} (100%) create mode 100644 paddler_second_brain_gui/src/ui/style_status_indicator.rs diff --git a/paddler_integration_tests/src/lib.rs b/paddler_integration_tests/src/lib.rs index 4000ac28..a036e0a9 100644 --- a/paddler_integration_tests/src/lib.rs +++ b/paddler_integration_tests/src/lib.rs @@ -21,7 +21,8 @@ pub const WAIT_FOR_STATE_CHANGE_TIMEOUT: Duration = Duration::from_secs(30); pub const WAIT_FOR_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_millis(10); static PADDLER_BINARY_PATH: LazyLock = LazyLock::new(|| { - std::env::var("PADDLER_BINARY_PATH").unwrap_or_else(|_| "../target/debug/paddler_cli".to_owned()) + std::env::var("PADDLER_BINARY_PATH") + .unwrap_or_else(|_| "../target/debug/paddler_cli".to_owned()) }); pub fn paddler_command() -> Command { diff --git a/paddler_second_brain_gui/src/screen_current.rs b/paddler_second_brain_gui/src/current_screen.rs similarity index 100% rename from paddler_second_brain_gui/src/screen_current.rs rename to paddler_second_brain_gui/src/current_screen.rs diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index 1d25faea..febb7577 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -1,5 +1,6 @@ mod agent_running_data; mod agent_running_handler; +mod current_screen; mod detect_network_interfaces; mod home_data; mod home_handler; @@ -12,7 +13,6 @@ mod running_cluster_data; mod running_cluster_handler; #[expect(unsafe_code, reason = "statum macros generate link_section statics")] mod screen; -mod screen_current; mod second_brain; mod start_cluster_config_data; mod start_cluster_config_handler; diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 9f865f64..e6ca43d9 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -26,6 +26,7 @@ use tokio::sync::oneshot; use tokio::sync::watch; use crate::agent_running_handler; +use crate::current_screen::CurrentScreen; use crate::home_data::HomeData; use crate::home_handler; use crate::join_cluster_config_handler; @@ -33,7 +34,6 @@ use crate::message::Message; use crate::running_cluster_handler; use crate::screen::AgentRunning; use crate::screen::Screen; -use crate::screen_current::CurrentScreen; use crate::start_cluster_config_handler; use crate::ui::variables::SPACING_2X; use crate::ui::variables::SPACING_BASE; @@ -43,6 +43,14 @@ use crate::ui::view_join_cluster_config::view_join_cluster_config; use crate::ui::view_running_cluster::view_running_cluster; use crate::ui::view_start_cluster_config::view_start_cluster_config; +fn send_shutdown(sender: &mut Option>, label: &str) { + if let Some(shutdown_tx) = sender.take() + && let Err(unsent_signal) = shutdown_tx.send(()) + { + log::error!("Failed to send {label} shutdown signal: {unsent_signal:?}"); + } +} + fn collect_sorted_agent_snapshots( pool: &AgentControllerPool, ) -> anyhow::Result> { @@ -67,17 +75,8 @@ pub struct SecondBrain { impl Drop for SecondBrain { fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() - && let Err(unsent_signal) = shutdown_tx.send(()) - { - log::error!("Failed to send cluster shutdown signal: {unsent_signal:?}"); - } - - if let Some(agent_shutdown_tx) = self.agent_shutdown_tx.take() - && let Err(unsent_signal) = agent_shutdown_tx.send(()) - { - log::error!("Failed to send agent shutdown signal: {unsent_signal:?}"); - } + send_shutdown(&mut self.shutdown_tx, "cluster"); + send_shutdown(&mut self.agent_shutdown_tx, "agent"); } } @@ -143,13 +142,7 @@ impl SecondBrain { Task::none() } start_cluster_config_handler::Action::Cancel => { - if let Some(shutdown_tx) = self.shutdown_tx.take() - && let Err(unsent_signal) = shutdown_tx.send(()) - { - log::error!( - "Failed to send cluster shutdown signal: {unsent_signal:?}" - ); - } + send_shutdown(&mut self.shutdown_tx, "cluster"); self.screen = CurrentScreen::Home(config.cancel()); Task::none() @@ -187,13 +180,7 @@ impl SecondBrain { Task::none() } running_cluster_handler::Action::Stop => { - if let Some(shutdown_tx) = self.shutdown_tx.take() - && let Err(unsent_signal) = shutdown_tx.send(()) - { - log::error!( - "Failed to send cluster shutdown signal: {unsent_signal:?}" - ); - } + send_shutdown(&mut self.shutdown_tx, "cluster"); self.screen = CurrentScreen::RunningCluster(running); Task::none() @@ -227,11 +214,7 @@ impl SecondBrain { Task::none() } agent_running_handler::Action::Disconnect => { - if let Some(agent_shutdown_tx) = self.agent_shutdown_tx.take() - && let Err(unsent_signal) = agent_shutdown_tx.send(()) - { - log::error!("Failed to send agent shutdown signal: {unsent_signal:?}"); - } + send_shutdown(&mut self.agent_shutdown_tx, "agent"); self.screen = CurrentScreen::Home(running.disconnect()); Task::none() diff --git a/paddler_second_brain_gui/src/ui/mod.rs b/paddler_second_brain_gui/src/ui/mod.rs index 6db041bb..cab22249 100644 --- a/paddler_second_brain_gui/src/ui/mod.rs +++ b/paddler_second_brain_gui/src/ui/mod.rs @@ -15,5 +15,6 @@ mod style_field_container; mod style_field_pick_list; mod style_field_pick_list_menu; mod style_field_text_input; +mod style_status_indicator; mod view_agent_card; mod view_form_field; diff --git a/paddler_second_brain_gui/src/ui/style_status_indicator.rs b/paddler_second_brain_gui/src/ui/style_status_indicator.rs new file mode 100644 index 00000000..b80b49c7 --- /dev/null +++ b/paddler_second_brain_gui/src/ui/style_status_indicator.rs @@ -0,0 +1,19 @@ +use iced::Background; +use iced::Border; +use iced::Color; +use iced::Theme; +use iced::widget::container; + +pub fn style_status_indicator(theme: &Theme) -> container::Style { + let base = container::transparent(theme); + + container::Style { + background: Some(Background::Color(Color::from_rgb8(0xEE, 0xFF, 0xEE))), + border: Border { + color: Color::from_rgb8(0xCC, 0xDD, 0xCC), + width: 2.0, + radius: 8.into(), + }, + ..base + } +} diff --git a/paddler_second_brain_gui/src/ui/variables.rs b/paddler_second_brain_gui/src/ui/variables.rs index b59b620e..8585804b 100644 --- a/paddler_second_brain_gui/src/ui/variables.rs +++ b/paddler_second_brain_gui/src/ui/variables.rs @@ -6,6 +6,10 @@ const FONT_SIZE_BASE: f32 = 14.0; const FONT_SIZE_L1: f32 = 1.5 * FONT_SIZE_BASE; pub const FONT_SIZE_L2: f32 = 1.5 * FONT_SIZE_L1; +// Sizing + +pub const FORM_WIDTH: f32 = 400.0; + // Spacing pub const SPACING_BASE: f32 = 16.0; diff --git a/paddler_second_brain_gui/src/ui/view_agent_card.rs b/paddler_second_brain_gui/src/ui/view_agent_card.rs index 269e3922..c680f0ed 100644 --- a/paddler_second_brain_gui/src/ui/view_agent_card.rs +++ b/paddler_second_brain_gui/src/ui/view_agent_card.rs @@ -15,7 +15,11 @@ use super::style_download_progress_bar::style_download_progress_bar; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; fn display_last_path_part(path: &str) -> String { - path.split('/').next_back().unwrap_or(path).to_owned() + std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_owned() } pub fn view_agent_card( diff --git a/paddler_second_brain_gui/src/ui/view_form_field.rs b/paddler_second_brain_gui/src/ui/view_form_field.rs index 25c67eee..b85934d5 100644 --- a/paddler_second_brain_gui/src/ui/view_form_field.rs +++ b/paddler_second_brain_gui/src/ui/view_form_field.rs @@ -17,14 +17,13 @@ pub fn view_form_field<'element, TMessage: 'static>( ) -> Element<'element, TMessage> { let mut field = column![ container(text(label.to_owned()).font(BOLD)).padding([0.0, SPACING_BASE]), - container(input).width(400).style(style_field_container), + container(input).style(style_field_container), ] .spacing(SPACING_HALF); if let Some(error) = error { field = field.push( container(text(error.clone()).font(REGULAR).color(COLOR_ERROR)) - .width(400) .padding([0.0, SPACING_BASE]), ); } diff --git a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs index f461e028..7246bd52 100644 --- a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs @@ -12,6 +12,7 @@ use super::font::BOLD; use super::style_button_primary::style_button_primary; use super::style_field_text_input::style_field_text_input; use super::variables::FONT_SIZE_L2; +use super::variables::FORM_WIDTH; use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; @@ -50,20 +51,25 @@ pub fn view_join_cluster_config(data: &JoinClusterConfigData) -> Element<'_, Mes column![ container(text("Join a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), - view_form_field( - "Cluster address", - cluster_address_input, - data.cluster_address_error.as_ref() - ), - view_form_field("Agent name (optional)", agent_name_input, None), - view_form_field("Slots", slots_input, data.slots_error.as_ref()), container( - row![cancel_button, confirm_button] - .align_y(Center) - .spacing(SPACING_BASE), + column![ + view_form_field( + "Cluster address", + cluster_address_input, + data.cluster_address_error.as_ref() + ), + view_form_field("Agent name (optional)", agent_name_input, None), + view_form_field("Slots", slots_input, data.slots_error.as_ref()), + container( + row![cancel_button, confirm_button] + .align_y(Center) + .spacing(SPACING_BASE), + ) + .align_x(Horizontal::Right), + ] + .spacing(SPACING_2X), ) - .width(400) - .align_x(Horizontal::Right), + .width(FORM_WIDTH), ] .spacing(SPACING_2X) .into() diff --git a/paddler_second_brain_gui/src/ui/view_running_cluster.rs b/paddler_second_brain_gui/src/ui/view_running_cluster.rs index 40ce9094..51b6359e 100644 --- a/paddler_second_brain_gui/src/ui/view_running_cluster.rs +++ b/paddler_second_brain_gui/src/ui/view_running_cluster.rs @@ -1,10 +1,6 @@ -use iced::Background; -use iced::Border; use iced::Center; -use iced::Color; use iced::Element; use iced::Fill; -use iced::Theme; use iced::widget::button; use iced::widget::column; use iced::widget::container; @@ -17,6 +13,7 @@ use super::font::BOLD; use super::font::REGULAR; use super::style_button_disconnect::style_button_disconnect; use super::style_card_container::style_card_container; +use super::style_status_indicator::style_status_indicator; use super::variables::FONT_SIZE_L2; use super::variables::SPACING_2X; use super::variables::SPACING_BASE; @@ -38,7 +35,7 @@ pub fn view_running_cluster(data: &RunningClusterData) -> Element<'_, Message> { .width(Fill), button( row![copy_icon, text("Copy address").font(BOLD)] - .spacing(SPACING_BASE / 2.0) + .spacing(SPACING_HALF) .align_y(Center), ) .style(button::text) @@ -55,19 +52,10 @@ pub fn view_running_cluster(data: &RunningClusterData) -> Element<'_, Message> { .width(16) .height(16); - let status_indicator = container("").width(16).height(16).style(|theme: &Theme| { - let base = container::transparent(theme); - - container::Style { - background: Some(Background::Color(Color::from_rgb8(0xEE, 0xFF, 0xEE))), - border: Border { - color: Color::from_rgb8(0xCC, 0xDD, 0xCC), - width: 2.0, - radius: 8.into(), - }, - ..base - } - }); + let status_indicator = container("") + .width(16) + .height(16) + .style(style_status_indicator); let stop_button = if data.stopping { button( diff --git a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs index 57e9534b..ced10e30 100644 --- a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs +++ b/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs @@ -16,6 +16,7 @@ use super::style_field_pick_list::style_field_pick_list; use super::style_field_pick_list_menu::style_field_pick_list_menu; use super::style_field_text_input::style_field_text_input; use super::variables::FONT_SIZE_L2; +use super::variables::FORM_WIDTH; use super::variables::SPACING_2X; use super::variables::SPACING_BASE; use super::variables::SPACING_HALF; @@ -68,24 +69,29 @@ pub fn view_start_cluster_config(data: &StartClusterConfigData) -> Element<'_, M column![ container(text("Start a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), - view_form_field( - "Cluster address", - cluster_address_input, - data.cluster_address_error.as_ref() - ), - view_form_field( - "Inference address", - inference_address_input, - data.inference_address_error.as_ref() - ), - view_form_field("Select a model", model_input, data.model_error.as_ref()), container( - row![cancel_button, confirm_button] - .align_y(Center) - .spacing(SPACING_BASE), + column![ + view_form_field( + "Cluster address", + cluster_address_input, + data.cluster_address_error.as_ref() + ), + view_form_field( + "Inference address", + inference_address_input, + data.inference_address_error.as_ref() + ), + view_form_field("Select a model", model_input, data.model_error.as_ref()), + container( + row![cancel_button, confirm_button] + .align_y(Center) + .spacing(SPACING_BASE), + ) + .align_x(Horizontal::Right), + ] + .spacing(SPACING_2X), ) - .width(400) - .align_x(Horizontal::Right), + .width(FORM_WIDTH), ] .spacing(SPACING_2X) .into() From 1c6d7858883a1385adb3e0c5b8e1bc5c652c2373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 15 Apr 2026 23:44:21 +0200 Subject: [PATCH 40/46] force the light theme --- paddler_second_brain_gui/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_second_brain_gui/src/main.rs index febb7577..290adeb6 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_second_brain_gui/src/main.rs @@ -19,6 +19,7 @@ mod start_cluster_config_handler; mod ui; use iced::Size; +use iced::Theme; use second_brain::SecondBrain; fn main() -> iced::Result { @@ -31,6 +32,7 @@ fn main() -> iced::Result { .font(include_bytes!( "../../resources/fonts/JetBrainsMono-Bold.ttf" )) + .theme(Theme::Light) .window_size(Size::new(800.0, 800.0)) .subscription(SecondBrain::subscription) .run() From 0c69392682355eba98bf1a85b3090f7fe6fc4eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Thu, 16 Apr 2026 00:15:13 +0200 Subject: [PATCH 41/46] Allow for tab key navigation where possible --- paddler_second_brain_gui/src/message.rs | 1 + paddler_second_brain_gui/src/second_brain.rs | 22 +++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_second_brain_gui/src/message.rs index 5f8d88b6..8d47dc5f 100644 --- a/paddler_second_brain_gui/src/message.rs +++ b/paddler_second_brain_gui/src/message.rs @@ -16,4 +16,5 @@ pub enum Message { ClusterFailed(String), AgentStopped, AgentFailed(String), + TabPressed { shift: bool }, } diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index e6ca43d9..8611c93d 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -9,8 +9,10 @@ use iced::Fill; use iced::Subscription; use iced::Task; use iced::futures::SinkExt; +use iced::keyboard; use iced::widget::column; use iced::widget::container; +use iced::widget::operation; use paddler::balancer::agent_controller_pool::AgentControllerPool; use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; use paddler::balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; @@ -235,6 +237,15 @@ impl SecondBrain { Task::none() } + (screen, Message::TabPressed { shift }) => { + self.screen = screen; + + if shift { + operation::focus_previous() + } else { + operation::focus_next() + } + } (screen, message) => { log::warn!("Unhandled message {message:?} for current screen"); self.screen = screen; @@ -249,7 +260,16 @@ impl SecondBrain { reason = "signature required by iced application API" )] pub fn subscription(&self) -> Subscription { - Subscription::none() + keyboard::listen().filter_map(|event| match event { + keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Tab), + modifiers, + .. + } => Some(Message::TabPressed { + shift: modifiers.shift(), + }), + _ => None, + }) } pub fn view(&self) -> Element<'_, Message> { From f2ba99ca99bd7cf23352be460d87c99253714e9b Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 16 Apr 2026 22:56:31 +0200 Subject: [PATCH 42/46] add headless unit tests for desktop gui state machine --- .../src/agent_running_handler.rs | 84 +++++++++ paddler_second_brain_gui/src/home_handler.rs | 23 +++ .../src/join_cluster_config_handler.rs | 167 +++++++++++++++++ .../src/running_cluster_handler.rs | 71 +++++++ paddler_second_brain_gui/src/second_brain.rs | 108 +++++++++++ .../src/start_cluster_config_handler.rs | 175 ++++++++++++++++++ 6 files changed, 628 insertions(+) diff --git a/paddler_second_brain_gui/src/agent_running_handler.rs b/paddler_second_brain_gui/src/agent_running_handler.rs index 78bfb814..13dc721e 100644 --- a/paddler_second_brain_gui/src/agent_running_handler.rs +++ b/paddler_second_brain_gui/src/agent_running_handler.rs @@ -25,3 +25,87 @@ impl AgentRunningData { } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + + use super::Action; + use super::AgentRunningData; + use super::Message; + + fn make_data() -> AgentRunningData { + AgentRunningData { + cluster_address: "127.0.0.1:8060".to_owned(), + connected: false, + snapshot: AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: String::new(), + issues: BTreeSet::new(), + model_path: None, + name: Some("before-update".to_owned()), + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } + } + + fn make_status() -> SlotAggregatedStatusSnapshot { + SlotAggregatedStatusSnapshot { + desired_slots_total: 4, + download_current: 128, + download_filename: Some("weights.gguf".to_owned()), + download_total: 256, + issues: BTreeSet::new(), + model_path: Some("/tmp/model.gguf".to_owned()), + slots_processing: 1, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Applied, + uses_chat_template_override: false, + version: 0, + } + } + + #[test] + fn status_update_sets_connected_and_returns_none_action() { + let mut data = make_data(); + + let action = data.update(Message::AgentStatusUpdated(make_status())); + + assert!(matches!(action, Action::None)); + assert!(data.connected); + } + + #[test] + fn status_update_applies_status_fields_and_preserves_name() { + let mut data = make_data(); + + data.update(Message::AgentStatusUpdated(make_status())); + + assert_eq!(data.snapshot.desired_slots_total, 4); + assert_eq!(data.snapshot.download_current, 128); + assert_eq!(data.snapshot.download_total, 256); + assert_eq!(data.snapshot.slots_processing, 1); + assert_eq!(data.snapshot.slots_total, 4); + assert_eq!(data.snapshot.model_path.as_deref(), Some("/tmp/model.gguf")); + assert_eq!(data.snapshot.name.as_deref(), Some("before-update")); + } + + #[test] + fn disconnect_returns_disconnect_action() { + let mut data = make_data(); + + let action = data.update(Message::Disconnect); + + assert!(matches!(action, Action::Disconnect)); + } +} diff --git a/paddler_second_brain_gui/src/home_handler.rs b/paddler_second_brain_gui/src/home_handler.rs index 6611b9d5..8faa564e 100644 --- a/paddler_second_brain_gui/src/home_handler.rs +++ b/paddler_second_brain_gui/src/home_handler.rs @@ -19,3 +19,26 @@ impl HomeData { } } } + +#[cfg(test)] +mod tests { + use super::Action; + use super::HomeData; + use super::Message; + + #[test] + fn start_cluster_message_produces_start_cluster_action() { + assert!(matches!( + HomeData::update(Message::StartCluster), + Action::StartCluster + )); + } + + #[test] + fn join_cluster_message_produces_join_cluster_action() { + assert!(matches!( + HomeData::update(Message::JoinCluster), + Action::JoinCluster + )); + } +} diff --git a/paddler_second_brain_gui/src/join_cluster_config_handler.rs b/paddler_second_brain_gui/src/join_cluster_config_handler.rs index eb386d8e..d05ddc94 100644 --- a/paddler_second_brain_gui/src/join_cluster_config_handler.rs +++ b/paddler_second_brain_gui/src/join_cluster_config_handler.rs @@ -108,3 +108,170 @@ impl JoinClusterConfigData { } } } + +#[cfg(test)] +mod tests { + use super::Action; + use super::JoinClusterConfigData; + use super::Message; + + fn make_data() -> JoinClusterConfigData { + JoinClusterConfigData::default() + } + + #[test] + fn set_agent_name_updates_field() { + let mut data = make_data(); + + let action = data.update(Message::SetAgentName("agent-1".to_owned())); + + assert!(matches!(action, Action::None)); + assert_eq!(data.agent_name, "agent-1"); + } + + #[test] + fn set_cluster_address_clears_prior_error() { + let mut data = make_data(); + data.cluster_address_error = Some("stale error".to_owned()); + + let action = data.update(Message::SetClusterAddress("127.0.0.1:8060".to_owned())); + + assert!(matches!(action, Action::None)); + assert_eq!(data.cluster_address, "127.0.0.1:8060"); + assert!(data.cluster_address_error.is_none()); + } + + #[test] + fn set_slots_count_accepts_digit_string() { + let mut data = make_data(); + + data.update(Message::SetSlotsCount("42".to_owned())); + + assert_eq!(data.slots_count, "42"); + } + + #[test] + fn set_slots_count_rejects_non_digit_characters() { + let mut data = make_data(); + data.slots_count = "7".to_owned(); + + data.update(Message::SetSlotsCount("7a".to_owned())); + + assert_eq!(data.slots_count, "7"); + } + + #[test] + fn connect_with_empty_cluster_address_sets_cluster_address_error() { + let mut data = make_data(); + data.slots_count = "4".to_owned(); + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert_eq!( + data.cluster_address_error.as_deref(), + Some("Cluster address is required.") + ); + } + + #[test] + fn connect_with_invalid_cluster_address_sets_format_error() { + let mut data = make_data(); + data.cluster_address = "not-an-address".to_owned(); + data.slots_count = "4".to_owned(); + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert_eq!( + data.cluster_address_error.as_deref(), + Some("Invalid address, expected format: IP:port") + ); + } + + #[test] + fn connect_with_empty_slots_sets_slots_error() { + let mut data = make_data(); + data.cluster_address = "127.0.0.1:8060".to_owned(); + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert_eq!( + data.slots_error.as_deref(), + Some("Number of slots is required.") + ); + } + + #[test] + fn connect_with_zero_slots_sets_slots_error() { + let mut data = make_data(); + data.cluster_address = "127.0.0.1:8060".to_owned(); + data.slots_count = "0".to_owned(); + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert!(data.slots_error.is_some()); + } + + #[test] + fn connect_with_overflowing_slots_sets_too_large_error() { + let mut data = make_data(); + data.cluster_address = "127.0.0.1:8060".to_owned(); + data.slots_count = "99999999999999".to_owned(); + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert_eq!( + data.slots_error.as_deref(), + Some("Number of slots is too large.") + ); + } + + #[test] + fn connect_with_valid_inputs_returns_connect_agent_action() { + let mut data = make_data(); + data.cluster_address = "127.0.0.1:8060".to_owned(); + data.agent_name = "my-agent".to_owned(); + data.slots_count = "4".to_owned(); + + let action = data.update(Message::Connect); + + assert!(matches!( + &action, + Action::ConnectAgent { + agent_name: Some(agent_name), + management_address, + slots: 4, + } if agent_name == "my-agent" && management_address == "127.0.0.1:8060" + )); + } + + #[test] + fn connect_with_empty_agent_name_produces_none_name() { + let mut data = make_data(); + data.cluster_address = "127.0.0.1:8060".to_owned(); + data.slots_count = "4".to_owned(); + + let action = data.update(Message::Connect); + + assert!(matches!( + action, + Action::ConnectAgent { + agent_name: None, + .. + } + )); + } + + #[test] + fn cancel_returns_cancel_action() { + let mut data = make_data(); + + let action = data.update(Message::Cancel); + + assert!(matches!(action, Action::Cancel)); + } +} diff --git a/paddler_second_brain_gui/src/running_cluster_handler.rs b/paddler_second_brain_gui/src/running_cluster_handler.rs index 32a29061..2f9af176 100644 --- a/paddler_second_brain_gui/src/running_cluster_handler.rs +++ b/paddler_second_brain_gui/src/running_cluster_handler.rs @@ -32,3 +32,74 @@ impl RunningClusterData { } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + + use super::Action; + use super::Message; + use super::RunningClusterData; + + fn make_data() -> RunningClusterData { + RunningClusterData { + agent_snapshots: vec![], + cluster_address: "127.0.0.1:8060".to_owned(), + stopping: false, + } + } + + fn make_snapshot(id: &str) -> AgentControllerSnapshot { + AgentControllerSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + id: id.to_owned(), + issues: BTreeSet::new(), + model_path: None, + name: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + } + } + + #[test] + fn agent_snapshots_updated_replaces_existing_snapshots() { + let mut data = make_data(); + data.agent_snapshots = vec![make_snapshot("stale")]; + + let action = data.update(Message::AgentSnapshotsUpdated(vec![ + make_snapshot("fresh-1"), + make_snapshot("fresh-2"), + ])); + + assert!(matches!(action, Action::None)); + assert_eq!(data.agent_snapshots.len(), 2); + assert_eq!(data.agent_snapshots[0].id, "fresh-1"); + } + + #[test] + fn stop_marks_stopping_and_returns_stop_action() { + let mut data = make_data(); + + let action = data.update(Message::Stop); + + assert!(matches!(action, Action::Stop)); + assert!(data.stopping); + } + + #[test] + fn copy_to_clipboard_returns_action_with_content() { + let mut data = make_data(); + + let action = data.update(Message::CopyToClipboard("paste-me".to_owned())); + + assert!(matches!(action, Action::CopyToClipboard(content) if content == "paste-me")); + } +} diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index 8611c93d..c3c71b4d 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -510,3 +510,111 @@ impl SecondBrain { ]) } } + +#[cfg(test)] +mod tests { + use crate::current_screen::CurrentScreen; + use crate::home_handler; + use crate::message::Message; + use crate::second_brain::SecondBrain; + + fn fresh_brain() -> SecondBrain { + let (brain, _initial_task) = SecondBrain::new(); + + brain + } + + #[test] + fn initial_screen_is_home() { + let brain = fresh_brain(); + + assert!(matches!(brain.screen, CurrentScreen::Home(_))); + } + + #[test] + fn home_start_cluster_transitions_to_start_cluster_config() { + let mut brain = fresh_brain(); + + let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); + + assert!(matches!(brain.screen, CurrentScreen::StartClusterConfig(_))); + } + + #[test] + fn home_join_cluster_transitions_to_join_cluster_config() { + let mut brain = fresh_brain(); + + let _ = brain.update(Message::Home(home_handler::Message::JoinCluster)); + + assert!(matches!(brain.screen, CurrentScreen::JoinClusterConfig(_))); + } + + #[test] + fn cluster_started_moves_start_cluster_config_to_running_cluster() { + let mut brain = fresh_brain(); + let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); + + let _ = brain.update(Message::ClusterStarted); + + assert!(matches!(brain.screen, CurrentScreen::RunningCluster(_))); + } + + #[test] + fn cluster_failed_on_start_cluster_config_returns_to_home_with_error() { + let mut brain = fresh_brain(); + let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); + + let _ = brain.update(Message::ClusterFailed("boom".to_owned())); + + assert!(matches!( + &brain.screen, + CurrentScreen::Home(screen) if screen.state_data.error.as_deref() == Some("boom") + )); + } + + #[test] + fn cluster_stopped_on_running_cluster_returns_to_home_without_error() { + let mut brain = fresh_brain(); + let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); + let _ = brain.update(Message::ClusterStarted); + + let _ = brain.update(Message::ClusterStopped); + + assert!(matches!( + &brain.screen, + CurrentScreen::Home(screen) if screen.state_data.error.is_none() + )); + } + + #[test] + fn cluster_failed_on_running_cluster_returns_to_home_with_error() { + let mut brain = fresh_brain(); + let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); + let _ = brain.update(Message::ClusterStarted); + + let _ = brain.update(Message::ClusterFailed("mid-flight".to_owned())); + + assert!(matches!( + &brain.screen, + CurrentScreen::Home(screen) if screen.state_data.error.as_deref() == Some("mid-flight") + )); + } + + #[test] + fn unhandled_message_preserves_current_screen() { + let mut brain = fresh_brain(); + + let _ = brain.update(Message::ClusterStarted); + + assert!(matches!(brain.screen, CurrentScreen::Home(_))); + } + + #[test] + fn tab_pressed_preserves_current_screen() { + let mut brain = fresh_brain(); + + let _ = brain.update(Message::TabPressed { shift: false }); + + assert!(matches!(brain.screen, CurrentScreen::Home(_))); + } +} diff --git a/paddler_second_brain_gui/src/start_cluster_config_handler.rs b/paddler_second_brain_gui/src/start_cluster_config_handler.rs index 8013a0a8..3acf36f5 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_handler.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_handler.rs @@ -135,3 +135,178 @@ impl StartClusterConfigData { } } } + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use anyhow::Context as _; + use anyhow::Result; + + use super::Action; + use super::Message; + use super::StartClusterConfigData; + use crate::model_preset::ModelPreset; + + fn make_data() -> StartClusterConfigData { + StartClusterConfigData { + cluster_address: String::new(), + cluster_address_error: None, + inference_address: String::new(), + inference_address_error: None, + model_error: None, + selected_model: None, + starting: false, + } + } + + fn first_preset() -> Result { + ModelPreset::available_presets() + .into_iter() + .next() + .context("available_presets must expose at least one model") + } + + fn ephemeral_local_addr() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + + drop(listener); + + Ok(format!("127.0.0.1:{port}")) + } + + #[test] + fn select_model_sets_model_and_clears_error() -> Result<()> { + let mut data = make_data(); + data.model_error = Some("pick one".to_owned()); + + let action = data.update(Message::SelectModel(first_preset()?)); + + assert!(matches!(action, Action::None)); + assert!(data.selected_model.is_some()); + assert!(data.model_error.is_none()); + + Ok(()) + } + + #[test] + fn set_cluster_address_clears_prior_error() { + let mut data = make_data(); + data.cluster_address_error = Some("stale".to_owned()); + + let action = data.update(Message::SetClusterAddress("127.0.0.1:8060".to_owned())); + + assert!(matches!(action, Action::None)); + assert_eq!(data.cluster_address, "127.0.0.1:8060"); + assert!(data.cluster_address_error.is_none()); + } + + #[test] + fn set_inference_address_clears_prior_error() { + let mut data = make_data(); + data.inference_address_error = Some("stale".to_owned()); + + let action = data.update(Message::SetInferenceAddress("127.0.0.1:8061".to_owned())); + + assert!(matches!(action, Action::None)); + assert_eq!(data.inference_address, "127.0.0.1:8061"); + assert!(data.inference_address_error.is_none()); + } + + #[test] + fn confirm_without_model_sets_model_error() -> Result<()> { + let mut data = make_data(); + data.cluster_address = ephemeral_local_addr()?; + data.inference_address = ephemeral_local_addr()?; + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert!(data.model_error.is_some()); + + Ok(()) + } + + #[test] + fn confirm_with_empty_cluster_address_sets_cluster_address_error() -> Result<()> { + let mut data = make_data(); + data.selected_model = Some(first_preset()?); + data.inference_address = ephemeral_local_addr()?; + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert_eq!( + data.cluster_address_error.as_deref(), + Some("Cluster address is required.") + ); + + Ok(()) + } + + #[test] + fn confirm_with_invalid_cluster_address_sets_format_error() -> Result<()> { + let mut data = make_data(); + data.selected_model = Some(first_preset()?); + data.cluster_address = "not-an-address".to_owned(); + data.inference_address = ephemeral_local_addr()?; + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert_eq!( + data.cluster_address_error.as_deref(), + Some("Invalid address, expected format: IP:port") + ); + + Ok(()) + } + + #[test] + fn confirm_with_port_already_in_use_sets_error_mentioning_port() -> Result<()> { + let busy_listener = TcpListener::bind("127.0.0.1:0")?; + let busy_port = busy_listener.local_addr()?.port(); + let mut data = make_data(); + data.selected_model = Some(first_preset()?); + data.cluster_address = format!("127.0.0.1:{busy_port}"); + data.inference_address = ephemeral_local_addr()?; + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert!( + data.cluster_address_error + .as_deref() + .is_some_and(|message| message.contains(&busy_port.to_string())) + ); + + drop(busy_listener); + + Ok(()) + } + + #[test] + fn confirm_with_valid_inputs_returns_start_cluster_action_and_marks_starting() -> Result<()> { + let mut data = make_data(); + data.selected_model = Some(first_preset()?); + data.cluster_address = ephemeral_local_addr()?; + data.inference_address = ephemeral_local_addr()?; + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::StartCluster { .. })); + assert!(data.starting); + + Ok(()) + } + + #[test] + fn cancel_returns_cancel_action() { + let mut data = make_data(); + + let action = data.update(Message::Cancel); + + assert!(matches!(action, Action::Cancel)); + } +} From aebfd22fe4ed15e865147fd9cc48684f943c407b Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 16 Apr 2026 23:25:11 +0200 Subject: [PATCH 43/46] add headless wayland launch test for desktop gui --- Cargo.lock | 2 + Makefile | 4 + paddler_second_brain_gui/Cargo.toml | 5 + paddler_second_brain_gui/shell.nix | 31 ++++ .../tests/application_startup.rs | 152 ++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 paddler_second_brain_gui/shell.nix create mode 100644 paddler_second_brain_gui/tests/application_startup.rs diff --git a/Cargo.lock b/Cargo.lock index c89b0817..65cec1ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4590,10 +4590,12 @@ dependencies = [ "iced", "if-addrs", "log", + "nix", "paddler", "paddler_bootstrap", "paddler_types", "statum", + "tempfile", "tokio", ] diff --git a/Makefile b/Makefile index b1da541e..eeba5cc7 100644 --- a/Makefile +++ b/Makefile @@ -74,6 +74,10 @@ test.unit: jarmuz-static test.integration: target/debug/paddler_cli timeout 300 cargo test -p paddler_integration_tests --features tests_that_use_compiled_paddler,tests_that_use_llms -- --nocapture --test-threads=1 +.PHONY: test.gui +test.gui: + nix-shell paddler_second_brain_gui/shell.nix --run "timeout 120 cargo test -p paddler_second_brain_gui --features tests_that_use_headless_wayland -- --nocapture --test-threads=1" + .PHONY: watch watch: node_modules ./jarmuz-watch.mjs diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index a4a69fa3..1400620d 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -19,9 +19,14 @@ paddler_types = { workspace = true } statum = { workspace = true } tokio = { workspace = true } +[dev-dependencies] +nix = { workspace = true } +tempfile = { workspace = true } + [lints] workspace = true [features] default = [] +tests_that_use_headless_wayland = [] web_admin_panel = ["paddler/web_admin_panel"] diff --git a/paddler_second_brain_gui/shell.nix b/paddler_second_brain_gui/shell.nix new file mode 100644 index 00000000..10870714 --- /dev/null +++ b/paddler_second_brain_gui/shell.nix @@ -0,0 +1,31 @@ +{ pkgs ? import { } }: + +let + runtimeLibraries = with pkgs; [ + fontconfig + freetype + libGL + libxkbcommon + vulkan-loader + wayland + ]; +in +pkgs.mkShell { + name = "paddler-second-brain-gui"; + + nativeBuildInputs = with pkgs; [ + pkg-config + weston + ]; + + buildInputs = runtimeLibraries ++ (with pkgs; [ + wayland-protocols + ]); + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibraries}:''${LD_LIBRARY_PATH:-}" + export XDG_RUNTIME_DIR="''${TMPDIR:-/tmp}/paddler-gui-runtime-$$" + mkdir -p "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + ''; +} diff --git a/paddler_second_brain_gui/tests/application_startup.rs b/paddler_second_brain_gui/tests/application_startup.rs new file mode 100644 index 00000000..699b9ed9 --- /dev/null +++ b/paddler_second_brain_gui/tests/application_startup.rs @@ -0,0 +1,152 @@ +#![cfg(feature = "tests_that_use_headless_wayland")] + +use std::os::unix::process::ExitStatusExt as _; +use std::path::Path; +use std::process::Child; +use std::process::Command; +use std::process::ExitStatus; +use std::process::Stdio; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use nix::sys::signal::Signal; +use nix::sys::signal::kill; +use nix::unistd::Pid; +use tempfile::TempDir; + +const COMPOSITOR_READY_DEADLINE: Duration = Duration::from_secs(10); +const GUI_EVENT_LOOP_DEADLINE: Duration = Duration::from_secs(10); +const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5); +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +fn spawn_weston(runtime_dir: &Path, socket_name: &str) -> Result { + Command::new("weston") + .arg("--backend=headless") + .arg(format!("--socket={socket_name}")) + .env("XDG_RUNTIME_DIR", runtime_dir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("failed to spawn weston; ensure it is on PATH (nix-shell paddler_second_brain_gui/shell.nix)") +} + +fn wait_for_socket(socket_path: &Path, weston: &mut Child) -> Result<()> { + let deadline = Instant::now() + COMPOSITOR_READY_DEADLINE; + + while Instant::now() < deadline { + if socket_path.exists() { + return Ok(()); + } + if let Some(status) = weston.try_wait()? { + return Err(anyhow!( + "weston exited before creating socket {}: {status}", + socket_path.display() + )); + } + + thread::sleep(POLL_INTERVAL); + } + + Err(anyhow!( + "weston did not create socket {} within {:?}", + socket_path.display(), + COMPOSITOR_READY_DEADLINE + )) +} + +fn spawn_gui(runtime_dir: &Path, socket_name: &str) -> Result { + Command::new(env!("CARGO_BIN_EXE_paddler_second_brain_gui")) + .env("XDG_RUNTIME_DIR", runtime_dir) + .env("WAYLAND_DISPLAY", socket_name) + .env_remove("DISPLAY") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("failed to spawn paddler_second_brain_gui binary") +} + +fn ensure_gui_reached_event_loop(gui: &mut Child) -> Result<()> { + let deadline = Instant::now() + GUI_EVENT_LOOP_DEADLINE; + + while Instant::now() < deadline { + if let Some(status) = gui.try_wait()? { + return Err(anyhow!( + "paddler_second_brain_gui exited before reaching event loop: {status}" + )); + } + + thread::sleep(POLL_INTERVAL); + } + + Ok(()) +} + +fn terminate_and_wait(child: &mut Child) -> Result { + let raw_pid = child + .id() + .try_into() + .context("child pid does not fit in i32")?; + let pid = Pid::from_raw(raw_pid); + kill(pid, Signal::SIGTERM).context("failed to send SIGTERM")?; + + let deadline = Instant::now() + SHUTDOWN_DEADLINE; + + while Instant::now() < deadline { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + + thread::sleep(POLL_INTERVAL); + } + child + .kill() + .context("failed to SIGKILL child after SIGTERM timeout")?; + + child + .wait() + .context("failed to wait for child after SIGKILL") +} + +fn assert_terminated_cleanly(status: ExitStatus, process_label: &str) -> Result<()> { + if status.signal() == Some(Signal::SIGTERM as i32) { + return Ok(()); + } + if status.success() { + return Ok(()); + } + + Err(anyhow!( + "{process_label} did not terminate cleanly: {status:?}" + )) +} + +#[test] +fn application_reaches_event_loop() -> Result<()> { + let runtime_dir = TempDir::new().context("failed to create runtime dir for wayland socket")?; + let socket_name = format!("wayland-paddler-{}", std::process::id()); + let socket_path = runtime_dir.path().join(&socket_name); + + let mut weston = spawn_weston(runtime_dir.path(), &socket_name)?; + let wait_result = wait_for_socket(&socket_path, &mut weston); + if let Err(err) = wait_result { + let _ = terminate_and_wait(&mut weston); + + return Err(err); + } + + let mut gui = spawn_gui(runtime_dir.path(), &socket_name)?; + let event_loop_result = ensure_gui_reached_event_loop(&mut gui); + + let gui_status = terminate_and_wait(&mut gui)?; + let weston_status = terminate_and_wait(&mut weston)?; + + event_loop_result?; + assert_terminated_cleanly(gui_status, "paddler_second_brain_gui")?; + assert_terminated_cleanly(weston_status, "weston")?; + + Ok(()) +} From 1f0356c315cf1319cc3b677d5a7cc47135f9b187 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 16 Apr 2026 23:31:12 +0200 Subject: [PATCH 44/46] Revert "add headless unit tests for desktop gui state machine" This reverts commit f2ba99ca99bd7cf23352be460d87c99253714e9b. --- .../src/agent_running_handler.rs | 84 --------- paddler_second_brain_gui/src/home_handler.rs | 23 --- .../src/join_cluster_config_handler.rs | 167 ----------------- .../src/running_cluster_handler.rs | 71 ------- paddler_second_brain_gui/src/second_brain.rs | 108 ----------- .../src/start_cluster_config_handler.rs | 175 ------------------ 6 files changed, 628 deletions(-) diff --git a/paddler_second_brain_gui/src/agent_running_handler.rs b/paddler_second_brain_gui/src/agent_running_handler.rs index 13dc721e..78bfb814 100644 --- a/paddler_second_brain_gui/src/agent_running_handler.rs +++ b/paddler_second_brain_gui/src/agent_running_handler.rs @@ -25,87 +25,3 @@ impl AgentRunningData { } } } - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; - use paddler_types::agent_state_application_status::AgentStateApplicationStatus; - use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; - - use super::Action; - use super::AgentRunningData; - use super::Message; - - fn make_data() -> AgentRunningData { - AgentRunningData { - cluster_address: "127.0.0.1:8060".to_owned(), - connected: false, - snapshot: AgentControllerSnapshot { - desired_slots_total: 0, - download_current: 0, - download_filename: None, - download_total: 0, - id: String::new(), - issues: BTreeSet::new(), - model_path: None, - name: Some("before-update".to_owned()), - slots_processing: 0, - slots_total: 0, - state_application_status: AgentStateApplicationStatus::Fresh, - uses_chat_template_override: false, - }, - } - } - - fn make_status() -> SlotAggregatedStatusSnapshot { - SlotAggregatedStatusSnapshot { - desired_slots_total: 4, - download_current: 128, - download_filename: Some("weights.gguf".to_owned()), - download_total: 256, - issues: BTreeSet::new(), - model_path: Some("/tmp/model.gguf".to_owned()), - slots_processing: 1, - slots_total: 4, - state_application_status: AgentStateApplicationStatus::Applied, - uses_chat_template_override: false, - version: 0, - } - } - - #[test] - fn status_update_sets_connected_and_returns_none_action() { - let mut data = make_data(); - - let action = data.update(Message::AgentStatusUpdated(make_status())); - - assert!(matches!(action, Action::None)); - assert!(data.connected); - } - - #[test] - fn status_update_applies_status_fields_and_preserves_name() { - let mut data = make_data(); - - data.update(Message::AgentStatusUpdated(make_status())); - - assert_eq!(data.snapshot.desired_slots_total, 4); - assert_eq!(data.snapshot.download_current, 128); - assert_eq!(data.snapshot.download_total, 256); - assert_eq!(data.snapshot.slots_processing, 1); - assert_eq!(data.snapshot.slots_total, 4); - assert_eq!(data.snapshot.model_path.as_deref(), Some("/tmp/model.gguf")); - assert_eq!(data.snapshot.name.as_deref(), Some("before-update")); - } - - #[test] - fn disconnect_returns_disconnect_action() { - let mut data = make_data(); - - let action = data.update(Message::Disconnect); - - assert!(matches!(action, Action::Disconnect)); - } -} diff --git a/paddler_second_brain_gui/src/home_handler.rs b/paddler_second_brain_gui/src/home_handler.rs index 8faa564e..6611b9d5 100644 --- a/paddler_second_brain_gui/src/home_handler.rs +++ b/paddler_second_brain_gui/src/home_handler.rs @@ -19,26 +19,3 @@ impl HomeData { } } } - -#[cfg(test)] -mod tests { - use super::Action; - use super::HomeData; - use super::Message; - - #[test] - fn start_cluster_message_produces_start_cluster_action() { - assert!(matches!( - HomeData::update(Message::StartCluster), - Action::StartCluster - )); - } - - #[test] - fn join_cluster_message_produces_join_cluster_action() { - assert!(matches!( - HomeData::update(Message::JoinCluster), - Action::JoinCluster - )); - } -} diff --git a/paddler_second_brain_gui/src/join_cluster_config_handler.rs b/paddler_second_brain_gui/src/join_cluster_config_handler.rs index d05ddc94..eb386d8e 100644 --- a/paddler_second_brain_gui/src/join_cluster_config_handler.rs +++ b/paddler_second_brain_gui/src/join_cluster_config_handler.rs @@ -108,170 +108,3 @@ impl JoinClusterConfigData { } } } - -#[cfg(test)] -mod tests { - use super::Action; - use super::JoinClusterConfigData; - use super::Message; - - fn make_data() -> JoinClusterConfigData { - JoinClusterConfigData::default() - } - - #[test] - fn set_agent_name_updates_field() { - let mut data = make_data(); - - let action = data.update(Message::SetAgentName("agent-1".to_owned())); - - assert!(matches!(action, Action::None)); - assert_eq!(data.agent_name, "agent-1"); - } - - #[test] - fn set_cluster_address_clears_prior_error() { - let mut data = make_data(); - data.cluster_address_error = Some("stale error".to_owned()); - - let action = data.update(Message::SetClusterAddress("127.0.0.1:8060".to_owned())); - - assert!(matches!(action, Action::None)); - assert_eq!(data.cluster_address, "127.0.0.1:8060"); - assert!(data.cluster_address_error.is_none()); - } - - #[test] - fn set_slots_count_accepts_digit_string() { - let mut data = make_data(); - - data.update(Message::SetSlotsCount("42".to_owned())); - - assert_eq!(data.slots_count, "42"); - } - - #[test] - fn set_slots_count_rejects_non_digit_characters() { - let mut data = make_data(); - data.slots_count = "7".to_owned(); - - data.update(Message::SetSlotsCount("7a".to_owned())); - - assert_eq!(data.slots_count, "7"); - } - - #[test] - fn connect_with_empty_cluster_address_sets_cluster_address_error() { - let mut data = make_data(); - data.slots_count = "4".to_owned(); - - let action = data.update(Message::Connect); - - assert!(matches!(action, Action::None)); - assert_eq!( - data.cluster_address_error.as_deref(), - Some("Cluster address is required.") - ); - } - - #[test] - fn connect_with_invalid_cluster_address_sets_format_error() { - let mut data = make_data(); - data.cluster_address = "not-an-address".to_owned(); - data.slots_count = "4".to_owned(); - - let action = data.update(Message::Connect); - - assert!(matches!(action, Action::None)); - assert_eq!( - data.cluster_address_error.as_deref(), - Some("Invalid address, expected format: IP:port") - ); - } - - #[test] - fn connect_with_empty_slots_sets_slots_error() { - let mut data = make_data(); - data.cluster_address = "127.0.0.1:8060".to_owned(); - - let action = data.update(Message::Connect); - - assert!(matches!(action, Action::None)); - assert_eq!( - data.slots_error.as_deref(), - Some("Number of slots is required.") - ); - } - - #[test] - fn connect_with_zero_slots_sets_slots_error() { - let mut data = make_data(); - data.cluster_address = "127.0.0.1:8060".to_owned(); - data.slots_count = "0".to_owned(); - - let action = data.update(Message::Connect); - - assert!(matches!(action, Action::None)); - assert!(data.slots_error.is_some()); - } - - #[test] - fn connect_with_overflowing_slots_sets_too_large_error() { - let mut data = make_data(); - data.cluster_address = "127.0.0.1:8060".to_owned(); - data.slots_count = "99999999999999".to_owned(); - - let action = data.update(Message::Connect); - - assert!(matches!(action, Action::None)); - assert_eq!( - data.slots_error.as_deref(), - Some("Number of slots is too large.") - ); - } - - #[test] - fn connect_with_valid_inputs_returns_connect_agent_action() { - let mut data = make_data(); - data.cluster_address = "127.0.0.1:8060".to_owned(); - data.agent_name = "my-agent".to_owned(); - data.slots_count = "4".to_owned(); - - let action = data.update(Message::Connect); - - assert!(matches!( - &action, - Action::ConnectAgent { - agent_name: Some(agent_name), - management_address, - slots: 4, - } if agent_name == "my-agent" && management_address == "127.0.0.1:8060" - )); - } - - #[test] - fn connect_with_empty_agent_name_produces_none_name() { - let mut data = make_data(); - data.cluster_address = "127.0.0.1:8060".to_owned(); - data.slots_count = "4".to_owned(); - - let action = data.update(Message::Connect); - - assert!(matches!( - action, - Action::ConnectAgent { - agent_name: None, - .. - } - )); - } - - #[test] - fn cancel_returns_cancel_action() { - let mut data = make_data(); - - let action = data.update(Message::Cancel); - - assert!(matches!(action, Action::Cancel)); - } -} diff --git a/paddler_second_brain_gui/src/running_cluster_handler.rs b/paddler_second_brain_gui/src/running_cluster_handler.rs index 2f9af176..32a29061 100644 --- a/paddler_second_brain_gui/src/running_cluster_handler.rs +++ b/paddler_second_brain_gui/src/running_cluster_handler.rs @@ -32,74 +32,3 @@ impl RunningClusterData { } } } - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; - use paddler_types::agent_state_application_status::AgentStateApplicationStatus; - - use super::Action; - use super::Message; - use super::RunningClusterData; - - fn make_data() -> RunningClusterData { - RunningClusterData { - agent_snapshots: vec![], - cluster_address: "127.0.0.1:8060".to_owned(), - stopping: false, - } - } - - fn make_snapshot(id: &str) -> AgentControllerSnapshot { - AgentControllerSnapshot { - desired_slots_total: 0, - download_current: 0, - download_filename: None, - download_total: 0, - id: id.to_owned(), - issues: BTreeSet::new(), - model_path: None, - name: None, - slots_processing: 0, - slots_total: 0, - state_application_status: AgentStateApplicationStatus::Fresh, - uses_chat_template_override: false, - } - } - - #[test] - fn agent_snapshots_updated_replaces_existing_snapshots() { - let mut data = make_data(); - data.agent_snapshots = vec![make_snapshot("stale")]; - - let action = data.update(Message::AgentSnapshotsUpdated(vec![ - make_snapshot("fresh-1"), - make_snapshot("fresh-2"), - ])); - - assert!(matches!(action, Action::None)); - assert_eq!(data.agent_snapshots.len(), 2); - assert_eq!(data.agent_snapshots[0].id, "fresh-1"); - } - - #[test] - fn stop_marks_stopping_and_returns_stop_action() { - let mut data = make_data(); - - let action = data.update(Message::Stop); - - assert!(matches!(action, Action::Stop)); - assert!(data.stopping); - } - - #[test] - fn copy_to_clipboard_returns_action_with_content() { - let mut data = make_data(); - - let action = data.update(Message::CopyToClipboard("paste-me".to_owned())); - - assert!(matches!(action, Action::CopyToClipboard(content) if content == "paste-me")); - } -} diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_second_brain_gui/src/second_brain.rs index c3c71b4d..8611c93d 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_second_brain_gui/src/second_brain.rs @@ -510,111 +510,3 @@ impl SecondBrain { ]) } } - -#[cfg(test)] -mod tests { - use crate::current_screen::CurrentScreen; - use crate::home_handler; - use crate::message::Message; - use crate::second_brain::SecondBrain; - - fn fresh_brain() -> SecondBrain { - let (brain, _initial_task) = SecondBrain::new(); - - brain - } - - #[test] - fn initial_screen_is_home() { - let brain = fresh_brain(); - - assert!(matches!(brain.screen, CurrentScreen::Home(_))); - } - - #[test] - fn home_start_cluster_transitions_to_start_cluster_config() { - let mut brain = fresh_brain(); - - let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); - - assert!(matches!(brain.screen, CurrentScreen::StartClusterConfig(_))); - } - - #[test] - fn home_join_cluster_transitions_to_join_cluster_config() { - let mut brain = fresh_brain(); - - let _ = brain.update(Message::Home(home_handler::Message::JoinCluster)); - - assert!(matches!(brain.screen, CurrentScreen::JoinClusterConfig(_))); - } - - #[test] - fn cluster_started_moves_start_cluster_config_to_running_cluster() { - let mut brain = fresh_brain(); - let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); - - let _ = brain.update(Message::ClusterStarted); - - assert!(matches!(brain.screen, CurrentScreen::RunningCluster(_))); - } - - #[test] - fn cluster_failed_on_start_cluster_config_returns_to_home_with_error() { - let mut brain = fresh_brain(); - let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); - - let _ = brain.update(Message::ClusterFailed("boom".to_owned())); - - assert!(matches!( - &brain.screen, - CurrentScreen::Home(screen) if screen.state_data.error.as_deref() == Some("boom") - )); - } - - #[test] - fn cluster_stopped_on_running_cluster_returns_to_home_without_error() { - let mut brain = fresh_brain(); - let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); - let _ = brain.update(Message::ClusterStarted); - - let _ = brain.update(Message::ClusterStopped); - - assert!(matches!( - &brain.screen, - CurrentScreen::Home(screen) if screen.state_data.error.is_none() - )); - } - - #[test] - fn cluster_failed_on_running_cluster_returns_to_home_with_error() { - let mut brain = fresh_brain(); - let _ = brain.update(Message::Home(home_handler::Message::StartCluster)); - let _ = brain.update(Message::ClusterStarted); - - let _ = brain.update(Message::ClusterFailed("mid-flight".to_owned())); - - assert!(matches!( - &brain.screen, - CurrentScreen::Home(screen) if screen.state_data.error.as_deref() == Some("mid-flight") - )); - } - - #[test] - fn unhandled_message_preserves_current_screen() { - let mut brain = fresh_brain(); - - let _ = brain.update(Message::ClusterStarted); - - assert!(matches!(brain.screen, CurrentScreen::Home(_))); - } - - #[test] - fn tab_pressed_preserves_current_screen() { - let mut brain = fresh_brain(); - - let _ = brain.update(Message::TabPressed { shift: false }); - - assert!(matches!(brain.screen, CurrentScreen::Home(_))); - } -} diff --git a/paddler_second_brain_gui/src/start_cluster_config_handler.rs b/paddler_second_brain_gui/src/start_cluster_config_handler.rs index 3acf36f5..8013a0a8 100644 --- a/paddler_second_brain_gui/src/start_cluster_config_handler.rs +++ b/paddler_second_brain_gui/src/start_cluster_config_handler.rs @@ -135,178 +135,3 @@ impl StartClusterConfigData { } } } - -#[cfg(test)] -mod tests { - use std::net::TcpListener; - - use anyhow::Context as _; - use anyhow::Result; - - use super::Action; - use super::Message; - use super::StartClusterConfigData; - use crate::model_preset::ModelPreset; - - fn make_data() -> StartClusterConfigData { - StartClusterConfigData { - cluster_address: String::new(), - cluster_address_error: None, - inference_address: String::new(), - inference_address_error: None, - model_error: None, - selected_model: None, - starting: false, - } - } - - fn first_preset() -> Result { - ModelPreset::available_presets() - .into_iter() - .next() - .context("available_presets must expose at least one model") - } - - fn ephemeral_local_addr() -> Result { - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - - drop(listener); - - Ok(format!("127.0.0.1:{port}")) - } - - #[test] - fn select_model_sets_model_and_clears_error() -> Result<()> { - let mut data = make_data(); - data.model_error = Some("pick one".to_owned()); - - let action = data.update(Message::SelectModel(first_preset()?)); - - assert!(matches!(action, Action::None)); - assert!(data.selected_model.is_some()); - assert!(data.model_error.is_none()); - - Ok(()) - } - - #[test] - fn set_cluster_address_clears_prior_error() { - let mut data = make_data(); - data.cluster_address_error = Some("stale".to_owned()); - - let action = data.update(Message::SetClusterAddress("127.0.0.1:8060".to_owned())); - - assert!(matches!(action, Action::None)); - assert_eq!(data.cluster_address, "127.0.0.1:8060"); - assert!(data.cluster_address_error.is_none()); - } - - #[test] - fn set_inference_address_clears_prior_error() { - let mut data = make_data(); - data.inference_address_error = Some("stale".to_owned()); - - let action = data.update(Message::SetInferenceAddress("127.0.0.1:8061".to_owned())); - - assert!(matches!(action, Action::None)); - assert_eq!(data.inference_address, "127.0.0.1:8061"); - assert!(data.inference_address_error.is_none()); - } - - #[test] - fn confirm_without_model_sets_model_error() -> Result<()> { - let mut data = make_data(); - data.cluster_address = ephemeral_local_addr()?; - data.inference_address = ephemeral_local_addr()?; - - let action = data.update(Message::Confirm); - - assert!(matches!(action, Action::None)); - assert!(data.model_error.is_some()); - - Ok(()) - } - - #[test] - fn confirm_with_empty_cluster_address_sets_cluster_address_error() -> Result<()> { - let mut data = make_data(); - data.selected_model = Some(first_preset()?); - data.inference_address = ephemeral_local_addr()?; - - let action = data.update(Message::Confirm); - - assert!(matches!(action, Action::None)); - assert_eq!( - data.cluster_address_error.as_deref(), - Some("Cluster address is required.") - ); - - Ok(()) - } - - #[test] - fn confirm_with_invalid_cluster_address_sets_format_error() -> Result<()> { - let mut data = make_data(); - data.selected_model = Some(first_preset()?); - data.cluster_address = "not-an-address".to_owned(); - data.inference_address = ephemeral_local_addr()?; - - let action = data.update(Message::Confirm); - - assert!(matches!(action, Action::None)); - assert_eq!( - data.cluster_address_error.as_deref(), - Some("Invalid address, expected format: IP:port") - ); - - Ok(()) - } - - #[test] - fn confirm_with_port_already_in_use_sets_error_mentioning_port() -> Result<()> { - let busy_listener = TcpListener::bind("127.0.0.1:0")?; - let busy_port = busy_listener.local_addr()?.port(); - let mut data = make_data(); - data.selected_model = Some(first_preset()?); - data.cluster_address = format!("127.0.0.1:{busy_port}"); - data.inference_address = ephemeral_local_addr()?; - - let action = data.update(Message::Confirm); - - assert!(matches!(action, Action::None)); - assert!( - data.cluster_address_error - .as_deref() - .is_some_and(|message| message.contains(&busy_port.to_string())) - ); - - drop(busy_listener); - - Ok(()) - } - - #[test] - fn confirm_with_valid_inputs_returns_start_cluster_action_and_marks_starting() -> Result<()> { - let mut data = make_data(); - data.selected_model = Some(first_preset()?); - data.cluster_address = ephemeral_local_addr()?; - data.inference_address = ephemeral_local_addr()?; - - let action = data.update(Message::Confirm); - - assert!(matches!(action, Action::StartCluster { .. })); - assert!(data.starting); - - Ok(()) - } - - #[test] - fn cancel_returns_cancel_action() { - let mut data = make_data(); - - let action = data.update(Message::Cancel); - - assert!(matches!(action, Action::Cancel)); - } -} From 141efaf01c72b7f5fe4e4a775731e2061a3f8601 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 16 Apr 2026 23:31:18 +0200 Subject: [PATCH 45/46] Revert "add headless wayland launch test for desktop gui" This reverts commit aebfd22fe4ed15e865147fd9cc48684f943c407b. --- Cargo.lock | 2 - Makefile | 4 - paddler_second_brain_gui/Cargo.toml | 5 - paddler_second_brain_gui/shell.nix | 31 ---- .../tests/application_startup.rs | 152 ------------------ 5 files changed, 194 deletions(-) delete mode 100644 paddler_second_brain_gui/shell.nix delete mode 100644 paddler_second_brain_gui/tests/application_startup.rs diff --git a/Cargo.lock b/Cargo.lock index 65cec1ab..c89b0817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4590,12 +4590,10 @@ dependencies = [ "iced", "if-addrs", "log", - "nix", "paddler", "paddler_bootstrap", "paddler_types", "statum", - "tempfile", "tokio", ] diff --git a/Makefile b/Makefile index eeba5cc7..b1da541e 100644 --- a/Makefile +++ b/Makefile @@ -74,10 +74,6 @@ test.unit: jarmuz-static test.integration: target/debug/paddler_cli timeout 300 cargo test -p paddler_integration_tests --features tests_that_use_compiled_paddler,tests_that_use_llms -- --nocapture --test-threads=1 -.PHONY: test.gui -test.gui: - nix-shell paddler_second_brain_gui/shell.nix --run "timeout 120 cargo test -p paddler_second_brain_gui --features tests_that_use_headless_wayland -- --nocapture --test-threads=1" - .PHONY: watch watch: node_modules ./jarmuz-watch.mjs diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_second_brain_gui/Cargo.toml index 1400620d..a4a69fa3 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_second_brain_gui/Cargo.toml @@ -19,14 +19,9 @@ paddler_types = { workspace = true } statum = { workspace = true } tokio = { workspace = true } -[dev-dependencies] -nix = { workspace = true } -tempfile = { workspace = true } - [lints] workspace = true [features] default = [] -tests_that_use_headless_wayland = [] web_admin_panel = ["paddler/web_admin_panel"] diff --git a/paddler_second_brain_gui/shell.nix b/paddler_second_brain_gui/shell.nix deleted file mode 100644 index 10870714..00000000 --- a/paddler_second_brain_gui/shell.nix +++ /dev/null @@ -1,31 +0,0 @@ -{ pkgs ? import { } }: - -let - runtimeLibraries = with pkgs; [ - fontconfig - freetype - libGL - libxkbcommon - vulkan-loader - wayland - ]; -in -pkgs.mkShell { - name = "paddler-second-brain-gui"; - - nativeBuildInputs = with pkgs; [ - pkg-config - weston - ]; - - buildInputs = runtimeLibraries ++ (with pkgs; [ - wayland-protocols - ]); - - shellHook = '' - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibraries}:''${LD_LIBRARY_PATH:-}" - export XDG_RUNTIME_DIR="''${TMPDIR:-/tmp}/paddler-gui-runtime-$$" - mkdir -p "$XDG_RUNTIME_DIR" - chmod 700 "$XDG_RUNTIME_DIR" - ''; -} diff --git a/paddler_second_brain_gui/tests/application_startup.rs b/paddler_second_brain_gui/tests/application_startup.rs deleted file mode 100644 index 699b9ed9..00000000 --- a/paddler_second_brain_gui/tests/application_startup.rs +++ /dev/null @@ -1,152 +0,0 @@ -#![cfg(feature = "tests_that_use_headless_wayland")] - -use std::os::unix::process::ExitStatusExt as _; -use std::path::Path; -use std::process::Child; -use std::process::Command; -use std::process::ExitStatus; -use std::process::Stdio; -use std::thread; -use std::time::Duration; -use std::time::Instant; - -use anyhow::Context as _; -use anyhow::Result; -use anyhow::anyhow; -use nix::sys::signal::Signal; -use nix::sys::signal::kill; -use nix::unistd::Pid; -use tempfile::TempDir; - -const COMPOSITOR_READY_DEADLINE: Duration = Duration::from_secs(10); -const GUI_EVENT_LOOP_DEADLINE: Duration = Duration::from_secs(10); -const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5); -const POLL_INTERVAL: Duration = Duration::from_millis(50); - -fn spawn_weston(runtime_dir: &Path, socket_name: &str) -> Result { - Command::new("weston") - .arg("--backend=headless") - .arg(format!("--socket={socket_name}")) - .env("XDG_RUNTIME_DIR", runtime_dir) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .context("failed to spawn weston; ensure it is on PATH (nix-shell paddler_second_brain_gui/shell.nix)") -} - -fn wait_for_socket(socket_path: &Path, weston: &mut Child) -> Result<()> { - let deadline = Instant::now() + COMPOSITOR_READY_DEADLINE; - - while Instant::now() < deadline { - if socket_path.exists() { - return Ok(()); - } - if let Some(status) = weston.try_wait()? { - return Err(anyhow!( - "weston exited before creating socket {}: {status}", - socket_path.display() - )); - } - - thread::sleep(POLL_INTERVAL); - } - - Err(anyhow!( - "weston did not create socket {} within {:?}", - socket_path.display(), - COMPOSITOR_READY_DEADLINE - )) -} - -fn spawn_gui(runtime_dir: &Path, socket_name: &str) -> Result { - Command::new(env!("CARGO_BIN_EXE_paddler_second_brain_gui")) - .env("XDG_RUNTIME_DIR", runtime_dir) - .env("WAYLAND_DISPLAY", socket_name) - .env_remove("DISPLAY") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .context("failed to spawn paddler_second_brain_gui binary") -} - -fn ensure_gui_reached_event_loop(gui: &mut Child) -> Result<()> { - let deadline = Instant::now() + GUI_EVENT_LOOP_DEADLINE; - - while Instant::now() < deadline { - if let Some(status) = gui.try_wait()? { - return Err(anyhow!( - "paddler_second_brain_gui exited before reaching event loop: {status}" - )); - } - - thread::sleep(POLL_INTERVAL); - } - - Ok(()) -} - -fn terminate_and_wait(child: &mut Child) -> Result { - let raw_pid = child - .id() - .try_into() - .context("child pid does not fit in i32")?; - let pid = Pid::from_raw(raw_pid); - kill(pid, Signal::SIGTERM).context("failed to send SIGTERM")?; - - let deadline = Instant::now() + SHUTDOWN_DEADLINE; - - while Instant::now() < deadline { - if let Some(status) = child.try_wait()? { - return Ok(status); - } - - thread::sleep(POLL_INTERVAL); - } - child - .kill() - .context("failed to SIGKILL child after SIGTERM timeout")?; - - child - .wait() - .context("failed to wait for child after SIGKILL") -} - -fn assert_terminated_cleanly(status: ExitStatus, process_label: &str) -> Result<()> { - if status.signal() == Some(Signal::SIGTERM as i32) { - return Ok(()); - } - if status.success() { - return Ok(()); - } - - Err(anyhow!( - "{process_label} did not terminate cleanly: {status:?}" - )) -} - -#[test] -fn application_reaches_event_loop() -> Result<()> { - let runtime_dir = TempDir::new().context("failed to create runtime dir for wayland socket")?; - let socket_name = format!("wayland-paddler-{}", std::process::id()); - let socket_path = runtime_dir.path().join(&socket_name); - - let mut weston = spawn_weston(runtime_dir.path(), &socket_name)?; - let wait_result = wait_for_socket(&socket_path, &mut weston); - if let Err(err) = wait_result { - let _ = terminate_and_wait(&mut weston); - - return Err(err); - } - - let mut gui = spawn_gui(runtime_dir.path(), &socket_name)?; - let event_loop_result = ensure_gui_reached_event_loop(&mut gui); - - let gui_status = terminate_and_wait(&mut gui)?; - let weston_status = terminate_and_wait(&mut weston)?; - - event_loop_result?; - assert_terminated_cleanly(gui_status, "paddler_second_brain_gui")?; - assert_terminated_cleanly(weston_status, "weston")?; - - Ok(()) -} From cea6adc4afc22540c4e456284f29ef4aeaf174a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 18 Apr 2026 00:33:19 +0200 Subject: [PATCH 46/46] rename second brain gui to paddler gui; update makefile to account for cli split --- Cargo.lock | 34 +++++++++---------- Cargo.toml | 2 +- Makefile | 24 ++++++------- package-lock.json | 13 +++++++ paddler_cli/Cargo.toml | 1 + .../Cargo.toml | 4 +-- .../src/agent_running_data.rs | 0 .../src/agent_running_handler.rs | 0 .../second_brain.rs => paddler_gui/src/app.rs | 10 +++--- .../src/current_screen.rs | 0 .../src/detect_network_interfaces.rs | 0 .../src/home_data.rs | 0 .../src/home_handler.rs | 0 .../src/join_cluster_config_data.rs | 0 .../src/join_cluster_config_handler.rs | 0 .../src/main.rs | 8 ++--- .../src/message.rs | 0 .../src/model_preset.rs | 0 .../src/network_interface_address.rs | 0 .../src/running_cluster_data.rs | 0 .../src/running_cluster_handler.rs | 0 .../src/screen.rs | 0 .../src/start_cluster_config_data.rs | 0 .../src/start_cluster_config_handler.rs | 0 .../src/ui/font.rs | 0 .../src/ui/mod.rs | 0 .../src/ui/style_agent_container.rs | 0 .../src/ui/style_button_disconnect.rs | 0 .../src/ui/style_button_primary.rs | 0 .../src/ui/style_card_container.rs | 0 .../src/ui/style_download_progress_bar.rs | 0 .../src/ui/style_field_container.rs | 0 .../src/ui/style_field_pick_list.rs | 0 .../src/ui/style_field_pick_list_menu.rs | 0 .../src/ui/style_field_text_input.rs | 0 .../src/ui/style_status_indicator.rs | 0 .../src/ui/variables.rs | 0 .../src/ui/view_agent_card.rs | 0 .../src/ui/view_agent_running.rs | 0 .../src/ui/view_form_field.rs | 0 .../src/ui/view_home.rs | 3 +- .../src/ui/view_join_cluster_config.rs | 0 .../src/ui/view_running_cluster.rs | 0 .../src/ui/view_start_cluster_config.rs | 0 44 files changed, 56 insertions(+), 43 deletions(-) rename {paddler_second_brain_gui => paddler_gui}/Cargo.toml (86%) rename {paddler_second_brain_gui => paddler_gui}/src/agent_running_data.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/agent_running_handler.rs (100%) rename paddler_second_brain_gui/src/second_brain.rs => paddler_gui/src/app.rs (99%) rename {paddler_second_brain_gui => paddler_gui}/src/current_screen.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/detect_network_interfaces.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/home_data.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/home_handler.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/join_cluster_config_data.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/join_cluster_config_handler.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/main.rs (83%) rename {paddler_second_brain_gui => paddler_gui}/src/message.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/model_preset.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/network_interface_address.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/running_cluster_data.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/running_cluster_handler.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/screen.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/start_cluster_config_data.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/start_cluster_config_handler.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/font.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/mod.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_agent_container.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_button_disconnect.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_button_primary.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_card_container.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_download_progress_bar.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_field_container.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_field_pick_list.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_field_pick_list_menu.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_field_text_input.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/style_status_indicator.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/variables.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/view_agent_card.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/view_agent_running.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/view_form_field.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/view_home.rs (94%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/view_join_cluster_config.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/view_running_cluster.rs (100%) rename {paddler_second_brain_gui => paddler_gui}/src/ui/view_start_cluster_config.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index c89b0817..0ab2eaef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4546,6 +4546,23 @@ dependencies = [ "url", ] +[[package]] +name = "paddler_gui" +version = "3.0.1" +dependencies = [ + "actix-web", + "anyhow", + "env_logger", + "iced", + "if-addrs", + "log", + "paddler", + "paddler_bootstrap", + "paddler_types", + "statum", + "tokio", +] + [[package]] name = "paddler_integration_tests" version = "3.0.1" @@ -4580,23 +4597,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "paddler_second_brain_gui" -version = "3.0.1" -dependencies = [ - "actix-web", - "anyhow", - "env_logger", - "iced", - "if-addrs", - "log", - "paddler", - "paddler_bootstrap", - "paddler_types", - "statum", - "tokio", -] - [[package]] name = "paddler_types" version = "3.0.1" diff --git a/Cargo.toml b/Cargo.toml index 7114d7db..5ca15625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["paddler_integration_tests", "paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_model_tests", "paddler_second_brain_gui", "paddler_types"] +members = ["paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_gui", "paddler_integration_tests", "paddler_model_tests", "paddler_types"] resolver = "2" [workspace.package] diff --git a/Makefile b/Makefile index 97a705e9..a0658319 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,8 @@ node_modules: package-lock.json npm install --from-lockfile touch node_modules -target/debug/paddler: $(shell find paddler/src paddler_types/src paddler_client/src -name '*.rs') - cargo build -p paddler +target/debug/paddler_cli: $(shell find paddler/src paddler_bootstrap/src paddler_cli/src paddler_client/src paddler_types/src -name '*.rs') + cargo build -p paddler_cli # ----------------------------------------------------------------------------- # Phony targets @@ -41,42 +41,42 @@ jarmuz-static: node_modules .PHONY: build build: jarmuz-static - cargo build -p paddler --features web_admin_panel + cargo build -p paddler_cli --features web_admin_panel .PHONY: build.cuda build.cuda: jarmuz-static - cargo build -p paddler --features cuda,web_admin_panel + cargo build -p paddler_cli --features cuda,web_admin_panel .PHONY: release release: jarmuz-static - cargo build --release -p paddler --features web_admin_panel + cargo build --release -p paddler_cli --features web_admin_panel .PHONY: release.cuda release.cuda: jarmuz-static - cargo build --release -p paddler --features web_admin_panel,cuda + cargo build --release -p paddler_cli --features web_admin_panel,cuda .PHONY: release.vulkan release.vulkan: jarmuz-static - cargo build --release -p paddler --features web_admin_panel,vulkan + cargo build --release -p paddler_cli --features web_admin_panel,vulkan .PHONY: test test: test.unit test.models test.integration .PHONY: test.models test.models: - timeout 300 cargo test -p paddler_model_tests --features tests_that_use_llms -- --nocapture --test-threads=1 + cargo test -p paddler_model_tests --features tests_that_use_llms -- --nocapture --test-threads=1 .PHONY: test.cuda test.cuda: - timeout 1800 cargo test -p paddler_model_tests --features tests_that_use_llms,cuda -- --nocapture --test-threads=1 + cargo test -p paddler_model_tests --features tests_that_use_llms,cuda -- --nocapture --test-threads=1 .PHONY: test.unit test.unit: jarmuz-static - timeout 300 cargo test --features web_admin_panel + cargo test --features web_admin_panel .PHONY: test.integration -test.integration: target/debug/paddler - timeout 300 cargo test -p paddler_integration_tests --features tests_that_use_compiled_paddler,tests_that_use_llms -- --nocapture --test-threads=1 +test.integration: target/debug/paddler_cli + cargo test -p paddler_integration_tests --features tests_that_use_compiled_paddler,tests_that_use_llms -- --nocapture --test-threads=1 .PHONY: watch watch: node_modules diff --git a/package-lock.json b/package-lock.json index cf1988f2..447776ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -222,6 +222,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -245,6 +246,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -268,6 +270,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1296,6 +1299,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1362,6 +1366,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -1763,6 +1768,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2868,6 +2874,7 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5426,6 +5433,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5538,6 +5546,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -5588,6 +5597,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5677,6 +5687,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5686,6 +5697,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7180,6 +7192,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/paddler_cli/Cargo.toml b/paddler_cli/Cargo.toml index ed349379..0002faff 100644 --- a/paddler_cli/Cargo.toml +++ b/paddler_cli/Cargo.toml @@ -28,6 +28,7 @@ workspace = true [features] default = [] cuda = ["paddler/cuda"] +vulkan = ["paddler/vulkan"] web_admin_panel = [ "dep:esbuild-metafile", "paddler/web_admin_panel", diff --git a/paddler_second_brain_gui/Cargo.toml b/paddler_gui/Cargo.toml similarity index 86% rename from paddler_second_brain_gui/Cargo.toml rename to paddler_gui/Cargo.toml index a4a69fa3..65834965 100644 --- a/paddler_second_brain_gui/Cargo.toml +++ b/paddler_gui/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "paddler_second_brain_gui" +name = "paddler_gui" version.workspace = true edition.workspace = true authors.workspace = true -description = "Desktop GUI for Paddler Second Brain" +description = "Paddler desktop application" license.workspace = true [dependencies] diff --git a/paddler_second_brain_gui/src/agent_running_data.rs b/paddler_gui/src/agent_running_data.rs similarity index 100% rename from paddler_second_brain_gui/src/agent_running_data.rs rename to paddler_gui/src/agent_running_data.rs diff --git a/paddler_second_brain_gui/src/agent_running_handler.rs b/paddler_gui/src/agent_running_handler.rs similarity index 100% rename from paddler_second_brain_gui/src/agent_running_handler.rs rename to paddler_gui/src/agent_running_handler.rs diff --git a/paddler_second_brain_gui/src/second_brain.rs b/paddler_gui/src/app.rs similarity index 99% rename from paddler_second_brain_gui/src/second_brain.rs rename to paddler_gui/src/app.rs index 8611c93d..bbba254f 100644 --- a/paddler_second_brain_gui/src/second_brain.rs +++ b/paddler_gui/src/app.rs @@ -69,28 +69,28 @@ fn collect_sorted_agent_snapshots( Ok(agents) } -pub struct SecondBrain { +pub struct App { agent_shutdown_tx: Option>, screen: CurrentScreen, shutdown_tx: Option>, } -impl Drop for SecondBrain { +impl Drop for App { fn drop(&mut self) { send_shutdown(&mut self.shutdown_tx, "cluster"); send_shutdown(&mut self.agent_shutdown_tx, "agent"); } } -impl SecondBrain { +impl App { pub fn new() -> (Self, Task) { - let second_brain = Self { + let app = Self { agent_shutdown_tx: None, screen: CurrentScreen::default(), shutdown_tx: None, }; - (second_brain, Task::none()) + (app, Task::none()) } pub fn update(&mut self, message: Message) -> Task { diff --git a/paddler_second_brain_gui/src/current_screen.rs b/paddler_gui/src/current_screen.rs similarity index 100% rename from paddler_second_brain_gui/src/current_screen.rs rename to paddler_gui/src/current_screen.rs diff --git a/paddler_second_brain_gui/src/detect_network_interfaces.rs b/paddler_gui/src/detect_network_interfaces.rs similarity index 100% rename from paddler_second_brain_gui/src/detect_network_interfaces.rs rename to paddler_gui/src/detect_network_interfaces.rs diff --git a/paddler_second_brain_gui/src/home_data.rs b/paddler_gui/src/home_data.rs similarity index 100% rename from paddler_second_brain_gui/src/home_data.rs rename to paddler_gui/src/home_data.rs diff --git a/paddler_second_brain_gui/src/home_handler.rs b/paddler_gui/src/home_handler.rs similarity index 100% rename from paddler_second_brain_gui/src/home_handler.rs rename to paddler_gui/src/home_handler.rs diff --git a/paddler_second_brain_gui/src/join_cluster_config_data.rs b/paddler_gui/src/join_cluster_config_data.rs similarity index 100% rename from paddler_second_brain_gui/src/join_cluster_config_data.rs rename to paddler_gui/src/join_cluster_config_data.rs diff --git a/paddler_second_brain_gui/src/join_cluster_config_handler.rs b/paddler_gui/src/join_cluster_config_handler.rs similarity index 100% rename from paddler_second_brain_gui/src/join_cluster_config_handler.rs rename to paddler_gui/src/join_cluster_config_handler.rs diff --git a/paddler_second_brain_gui/src/main.rs b/paddler_gui/src/main.rs similarity index 83% rename from paddler_second_brain_gui/src/main.rs rename to paddler_gui/src/main.rs index 290adeb6..104e8bb0 100644 --- a/paddler_second_brain_gui/src/main.rs +++ b/paddler_gui/src/main.rs @@ -1,5 +1,6 @@ mod agent_running_data; mod agent_running_handler; +mod app; mod current_screen; mod detect_network_interfaces; mod home_data; @@ -13,19 +14,18 @@ mod running_cluster_data; mod running_cluster_handler; #[expect(unsafe_code, reason = "statum macros generate link_section statics")] mod screen; -mod second_brain; mod start_cluster_config_data; mod start_cluster_config_handler; mod ui; +use app::App; use iced::Size; use iced::Theme; -use second_brain::SecondBrain; fn main() -> iced::Result { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - iced::application(SecondBrain::new, SecondBrain::update, SecondBrain::view) + iced::application(App::new, App::update, App::view) .font(include_bytes!( "../../resources/fonts/JetBrainsMono-Regular.ttf" )) @@ -34,6 +34,6 @@ fn main() -> iced::Result { )) .theme(Theme::Light) .window_size(Size::new(800.0, 800.0)) - .subscription(SecondBrain::subscription) + .subscription(App::subscription) .run() } diff --git a/paddler_second_brain_gui/src/message.rs b/paddler_gui/src/message.rs similarity index 100% rename from paddler_second_brain_gui/src/message.rs rename to paddler_gui/src/message.rs diff --git a/paddler_second_brain_gui/src/model_preset.rs b/paddler_gui/src/model_preset.rs similarity index 100% rename from paddler_second_brain_gui/src/model_preset.rs rename to paddler_gui/src/model_preset.rs diff --git a/paddler_second_brain_gui/src/network_interface_address.rs b/paddler_gui/src/network_interface_address.rs similarity index 100% rename from paddler_second_brain_gui/src/network_interface_address.rs rename to paddler_gui/src/network_interface_address.rs diff --git a/paddler_second_brain_gui/src/running_cluster_data.rs b/paddler_gui/src/running_cluster_data.rs similarity index 100% rename from paddler_second_brain_gui/src/running_cluster_data.rs rename to paddler_gui/src/running_cluster_data.rs diff --git a/paddler_second_brain_gui/src/running_cluster_handler.rs b/paddler_gui/src/running_cluster_handler.rs similarity index 100% rename from paddler_second_brain_gui/src/running_cluster_handler.rs rename to paddler_gui/src/running_cluster_handler.rs diff --git a/paddler_second_brain_gui/src/screen.rs b/paddler_gui/src/screen.rs similarity index 100% rename from paddler_second_brain_gui/src/screen.rs rename to paddler_gui/src/screen.rs diff --git a/paddler_second_brain_gui/src/start_cluster_config_data.rs b/paddler_gui/src/start_cluster_config_data.rs similarity index 100% rename from paddler_second_brain_gui/src/start_cluster_config_data.rs rename to paddler_gui/src/start_cluster_config_data.rs diff --git a/paddler_second_brain_gui/src/start_cluster_config_handler.rs b/paddler_gui/src/start_cluster_config_handler.rs similarity index 100% rename from paddler_second_brain_gui/src/start_cluster_config_handler.rs rename to paddler_gui/src/start_cluster_config_handler.rs diff --git a/paddler_second_brain_gui/src/ui/font.rs b/paddler_gui/src/ui/font.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/font.rs rename to paddler_gui/src/ui/font.rs diff --git a/paddler_second_brain_gui/src/ui/mod.rs b/paddler_gui/src/ui/mod.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/mod.rs rename to paddler_gui/src/ui/mod.rs diff --git a/paddler_second_brain_gui/src/ui/style_agent_container.rs b/paddler_gui/src/ui/style_agent_container.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_agent_container.rs rename to paddler_gui/src/ui/style_agent_container.rs diff --git a/paddler_second_brain_gui/src/ui/style_button_disconnect.rs b/paddler_gui/src/ui/style_button_disconnect.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_button_disconnect.rs rename to paddler_gui/src/ui/style_button_disconnect.rs diff --git a/paddler_second_brain_gui/src/ui/style_button_primary.rs b/paddler_gui/src/ui/style_button_primary.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_button_primary.rs rename to paddler_gui/src/ui/style_button_primary.rs diff --git a/paddler_second_brain_gui/src/ui/style_card_container.rs b/paddler_gui/src/ui/style_card_container.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_card_container.rs rename to paddler_gui/src/ui/style_card_container.rs diff --git a/paddler_second_brain_gui/src/ui/style_download_progress_bar.rs b/paddler_gui/src/ui/style_download_progress_bar.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_download_progress_bar.rs rename to paddler_gui/src/ui/style_download_progress_bar.rs diff --git a/paddler_second_brain_gui/src/ui/style_field_container.rs b/paddler_gui/src/ui/style_field_container.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_field_container.rs rename to paddler_gui/src/ui/style_field_container.rs diff --git a/paddler_second_brain_gui/src/ui/style_field_pick_list.rs b/paddler_gui/src/ui/style_field_pick_list.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_field_pick_list.rs rename to paddler_gui/src/ui/style_field_pick_list.rs diff --git a/paddler_second_brain_gui/src/ui/style_field_pick_list_menu.rs b/paddler_gui/src/ui/style_field_pick_list_menu.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_field_pick_list_menu.rs rename to paddler_gui/src/ui/style_field_pick_list_menu.rs diff --git a/paddler_second_brain_gui/src/ui/style_field_text_input.rs b/paddler_gui/src/ui/style_field_text_input.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_field_text_input.rs rename to paddler_gui/src/ui/style_field_text_input.rs diff --git a/paddler_second_brain_gui/src/ui/style_status_indicator.rs b/paddler_gui/src/ui/style_status_indicator.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/style_status_indicator.rs rename to paddler_gui/src/ui/style_status_indicator.rs diff --git a/paddler_second_brain_gui/src/ui/variables.rs b/paddler_gui/src/ui/variables.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/variables.rs rename to paddler_gui/src/ui/variables.rs diff --git a/paddler_second_brain_gui/src/ui/view_agent_card.rs b/paddler_gui/src/ui/view_agent_card.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/view_agent_card.rs rename to paddler_gui/src/ui/view_agent_card.rs diff --git a/paddler_second_brain_gui/src/ui/view_agent_running.rs b/paddler_gui/src/ui/view_agent_running.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/view_agent_running.rs rename to paddler_gui/src/ui/view_agent_running.rs diff --git a/paddler_second_brain_gui/src/ui/view_form_field.rs b/paddler_gui/src/ui/view_form_field.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/view_form_field.rs rename to paddler_gui/src/ui/view_form_field.rs diff --git a/paddler_second_brain_gui/src/ui/view_home.rs b/paddler_gui/src/ui/view_home.rs similarity index 94% rename from paddler_second_brain_gui/src/ui/view_home.rs rename to paddler_gui/src/ui/view_home.rs index d1ddd7e4..a760b6f4 100644 --- a/paddler_second_brain_gui/src/ui/view_home.rs +++ b/paddler_gui/src/ui/view_home.rs @@ -59,8 +59,7 @@ pub fn view_home(data: &HomeData) -> Element<'_, Message> { let options_row = row![start_column, join_column].spacing(SPACING_2X); let mut content = column![ - container(text("Paddler second brain").size(FONT_SIZE_L2).font(BOLD)) - .padding([0.0, SPACING_BASE]), + container(text("Paddler App").size(FONT_SIZE_L2).font(BOLD)).padding([0.0, SPACING_BASE]), container(options_row).align_x(Center), ] .spacing(SPACING_2X); diff --git a/paddler_second_brain_gui/src/ui/view_join_cluster_config.rs b/paddler_gui/src/ui/view_join_cluster_config.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/view_join_cluster_config.rs rename to paddler_gui/src/ui/view_join_cluster_config.rs diff --git a/paddler_second_brain_gui/src/ui/view_running_cluster.rs b/paddler_gui/src/ui/view_running_cluster.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/view_running_cluster.rs rename to paddler_gui/src/ui/view_running_cluster.rs diff --git a/paddler_second_brain_gui/src/ui/view_start_cluster_config.rs b/paddler_gui/src/ui/view_start_cluster_config.rs similarity index 100% rename from paddler_second_brain_gui/src/ui/view_start_cluster_config.rs rename to paddler_gui/src/ui/view_start_cluster_config.rs