diff --git a/.claude/rules/code-coverage.md b/.claude/rules/code-coverage.md new file mode 100644 index 00000000..26739d6a --- /dev/null +++ b/.claude/rules/code-coverage.md @@ -0,0 +1,3 @@ +# Code Coverage Measurement + +- Use `make coverage` to measure code coverage. This is the authoritative source. diff --git a/.claude/rules/github-actions.md b/.claude/rules/github-actions.md new file mode 100644 index 00000000..1457661c --- /dev/null +++ b/.claude/rules/github-actions.md @@ -0,0 +1,9 @@ +--- +paths: + - ".github/actions/**/*" +--- + +# GitHub Actions Standards + +- Actions must represent a single purpose and a single concern. +- Compose actions if they need to provide a more complex functionality. diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md index 68d68c71..7ed01ae4 100644 --- a/.claude/rules/github-workflows.md +++ b/.claude/rules/github-workflows.md @@ -1,11 +1,14 @@ --- paths: - - ".github/**/*" + - ".github/workflows/**/*" --- # GitHub Workflows Standards -- Always use Makefile targets in the workflow to avoid code duplication. +- Always use Makefile targets in the workflow to avoid code duplication (if they need to run something that is already present in a Makefile). - Never add the tests that use LLMs to GitHub workflows, because the default GitHub worker does not have the capacity to run them. - Only add unit tests to GitHub workflows. - Keep GitHub workflows responsible for only a single concern. For example, run linter, and tests in parallel. +- Treat GitHub workflows as a coding project. Use composable actions, factor similar concerns into actions. +- Encapsulate functionalities in composable actions. +- Keep the workflows clean and purposeful. diff --git a/Cargo.lock b/Cargo.lock index 699a3cb2..e8fd7cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3004,6 +3004,32 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "iced_selector" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1442ccc30959cc48b3a624233521ef7f3bc3e06528b6fe306c3a775fbddc4fe" +dependencies = [ + "iced_core", +] + +[[package]] +name = "iced_test" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b56c26cc6e63958ce6be06a68fa3136fd5f646da6302ee12abf1a0f311dd0166" +dependencies = [ + "iced_futures", + "iced_program", + "iced_renderer", + "iced_runtime", + "iced_selector", + "nom 8.0.0", + "png 0.18.1", + "sha2", + "thiserror 2.0.18", +] + [[package]] name = "iced_tiny_skia" version = "0.14.0" @@ -5001,12 +5027,39 @@ dependencies = [ "open", "paddler", "paddler_bootstrap", + "paddler_ports", "paddler_types", "statum", "tokio", "tokio-util", ] +[[package]] +name = "paddler_gui_tests" +version = "4.0.0" +dependencies = [ + "anyhow", + "iced", + "iced_test", + "log", + "nix 0.30.1", + "paddler", + "paddler_bootstrap", + "paddler_gui", + "paddler_types", + "serial_test", + "tokio", + "tokio-util", +] + +[[package]] +name = "paddler_ports" +version = "4.0.0" +dependencies = [ + "anyhow", + "thiserror 2.0.18", +] + [[package]] name = "paddler_tests" version = "4.0.0" @@ -5022,6 +5075,7 @@ dependencies = [ "paddler", "paddler_bootstrap", "paddler_client", + "paddler_ports", "paddler_types", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 235e702d..b0bac3ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_client_cli", "paddler_gui", "paddler_tests", "paddler_types"] +members = ["paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_client_cli", "paddler_gui", "paddler_gui_tests", "paddler_ports", "paddler_tests", "paddler_types"] resolver = "2" [workspace.package] @@ -58,6 +58,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" iced = { version = "0.14", features = ["image", "svg", "tokio"] } +iced_test = "=0.14.0" if-addrs = "0.13" statum = "0.6" tempfile = "3.20.0" @@ -71,6 +72,8 @@ url = { version = "2.5", features = ["serde"] } paddler = { version = "4.0.0", path = "paddler" } paddler_bootstrap = { version = "4.0.0", path = "paddler_bootstrap" } paddler_client = { version = "4.0.0", path = "paddler_client" } +paddler_gui = { version = "4.0.0", path = "paddler_gui" } +paddler_ports = { version = "4.0.0", path = "paddler_ports" } paddler_tests = { version = "4.0.0", path = "paddler_tests" } paddler_types = { version = "4.0.0", path = "paddler_types" } diff --git a/Makefile b/Makefile index 1f90ea08..9031d115 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ .DEFAULT_GOAL := target/release/paddler +COVERAGE_PACKAGES := -p paddler_gui -p paddler_gui_tests RUST_LOG ?= debug PADDLER_CLI_SOURCES := $(shell find paddler/src paddler_bootstrap/src paddler_cli/src paddler_client/src paddler_types/src -name '*.rs') @@ -20,30 +21,45 @@ node_modules: package-lock.json esbuild-meta.json: $(FRONTEND_SOURCES) jarmuz-static.mjs tsconfig.json package.json node_modules ./jarmuz-static.mjs -target/debug/paddler: $(PADDLER_CLI_SOURCES) - cargo build -p paddler_cli - -target/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json - cargo build --release -p paddler_cli --features web_admin_panel - target/cuda/debug/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build -p paddler_cli --features cuda,web_admin_panel --target-dir target/cuda +target/cuda/debug/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build -p paddler_gui --features cuda,web_admin_panel --target-dir target/cuda + target/cuda/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build --release -p paddler_cli --features cuda,web_admin_panel --target-dir target/cuda +target/cuda/release/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_gui --features cuda,web_admin_panel --target-dir target/cuda + +target/debug/paddler: $(PADDLER_CLI_SOURCES) + cargo build -p paddler_cli + +target/debug/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build -p paddler_gui --features web_admin_panel + target/metal/debug/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build -p paddler_cli --features metal,web_admin_panel --target-dir target/metal +target/metal/debug/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build -p paddler_gui --features metal,web_admin_panel --target-dir target/metal + target/metal/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json cargo build --release -p paddler_cli --features metal,web_admin_panel --target-dir target/metal -target/vulkan/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json - cargo build --release -p paddler_cli --features vulkan,web_admin_panel --target-dir target/vulkan +target/metal/release/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_gui --features metal,web_admin_panel --target-dir target/metal + +target/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_cli --features web_admin_panel target/release/paddler_gui: $(PADDLER_GUI_SOURCES) esbuild-meta.json cargo build --release -p paddler_gui --features web_admin_panel +target/vulkan/release/paddler: $(PADDLER_CLI_SOURCES) esbuild-meta.json + cargo build --release -p paddler_cli --features vulkan,web_admin_panel --target-dir target/vulkan + # ----------------------------------------------------------------------------- # Phony targets # ----------------------------------------------------------------------------- @@ -59,6 +75,28 @@ clean: clippy: esbuild-meta.json cargo clippy --workspace --all-targets --features web_admin_panel,tests_that_use_llms,tests_that_use_compiled_paddler +.PHONY: coverage +coverage: esbuild-meta.json + cargo llvm-cov clean --workspace + cargo llvm-cov $(COVERAGE_PACKAGES) --features web_admin_panel --no-report + cargo llvm-cov report --json --output-path target/llvm-cov.json + cargo llvm-cov report --lcov --output-path target/lcov.info + cargo llvm-cov report + npx rust-coverage-check target/llvm-cov.json \ + --workspace-root $(CURDIR) \ + --gated paddler_gui \ + --required-percent 100 + +.PHONY: coverage-clean +coverage-clean: + cargo llvm-cov clean --workspace + rm -rf target/llvm-cov-target + rm -f target/llvm-cov.json target/lcov.info + +.PHONY: coverage-report +coverage-report: esbuild-meta.json + cargo llvm-cov $(COVERAGE_PACKAGES) --features web_admin_panel --html + .PHONY: fmt fmt: node_modules ./jarmuz-fmt.mjs @@ -80,7 +118,7 @@ test.integration.metal: target/metal/debug/paddler .PHONY: test.unit test.unit: esbuild-meta.json - cargo test --features web_admin_panel + cargo test --workspace --features web_admin_panel .PHONY: build.client.js build.client.js: diff --git a/package-lock.json b/package-lock.json index 65a10d2e..30c02660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "zod": "^4.0.17" }, "devDependencies": { + "@intentee/rust-coverage-check": "0.1.0", "@types/hotwired__turbo": "^8", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -984,6 +985,19 @@ "resolved": "paddler_client_javascript", "link": true }, + "node_modules/@intentee/rust-coverage-check": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@intentee/rust-coverage-check/-/rust-coverage-check-0.1.0.tgz", + "integrity": "sha512-0PpHUxGda5FjSOh7ebMrR99xbFRC93PnWY1mSTeuetX2yMgqVhY8Bjrg5GqedsxXZt6z7OrQ3SOwBA2gTgDUBg==", + "dev": true, + "license": "MIT", + "bin": { + "rust-coverage-check": "src/main.mjs" + }, + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index cdf98de8..3c686061 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "devDependencies": { + "@intentee/rust-coverage-check": "0.1.0", "@types/hotwired__turbo": "^8", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/paddler/src/balancer/compatibility/openai_service/mod.rs b/paddler/src/balancer/compatibility/openai_service/mod.rs index 03ea1cf7..a4098df8 100644 --- a/paddler/src/balancer/compatibility/openai_service/mod.rs +++ b/paddler/src/balancer/compatibility/openai_service/mod.rs @@ -2,6 +2,7 @@ pub mod app_data; pub mod configuration; pub mod http_route; +use std::net::TcpListener; use std::sync::Arc; use actix_web::App; @@ -22,6 +23,7 @@ use crate::service::Service; pub struct OpenAIService { pub buffered_request_manager: Arc, pub inference_service_configuration: InferenceServiceConfiguration, + pub listener: Option, pub openai_service_configuration: OpenAIServiceConfiguration, } @@ -43,8 +45,10 @@ impl Service for OpenAIService { inference_service_configuration: self.inference_service_configuration.clone(), }); - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let taken_listener = self.listener.take(); + let configured_addr = self.openai_service_configuration.addr; + + let bound = HttpServer::new(move || { App::new() .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) .app_data(app_data.clone()) @@ -54,11 +58,16 @@ impl Service for OpenAIService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.openai_service_configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler/src/balancer/inference_service/mod.rs b/paddler/src/balancer/inference_service/mod.rs index 844a004a..8a71bf92 100644 --- a/paddler/src/balancer/inference_service/mod.rs +++ b/paddler/src/balancer/inference_service/mod.rs @@ -2,6 +2,7 @@ pub mod app_data; pub mod configuration; pub mod http_route; +use std::net::TcpListener; use std::sync::Arc; use actix_web::App; @@ -27,6 +28,7 @@ pub struct InferenceService { pub balancer_applicable_state_holder: Arc, pub buffered_request_manager: Arc, pub configuration: InferenceServiceConfiguration, + pub listener: Option, #[cfg(feature = "web_admin_panel")] pub web_admin_panel_service_configuration: Option, } @@ -56,8 +58,10 @@ impl Service for InferenceService { shutdown: shutdown.clone(), }); - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let taken_listener = self.listener.take(); + let configured_addr = self.configuration.addr; + + let bound = HttpServer::new(move || { App::new() .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) .app_data(app_data.clone()) @@ -70,11 +74,16 @@ impl Service for InferenceService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler/src/balancer/management_service/mod.rs b/paddler/src/balancer/management_service/mod.rs index 6c407c2d..55acdd98 100644 --- a/paddler/src/balancer/management_service/mod.rs +++ b/paddler/src/balancer/management_service/mod.rs @@ -2,6 +2,7 @@ pub mod app_data; pub mod configuration; pub mod http_route; +use std::net::TcpListener; use std::sync::Arc; use actix_web::App; @@ -35,6 +36,7 @@ pub struct ManagementService { pub configuration: ManagementServiceConfiguration, pub embedding_sender_collection: Arc, pub generate_tokens_sender_collection: Arc, + pub listener: Option, pub model_metadata_sender_collection: Arc, pub state_database: Arc, pub statsd_prefix: String, @@ -74,8 +76,10 @@ impl Service for ManagementService { statsd_prefix: self.statsd_prefix.clone(), }); - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let taken_listener = self.listener.take(); + let configured_addr = self.configuration.addr; + + let bound = HttpServer::new(move || { App::new() .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) .app_data(app_data.clone()) @@ -95,11 +99,16 @@ impl Service for ManagementService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler/src/balancer/web_admin_panel_service/mod.rs b/paddler/src/balancer/web_admin_panel_service/mod.rs index 715464a9..eeb7c490 100644 --- a/paddler/src/balancer/web_admin_panel_service/mod.rs +++ b/paddler/src/balancer/web_admin_panel_service/mod.rs @@ -3,6 +3,8 @@ pub mod configuration; pub mod http_route; pub mod template_data; +use std::net::TcpListener; + use actix_web::App; use actix_web::HttpServer; use actix_web::web::Data; @@ -16,6 +18,7 @@ use crate::service::Service; pub struct WebAdminPanelService { pub configuration: WebAdminPanelServiceConfiguration, + pub listener: Option, } #[async_trait] @@ -29,8 +32,10 @@ impl Service for WebAdminPanelService { template_data: self.configuration.template_data.clone(), }); - #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] - HttpServer::new(move || { + let taken_listener = self.listener.take(); + let configured_addr = self.configuration.addr; + + let bound = HttpServer::new(move || { App::new() .app_data(app_data.clone()) .configure(http_route::favicon::register) @@ -40,11 +45,16 @@ impl Service for WebAdminPanelService { .shutdown_signal(async move { shutdown.cancelled().await; }) - .disable_signals() - .bind(self.configuration.addr) - .expect("Unable to bind server to address") - .run() - .await?; + .disable_signals(); + + #[expect(clippy::expect_used, reason = "server bind failure is unrecoverable")] + let bound = match taken_listener { + Some(listener) => bound.listen(listener), + None => bound.bind(configured_addr), + } + .expect("Unable to bind/listen server on address"); + + bound.run().await?; Ok(()) } diff --git a/paddler_bootstrap/src/balancer_runner.rs b/paddler_bootstrap/src/balancer_runner.rs index 6e126403..18a804de 100644 --- a/paddler_bootstrap/src/balancer_runner.rs +++ b/paddler_bootstrap/src/balancer_runner.rs @@ -1,4 +1,5 @@ use std::future::Future; +use std::net::TcpListener; use std::sync::Arc; use std::time::Duration; @@ -23,15 +24,20 @@ use crate::service_thread::ServiceThread; pub struct BalancerRunnerParams { pub buffered_request_timeout: Duration, + pub inference_listener: Option, pub inference_service_configuration: InferenceServiceConfiguration, + pub management_listener: Option, pub management_service_configuration: ManagementServiceConfiguration, pub max_buffered_requests: i32, + pub openai_listener: Option, pub openai_service_configuration: Option, pub cancellation_token: CancellationToken, pub state_database_type: StateDatabaseType, pub statsd_prefix: String, pub statsd_service_configuration: Option, #[cfg(feature = "web_admin_panel")] + pub web_admin_panel_listener: Option, + #[cfg(feature = "web_admin_panel")] pub web_admin_panel_service_configuration: Option, } @@ -47,15 +53,20 @@ impl BalancerRunner { pub async fn start( BalancerRunnerParams { buffered_request_timeout, + inference_listener, inference_service_configuration, + management_listener, management_service_configuration, max_buffered_requests, + openai_listener, openai_service_configuration, cancellation_token, state_database_type, statsd_prefix, statsd_service_configuration, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }: BalancerRunnerParams, ) -> Result { @@ -67,14 +78,19 @@ impl BalancerRunner { state_database, } = bootstrap_balancer(BalancerBootstrapConfig { buffered_request_timeout, + inference_listener, inference_service_configuration, + management_listener, management_service_configuration, max_buffered_requests, + openai_listener, openai_service_configuration, state_database_type, statsd_prefix, statsd_service_configuration, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }) .await?; diff --git a/paddler_bootstrap/src/bootstrapped_balancer_handle.rs b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs index 1e208648..b000422a 100644 --- a/paddler_bootstrap/src/bootstrapped_balancer_handle.rs +++ b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs @@ -1,3 +1,4 @@ +use std::net::TcpListener; use std::sync::Arc; use std::time::Duration; @@ -31,14 +32,19 @@ use tokio::sync::broadcast; pub struct BalancerBootstrapConfig { pub buffered_request_timeout: Duration, + pub inference_listener: Option, pub inference_service_configuration: InferenceServiceConfiguration, + pub management_listener: Option, pub management_service_configuration: ManagementServiceConfiguration, pub max_buffered_requests: i32, + pub openai_listener: Option, pub openai_service_configuration: Option, pub state_database_type: StateDatabaseType, pub statsd_prefix: String, pub statsd_service_configuration: Option, #[cfg(feature = "web_admin_panel")] + pub web_admin_panel_listener: Option, + #[cfg(feature = "web_admin_panel")] pub web_admin_panel_service_configuration: Option, } @@ -53,14 +59,19 @@ pub struct BootstrappedBalancerHandle { pub async fn bootstrap_balancer( BalancerBootstrapConfig { buffered_request_timeout, + inference_listener, inference_service_configuration, + management_listener, management_service_configuration, max_buffered_requests, + openai_listener, openai_service_configuration, state_database_type, statsd_prefix, statsd_service_configuration, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }: BalancerBootstrapConfig, ) -> anyhow::Result { @@ -94,6 +105,7 @@ pub async fn bootstrap_balancer( balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), buffered_request_manager: buffered_request_manager.clone(), configuration: inference_service_configuration.clone(), + listener: inference_listener, #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: web_admin_panel_service_configuration.clone(), }); @@ -106,6 +118,7 @@ pub async fn bootstrap_balancer( configuration: management_service_configuration, embedding_sender_collection, generate_tokens_sender_collection, + listener: management_listener, model_metadata_sender_collection, state_database: state_database.clone(), statsd_prefix, @@ -125,6 +138,7 @@ pub async fn bootstrap_balancer( service_manager.add_service(OpenAIService { buffered_request_manager: buffered_request_manager.clone(), inference_service_configuration, + listener: openai_listener, openai_service_configuration: openai_configuration, }); } @@ -141,6 +155,7 @@ pub async fn bootstrap_balancer( if let Some(web_admin_panel_configuration) = web_admin_panel_service_configuration { service_manager.add_service(WebAdminPanelService { configuration: web_admin_panel_configuration, + listener: web_admin_panel_listener, }); } diff --git a/paddler_bootstrap/tests/runners.rs b/paddler_bootstrap/tests/runners.rs index 3567cb91..5bb3edbe 100644 --- a/paddler_bootstrap/tests/runners.rs +++ b/paddler_bootstrap/tests/runners.rs @@ -50,22 +50,27 @@ fn make_balancer_runner_params( ) -> BalancerRunnerParams { BalancerRunnerParams { buffered_request_timeout: Duration::from_secs(10), + inference_listener: None, inference_service_configuration: InferenceServiceConfiguration { addr: inference_addr, cors_allowed_hosts: vec![], inference_item_timeout: Duration::from_secs(30), }, + management_listener: None, management_service_configuration: ManagementServiceConfiguration { addr: management_addr, cors_allowed_hosts: vec![], }, max_buffered_requests: 30, + openai_listener: None, openai_service_configuration: None, cancellation_token, state_database_type: StateDatabaseType::Memory(Box::default()), statsd_prefix: "paddler_bootstrap_test_".to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: None, } } diff --git a/paddler_cli/src/cmd/balancer.rs b/paddler_cli/src/cmd/balancer.rs index c2e25aab..1d51361d 100644 --- a/paddler_cli/src/cmd/balancer.rs +++ b/paddler_cli/src/cmd/balancer.rs @@ -113,16 +113,19 @@ impl Handler for Balancer { async fn handle(&self, shutdown: CancellationToken) -> Result<()> { let mut runner = BalancerRunner::start(BalancerRunnerParams { buffered_request_timeout: self.buffered_request_timeout, + inference_listener: None, inference_service_configuration: InferenceServiceConfiguration { addr: self.inference_addr.socket_addr, cors_allowed_hosts: self.inference_cors_allowed_hosts.clone(), inference_item_timeout: self.inference_item_timeout, }, + management_listener: None, management_service_configuration: ManagementServiceConfiguration { addr: self.management_addr.socket_addr, cors_allowed_hosts: self.management_cors_allowed_hosts.clone(), }, max_buffered_requests: self.max_buffered_requests, + openai_listener: None, openai_service_configuration: self.compat_openai_addr.clone().map( |compat_openai_addr| OpenAIServiceConfiguration { addr: compat_openai_addr.socket_addr, @@ -139,6 +142,8 @@ impl Handler for Balancer { } }), #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: self.get_web_admin_panel_service_configuration(), }) .await?; diff --git a/paddler_gui/Cargo.toml b/paddler_gui/Cargo.toml index b59d5d90..112eac34 100644 --- a/paddler_gui/Cargo.toml +++ b/paddler_gui/Cargo.toml @@ -18,13 +18,23 @@ log = { workspace = true } open = { workspace = true } paddler = { workspace = true } paddler_bootstrap = { workspace = true } +paddler_ports = { workspace = true } paddler_types = { workspace = true } statum = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +[lib] +path = "src/lib.rs" + +[[bin]] +name = "paddler_gui" +path = "src/main.rs" + [dev-dependencies] +anyhow = { workspace = true } dashmap = { workspace = true } +tokio = { workspace = true } [lints] workspace = true @@ -32,6 +42,7 @@ workspace = true [features] default = [] cuda = ["paddler/cuda"] +metal = ["paddler/metal"] web_admin_panel = [ "dep:esbuild-metafile", "paddler/web_admin_panel", diff --git a/paddler_gui/src/address_field.rs b/paddler_gui/src/address_field.rs new file mode 100644 index 00000000..78ea3b16 --- /dev/null +++ b/paddler_gui/src/address_field.rs @@ -0,0 +1,154 @@ +use paddler_ports::bound_port::BoundPort; +use paddler_ports::check_optional_port::check_optional_port; +use paddler_ports::check_port::check_port; + +#[derive(Default)] +pub enum AddressField { + #[default] + Empty, + Bound { + raw: String, + port: BoundPort, + }, + Invalid { + raw: String, + error: String, + }, +} + +impl AddressField { + #[must_use] + pub fn required_from_user_input(raw: String) -> Self { + match check_port(&raw) { + Ok(port) => Self::Bound { raw, port }, + Err(error) => { + if raw.is_empty() { + Self::Empty + } else { + Self::Invalid { + raw, + error: error.user_facing_message(), + } + } + } + } + } + + #[must_use] + pub fn optional_from_user_input(raw: String) -> Self { + match check_optional_port(&raw) { + Ok(Some(port)) => Self::Bound { raw, port }, + Ok(None) => Self::Empty, + Err(error) => Self::Invalid { + raw, + error: error.user_facing_message(), + }, + } + } + + #[must_use] + pub fn raw_text(&self) -> &str { + match self { + Self::Empty => "", + Self::Bound { raw, .. } | Self::Invalid { raw, .. } => raw, + } + } + + #[must_use] + pub fn error_text(&self) -> Option<&str> { + match self { + Self::Invalid { error, .. } => Some(error), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use super::AddressField; + + #[test] + fn required_with_empty_input_resolves_to_empty() { + assert!(matches!( + AddressField::required_from_user_input(String::new()), + AddressField::Empty + )); + } + + #[test] + fn required_with_unparseable_input_resolves_to_invalid() { + assert!(matches!( + AddressField::required_from_user_input("not-a-socket".to_owned()), + AddressField::Invalid { .. } + )); + } + + #[test] + fn required_with_in_use_port_resolves_to_invalid() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + + assert!(matches!( + AddressField::required_from_user_input(addr.to_string()), + AddressField::Invalid { .. } + )); + Ok(()) + } + + #[test] + fn optional_with_empty_input_resolves_to_empty() { + assert!(matches!( + AddressField::optional_from_user_input(String::new()), + AddressField::Empty + )); + } + + #[test] + fn optional_with_unparseable_input_resolves_to_invalid() { + assert!(matches!( + AddressField::optional_from_user_input("not-a-socket".to_owned()), + AddressField::Invalid { .. } + )); + } + + #[test] + fn optional_with_bindable_input_resolves_to_bound() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + drop(listener); + + assert!(matches!( + AddressField::optional_from_user_input(address.to_string()), + AddressField::Bound { .. } + )); + Ok(()) + } + + #[test] + fn raw_text_returns_inner_raw_for_bound_and_invalid_variants() { + assert_eq!(AddressField::Empty.raw_text(), ""); + assert_eq!( + AddressField::Invalid { + raw: "raw-text".to_owned(), + error: "ignored".to_owned() + } + .raw_text(), + "raw-text" + ); + } + + #[test] + fn error_text_returns_error_only_for_invalid_variant() { + assert!(AddressField::Empty.error_text().is_none()); + assert_eq!( + AddressField::Invalid { + raw: String::new(), + error: "the error".to_owned() + } + .error_text(), + Some("the error") + ); + } +} diff --git a/paddler_gui/src/agent_running_data.rs b/paddler_gui/src/agent_running_data.rs index e2c6d042..f0729181 100644 --- a/paddler_gui/src/agent_running_data.rs +++ b/paddler_gui/src/agent_running_data.rs @@ -26,3 +26,89 @@ impl AgentRunningData { }; } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use std::collections::BTreeSet; + + use anyhow::Result; + 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::AgentRunningData; + + #[test] + fn apply_status_marks_connected_preserves_existing_name_and_copies_snapshot_fields() + -> Result<()> { + let mut data = AgentRunningData { + balancer_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: "stale-id-should-be-cleared".to_owned(), + issues: BTreeSet::new(), + model_path: None, + name: Some("agent-fixture".to_owned()), + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + }; + + let status = SlotAggregatedStatusSnapshot { + desired_slots_total: 6, + download_current: 100, + download_filename: Some("model.gguf".to_owned()), + download_total: 200, + issues: BTreeSet::new(), + model_path: Some("/models/model.gguf".to_owned()), + slots_processing: 2, + slots_total: 6, + state_application_status: AgentStateApplicationStatus::Applied, + uses_chat_template_override: true, + version: 7, + }; + + data.apply_status(status); + + assert!(data.connected, "expected connected to flip to true"); + assert_eq!( + data.snapshot.name.as_deref(), + Some("agent-fixture"), + "expected existing name to be preserved" + ); + assert!( + data.snapshot.id.is_empty(), + "expected id to be cleared by apply_status" + ); + assert_eq!( + data.snapshot.desired_slots_total, 6, + "expected desired_slots_total to be copied from status" + ); + assert_eq!( + data.snapshot.slots_processing, 2, + "expected slots_processing to be copied from status" + ); + assert_eq!( + data.snapshot.model_path.as_deref(), + Some("/models/model.gguf"), + "expected model_path to be copied from status" + ); + assert!( + data.snapshot.uses_chat_template_override, + "expected uses_chat_template_override to be copied from status" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/agent_running_handler.rs b/paddler_gui/src/agent_running_handler.rs index 78bfb814..64000b14 100644 --- a/paddler_gui/src/agent_running_handler.rs +++ b/paddler_gui/src/agent_running_handler.rs @@ -25,3 +25,86 @@ impl AgentRunningData { } } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use std::collections::BTreeSet; + + use anyhow::Result; + 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 fresh_running_data() -> AgentRunningData { + AgentRunningData { + balancer_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("agent-fixture".to_owned()), + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } + } + + fn applied_status() -> SlotAggregatedStatusSnapshot { + SlotAggregatedStatusSnapshot { + desired_slots_total: 4, + download_current: 0, + download_filename: None, + download_total: 0, + issues: BTreeSet::new(), + model_path: Some("/models/model.gguf".to_owned()), + slots_processing: 1, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Applied, + uses_chat_template_override: false, + version: 1, + } + } + + #[test] + fn agent_status_updated_marks_connected_and_returns_none_action() -> Result<()> { + let mut data = fresh_running_data(); + + let action = data.update(Message::AgentStatusUpdated(applied_status())); + + assert!(matches!(action, Action::None)); + assert!( + data.connected, + "expected connected flag to flip to true after status update" + ); + + Ok(()) + } + + #[test] + fn disconnect_message_returns_disconnect_action() -> Result<()> { + let mut data = fresh_running_data(); + + assert!(matches!( + data.update(Message::Disconnect), + Action::Disconnect + )); + + Ok(()) + } +} diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index 7bfa8024..c8312c04 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -1,5 +1,4 @@ use std::mem; -use std::net::SocketAddr; use std::sync::LazyLock; use std::time::Duration; @@ -10,7 +9,6 @@ use iced::Fill; use iced::Right; use iced::Subscription; use iced::Task; -use iced::futures::SinkExt; use iced::keyboard; use iced::widget::column; use iced::widget::container; @@ -26,30 +24,29 @@ use paddler::balancer::state_database_type::StateDatabaseType; 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::produces_snapshot::ProducesSnapshot; #[cfg(feature = "web_admin_panel")] use paddler::resolved_socket_addr::ResolvedSocketAddr; -use paddler::subscribes_to_updates::SubscribesToUpdates as _; -use paddler_bootstrap::agent_runner::AgentRunner; use paddler_bootstrap::agent_runner::AgentRunnerParams; -use paddler_bootstrap::balancer_runner::BalancerRunner; use paddler_bootstrap::balancer_runner::BalancerRunnerParams; use paddler_bootstrap::shutdown_signal::register_shutdown_signals; +use paddler_ports::bound_port::BoundPort; use paddler_types::balancer_desired_state::BalancerDesiredState; -use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use crate::agent_running_handler; use crate::current_screen::CurrentScreen; +use crate::drive_agent_stream::drive_agent_stream; +use crate::drive_balancer_stream::drive_balancer_stream; +use crate::drive_shutdown_signal_stream::drive_shutdown_signal_stream; use crate::home_data::HomeData; use crate::home_handler; use crate::join_balancer_form_handler; use crate::message::Message; use crate::running_balancer_handler; -use crate::running_balancer_snapshot::RunningBalancerSnapshot; use crate::screen::AgentRunning; use crate::screen::Screen; use crate::start_balancer_form_handler; +use crate::started_balancer_display::StartedBalancerDisplay; use crate::ui::variables::SPACING_2X; use crate::ui::variables::SPACING_BASE; use crate::ui::view_agent_running::view_agent_running; @@ -62,29 +59,6 @@ static BETA_IMAGE: LazyLock = LazyLock::new(|| { ImageHandle::from_bytes(include_bytes!("../../resources/images/beta.png").as_slice()) }); -fn shutdown_signal_stream() -> impl iced::futures::Stream { - iced::stream::channel(1, async move |mut output| { - let shutdown_signals = match register_shutdown_signals() { - Ok(shutdown_signals) => shutdown_signals, - Err(error) => { - log::error!("failed to register shutdown signal handlers: {error}"); - - return; - } - }; - - if let Err(error) = shutdown_signals.wait().await { - log::error!("shutdown signal listener failed: {error}"); - - return; - } - - if let Err(err) = output.send(Message::Quit).await { - log::warn!("Failed to deliver Quit message to iced runtime (receiver dropped): {err}"); - } - }) -} - pub struct App { agent_cancel: Option, shutdown: CancellationToken, @@ -176,24 +150,36 @@ impl App { Task::none() } start_balancer_form_handler::Action::StartBalancer { - management_addr, - inference_addr, - web_admin_panel_addr, + management_port, + inference_port, + web_admin_panel_port, + balancer_display_address, + web_admin_panel_display_address, desired_state, } => { self.screen = CurrentScreen::StartBalancerForm(form); + let display = StartedBalancerDisplay { + balancer_address: balancer_display_address, + web_admin_panel_address: web_admin_panel_display_address, + }; + self.spawn_balancer( - management_addr, - inference_addr, - web_admin_panel_addr, + management_port, + inference_port, + web_admin_panel_port, + display, &desired_state, ) } } } - (CurrentScreen::StartBalancerForm(form), Message::BalancerStarted) => { - self.screen = CurrentScreen::RunningBalancer(form.balancer_started()); + (CurrentScreen::StartBalancerForm(form), Message::BalancerStarted(display)) => { + self.screen = + CurrentScreen::RunningBalancer(form.balancer_started( + display.balancer_address, + display.web_admin_panel_address, + )); Task::none() } @@ -301,10 +287,6 @@ impl App { } } - #[expect( - clippy::unused_self, - reason = "signature required by iced application API" - )] pub fn subscription(&self) -> Subscription { Subscription::batch([ keyboard::listen().filter_map(|event| match event { @@ -318,7 +300,11 @@ impl App { _ => None, }), window::close_requests().map(|_| Message::Quit), - Subscription::run(shutdown_signal_stream), + Subscription::run(|| { + iced::stream::channel(1, |output| { + drive_shutdown_signal_stream(register_shutdown_signals(), output) + }) + }), ]) } @@ -373,83 +359,61 @@ impl App { self.agent_cancel = Some(cancel.clone()); self.screen = CurrentScreen::AgentRunning(screen); - Task::stream(iced::stream::channel(1, async move |mut output| { - let mut runner = AgentRunner::start(AgentRunnerParams { - agent_name, - management_address, - cancellation_token: cancel, - slots, - }); - - let slot_aggregated_status = runner.slot_aggregated_status.clone(); - let mut update_rx = slot_aggregated_status.subscribe_to_updates(); - let completion_future = runner.wait_for_completion(); - tokio::pin!(completion_future); - - 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! { - changed = update_rx.changed() => { - if changed.is_err() { - return; - } - } - result = &mut completion_future => { - match result { - Ok(()) => { - if let Err(err) = output.send(Message::AgentStopped).await { - log::warn!( - "Failed to deliver AgentStopped to UI (receiver dropped): {err}" - ); - } - } - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output - .send(Message::AgentFailed(detail.clone())) - .await - { - log::error!( - "Failed to deliver AgentFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } - } - } + let params = AgentRunnerParams { + agent_name, + cancellation_token: cancel, + management_address, + slots, + }; - return; - } - } - } + Task::stream(iced::stream::channel(1, move |output| { + drive_agent_stream(params, output) })) } #[cfg(test)] + #[must_use] pub fn shutdown_token_for_test(&self) -> CancellationToken { self.shutdown.clone() } + #[cfg(test)] + #[must_use] + pub fn agent_cancel_for_test(&self) -> Option { + self.agent_cancel.clone() + } + + #[cfg(test)] + #[must_use] + pub fn balancer_cancel_for_test(&self) -> Option { + self.balancer_cancel.clone() + } + + #[cfg(test)] + pub fn set_balancer_cancel_for_test(&mut self, token: CancellationToken) { + self.balancer_cancel = Some(token); + } + + #[cfg(test)] + pub fn set_agent_cancel_for_test(&mut self, token: CancellationToken) { + self.agent_cancel = Some(token); + } + + #[cfg(test)] + #[must_use] + pub const fn current_screen_for_test(&self) -> &CurrentScreen { + &self.screen + } + + #[cfg(test)] + pub fn set_screen_for_test(&mut self, screen: CurrentScreen) { + self.screen = screen; + } + fn spawn_balancer( &mut self, - management_addr: SocketAddr, - inference_addr: SocketAddr, + management_port: BoundPort, + inference_port: BoundPort, #[cfg_attr( not(feature = "web_admin_panel"), expect( @@ -457,7 +421,8 @@ impl App { reason = "web admin panel configuration is only built when the feature is enabled" ) )] - web_admin_panel_addr: Option, + web_admin_panel_port: Option, + started_display: StartedBalancerDisplay, desired_state: &BalancerDesiredState, ) -> Task { let cancel = self.shutdown.child_token(); @@ -467,188 +432,726 @@ impl App { let max_buffered_requests = 30; let statsd_prefix = "paddler_"; + let management_addr = management_port.socket_addr; + let inference_addr = inference_port.socket_addr; + #[cfg(feature = "web_admin_panel")] - let web_admin_panel_service_configuration = - web_admin_panel_addr.map(|addr| WebAdminPanelServiceConfiguration { - addr, - template_data: TemplateData { - buffered_request_timeout, - compat_openai_addr: None, - inference_addr: ResolvedSocketAddr { - input_addr: inference_addr.to_string(), - socket_addr: inference_addr, - }, - management_addr: ResolvedSocketAddr { - input_addr: management_addr.to_string(), - socket_addr: management_addr, - }, - max_buffered_requests, - statsd_addr: None, - statsd_prefix: statsd_prefix.to_owned(), - statsd_reporting_interval: Duration::from_secs(10), - }, - }); + let (web_admin_panel_service_configuration, web_admin_panel_listener) = + match web_admin_panel_port { + Some(bound) => { + let admin_addr = bound.socket_addr; + let configuration = WebAdminPanelServiceConfiguration { + addr: admin_addr, + template_data: TemplateData { + buffered_request_timeout, + compat_openai_addr: None, + inference_addr: ResolvedSocketAddr { + input_addr: inference_addr.to_string(), + socket_addr: inference_addr, + }, + management_addr: ResolvedSocketAddr { + input_addr: management_addr.to_string(), + socket_addr: management_addr, + }, + max_buffered_requests, + statsd_addr: None, + statsd_prefix: statsd_prefix.to_owned(), + statsd_reporting_interval: Duration::from_secs(10), + }, + }; + (Some(configuration), Some(bound.listener)) + } + None => (None, None), + }; let params = BalancerRunnerParams { buffered_request_timeout, + inference_listener: Some(inference_port.listener), inference_service_configuration: InferenceServiceConfiguration { addr: inference_addr, cors_allowed_hosts: vec![], inference_item_timeout: Duration::from_secs(30), }, + management_listener: Some(management_port.listener), management_service_configuration: ManagementServiceConfiguration { addr: management_addr, cors_allowed_hosts: vec![], }, max_buffered_requests, + openai_listener: None, openai_service_configuration: None, cancellation_token: cancel, state_database_type: StateDatabaseType::Memory(Box::new(desired_state.clone())), statsd_prefix: statsd_prefix.to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }; - Task::stream(iced::stream::channel(1, async move |mut output| { - let mut runner = match BalancerRunner::start(params).await { - Ok(runner) => runner, - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output.send(Message::BalancerFailed(detail.clone())).await { - log::error!( - "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } + Task::stream(iced::stream::channel(1, move |output| { + drive_balancer_stream(params, started_display, output) + })) + } +} - return; - } - }; +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] - let completion_future = runner.wait_for_completion(); - tokio::pin!(completion_future); + use anyhow::Result; + use anyhow::bail; - if output.send(Message::BalancerStarted).await.is_err() { - return; - } + use super::*; + use crate::agent_running_data::AgentRunningData; + use crate::join_balancer_form_data::JoinBalancerFormData; + use crate::running_balancer_data::RunningBalancerData; + use crate::running_balancer_snapshot::RunningBalancerSnapshot; + use crate::screen::AgentRunning; + use crate::screen::JoinBalancerForm; + use crate::screen::RunningBalancer; + use crate::screen::StartBalancerForm; + use crate::start_balancer_form_data::StartBalancerFormData; + + fn fresh_join_form_data() -> JoinBalancerFormData { + JoinBalancerFormData::default() + } - let mut desired_state_rx = runner.balancer_desired_state_tx.subscribe(); - let mut current_desired_state = runner.initial_desired_state.clone(); - let mut pool_update_rx = runner.agent_controller_pool.subscribe_to_updates(); - let mut holder_update_rx = runner - .balancer_applicable_state_holder - .subscribe_to_updates(); - - loop { - match RunningBalancerSnapshot::build( - &runner.agent_controller_pool, - &runner.balancer_applicable_state_holder, - current_desired_state.clone(), - ) { - Ok(snapshot) => { - if output - .send(Message::RunningBalancer( - running_balancer_handler::Message::SnapshotUpdated(Box::new( - snapshot, - )), - )) - .await - .is_err() - { - return; - } - } - Err(error) => { - log::error!("Failed to build running balancer snapshot: {error}"); + fn fresh_start_form_data() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: crate::address_field::AddressField::Empty, + inference_address: crate::address_field::AddressField::Empty, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: crate::address_field::AddressField::Empty, + web_admin_panel_address_placeholder: String::new(), + } + } - return; - } - } + fn fresh_running_data() -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + } + } - tokio::select! { - changed = pool_update_rx.changed() => { - if changed.is_err() { - return; - } - } - changed = holder_update_rx.changed() => { - if changed.is_err() { - return; - } - } - desired_state_result = desired_state_rx.recv() => { - match desired_state_result { - Ok(new_desired_state) => { - current_desired_state = new_desired_state; - } - Err(broadcast::error::RecvError::Lagged(missed)) => { - log::warn!( - "Desired-state broadcast lagged by {missed} messages; \ - continuing with the last known state" - ); - } - Err(broadcast::error::RecvError::Closed) => { - log::info!( - "Desired-state broadcast closed; ending snapshot stream" - ); - - return; - } - } - } - result = &mut completion_future => { - match result { - Ok(()) => { - if let Err(err) = output.send(Message::BalancerStopped).await { - log::warn!( - "Failed to deliver BalancerStopped to UI (receiver dropped): {err}" - ); - } - } - Err(error) => { - let detail = error.to_string(); - if let Err(err) = output - .send(Message::BalancerFailed(detail.clone())) - .await - { - log::error!( - "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" - ); - } - } - } + fn fresh_agent_running_data() -> AgentRunningData { + use std::collections::BTreeSet; + + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + + AgentRunningData { + balancer_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: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } + } - return; - } - } - } - })) + fn app_with_screen(screen: CurrentScreen) -> App { + let (mut app, _initial_task) = App::new(); + app.set_screen_for_test(screen); + app } -} -#[cfg(test)] -mod tests { - use super::*; + fn screen_join_form(form: JoinBalancerFormData) -> CurrentScreen { + CurrentScreen::JoinBalancerForm( + Screen::::builder() + .state_data(form) + .build(), + ) + } + + fn screen_start_form(form: StartBalancerFormData) -> CurrentScreen { + CurrentScreen::StartBalancerForm( + Screen::::builder() + .state_data(form) + .build(), + ) + } + + fn screen_running(data: RunningBalancerData) -> CurrentScreen { + CurrentScreen::RunningBalancer( + Screen::::builder() + .state_data(data) + .build(), + ) + } + + fn screen_agent_running(data: AgentRunningData) -> CurrentScreen { + CurrentScreen::AgentRunning(Screen::::builder().state_data(data).build()) + } + + fn bound_address_field() -> Result { + let bound = paddler_ports::bind_ephemeral_port::bind_ephemeral_port()?; + Ok(crate::address_field::AddressField::Bound { + raw: bound.socket_addr.to_string(), + port: bound, + }) + } + + fn valid_connect_address_field() -> Result { + let raw = "127.0.0.1:9100".to_owned(); + let socket_addr: std::net::SocketAddr = raw.parse()?; + Ok(crate::connect_address_field::ConnectAddressField::Valid { raw, socket_addr }) + } + + fn bound_join_form_data() -> Result { + Ok(JoinBalancerFormData { + agent_name: String::new(), + balancer_address: valid_connect_address_field()?, + slots_count: crate::slot_count_field::SlotCountField::Valid { + raw: "2".to_owned(), + value: 2, + }, + }) + } + + fn assert_screen_is_home(app: &App) -> Result<()> { + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(_) + )); + Ok(()) + } #[test] - fn quit_message_cancels_shutdown_token() { + fn quit_message_cancels_shutdown_token() -> Result<()> { let (mut app, _initial_task) = App::new(); let shutdown = app.shutdown_token_for_test(); - assert!(!shutdown.is_cancelled()); + assert!( + !shutdown.is_cancelled(), + "expected shutdown token to start uncancelled" + ); let _exit_task = app.update(Message::Quit); - assert!(shutdown.is_cancelled()); + assert!( + shutdown.is_cancelled(), + "expected Quit to cancel shutdown token" + ); + Ok(()) } #[test] - fn quit_message_drops_both_runners() { + fn quit_message_drops_both_runners() -> Result<()> { let (mut app, _initial_task) = App::new(); let _exit_task = app.update(Message::Quit); - assert!(app.agent_cancel.is_none()); - assert!(app.balancer_cancel.is_none()); + assert!( + app.agent_cancel_for_test().is_none(), + "expected Quit to drop agent_cancel" + ); + assert!( + app.balancer_cancel_for_test().is_none(), + "expected Quit to drop balancer_cancel" + ); + Ok(()) + } + + #[test] + fn iced_event_loop_ready_preserves_current_screen() -> Result<()> { + let (mut app, _) = App::new(); + let _ = app.update(Message::IcedEventLoopReady); + assert_screen_is_home(&app) + } + + #[test] + fn home_start_balancer_message_transitions_to_start_balancer_form_screen() -> Result<()> { + let (mut app, _) = App::new(); + + let _ = app.update(Message::Home(home_handler::Message::StartBalancer)); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::StartBalancerForm(_) + )); + Ok(()) + } + + #[test] + fn home_join_balancer_message_transitions_to_join_balancer_form_screen() -> Result<()> { + let (mut app, _) = App::new(); + + let _ = app.update(Message::Home(home_handler::Message::JoinBalancer)); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::JoinBalancerForm(_) + )); + Ok(()) + } + + #[test] + fn join_form_setter_message_keeps_user_on_the_join_balancer_form() -> Result<()> { + let mut app = app_with_screen(screen_join_form(fresh_join_form_data())); + + let _ = app.update(Message::JoinBalancerForm( + join_balancer_form_handler::Message::SetAgentName("alice".to_owned()), + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::JoinBalancerForm(_) + )); + Ok(()) + } + + #[test] + fn join_form_cancel_message_returns_user_to_home() -> Result<()> { + let mut app = app_with_screen(screen_join_form(fresh_join_form_data())); + + let _ = app.update(Message::JoinBalancerForm( + join_balancer_form_handler::Message::Cancel, + )); + + assert_screen_is_home(&app) + } + + #[test] + fn join_form_connect_action_transitions_to_agent_running_and_sets_agent_cancel_token() + -> Result<()> { + let mut app = app_with_screen(screen_join_form(bound_join_form_data()?)); + + let _ = app.update(Message::JoinBalancerForm( + join_balancer_form_handler::Message::Connect, + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::AgentRunning(_) + )); + assert!( + app.agent_cancel_for_test().is_some(), + "expected agent_cancel token to be set" + ); + + // Stop the spawned task immediately so the test does not leave a runner. + if let Some(token) = app.agent_cancel_for_test() { + token.cancel(); + } + + Ok(()) + } + + #[test] + fn start_form_setter_message_keeps_user_on_the_start_balancer_form() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::SetBalancerAddress("127.0.0.1:0".to_owned()), + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::StartBalancerForm(_) + )); + Ok(()) + } + + #[test] + fn start_form_cancel_message_cancels_pending_balancer_token_and_returns_home() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + let token = CancellationToken::new(); + app.set_balancer_cancel_for_test(token.clone()); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::Cancel, + )); + + assert!( + token.is_cancelled(), + "expected balancer_cancel token to be cancelled" + ); + + assert_screen_is_home(&app) + } + + #[test] + fn start_form_confirm_action_sets_balancer_cancel_token_and_starts_spawn() -> Result<()> { + let form = StartBalancerFormData { + balancer_address: bound_address_field()?, + inference_address: bound_address_field()?, + add_model_later: true, + ..fresh_start_form_data() + }; + + let mut app = app_with_screen(screen_start_form(form)); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::Confirm, + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::StartBalancerForm(_) + )); + assert!( + app.balancer_cancel_for_test().is_some(), + "expected balancer_cancel token to be set after Confirm" + ); + + if let Some(token) = app.balancer_cancel_for_test() { + token.cancel(); + } + + Ok(()) + } + + #[test] + fn balancer_started_message_transitions_from_start_form_to_running_balancer() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + + let _ = app.update(Message::BalancerStarted(StartedBalancerDisplay { + balancer_address: "127.0.0.1:8060".to_owned(), + web_admin_panel_address: None, + })); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) + } + + #[test] + fn balancer_started_message_populates_the_running_view_with_user_typed_addresses() -> Result<()> + { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + + let _ = app.update(Message::BalancerStarted(StartedBalancerDisplay { + balancer_address: "127.0.0.1:8060".to_owned(), + web_admin_panel_address: Some("127.0.0.1:8062".to_owned()), + })); + + match app.current_screen_for_test() { + CurrentScreen::RunningBalancer(screen) => { + assert_eq!(screen.state_data.balancer_address, "127.0.0.1:8060"); + assert_eq!( + screen.state_data.web_admin_panel_address.as_deref(), + Some("127.0.0.1:8062") + ); + } + _ => bail!("expected RunningBalancer screen after BalancerStarted"), + } + Ok(()) + } + + #[test] + fn balancer_failed_message_during_startup_returns_user_to_home_with_error() -> Result<()> { + let mut app = app_with_screen(screen_start_form(fresh_start_form_data())); + app.set_balancer_cancel_for_test(CancellationToken::new()); + + let _ = app.update(Message::BalancerFailed("bind error".to_owned())); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(home) if home.state_data.error.as_deref() == Some("bind error") + )); + assert!( + app.balancer_cancel_for_test().is_none(), + "expected balancer_cancel to be dropped" + ); + Ok(()) + } + + #[test] + #[expect( + clippy::box_default, + reason = "explicit Box::new construction reads more clearly than Box::default in this test" + )] + fn running_balancer_snapshot_update_keeps_user_on_the_running_balancer_screen() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::SnapshotUpdated(Box::new( + RunningBalancerSnapshot::default(), + )), + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) + } + + #[test] + fn running_balancer_stop_message_cancels_token_and_keeps_user_on_screen() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + let token = CancellationToken::new(); + app.set_balancer_cancel_for_test(token.clone()); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::Stop, + )); + + assert!( + token.is_cancelled(), + "expected Stop to cancel balancer_cancel token" + ); + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) + } + + #[test] + fn running_balancer_copy_to_clipboard_keeps_user_on_screen() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::CopyToClipboard("text".to_owned()), + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) + } + + #[test] + fn running_balancer_open_url_with_invalid_url_logs_error_but_keeps_user_on_screen() -> Result<()> + { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::RunningBalancer( + running_balancer_handler::Message::OpenUrl("not-a-real-scheme://broken".to_owned()), + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::RunningBalancer(_) + )); + Ok(()) + } + + #[test] + fn balancer_stopped_message_from_running_balancer_returns_user_to_home() -> Result<()> { + let mut app = app_with_screen(screen_running(fresh_running_data())); + app.set_balancer_cancel_for_test(CancellationToken::new()); + + let _ = app.update(Message::BalancerStopped); + + assert!( + app.balancer_cancel_for_test().is_none(), + "expected balancer_cancel to be dropped" + ); + + assert_screen_is_home(&app) + } + + #[test] + fn balancer_failed_message_from_running_balancer_returns_user_to_home_with_error() -> Result<()> + { + let mut app = app_with_screen(screen_running(fresh_running_data())); + + let _ = app.update(Message::BalancerFailed("crash".to_owned())); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(home) if home.state_data.error.as_deref() == Some("crash") + )); + Ok(()) + } + + #[test] + fn agent_running_status_update_keeps_user_on_the_agent_running_screen() -> Result<()> { + use std::collections::BTreeSet; + + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + + let _ = app.update(Message::AgentRunning( + agent_running_handler::Message::AgentStatusUpdated(SlotAggregatedStatusSnapshot { + desired_slots_total: 0, + download_current: 0, + download_filename: None, + download_total: 0, + issues: BTreeSet::new(), + model_path: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + version: 0, + }), + )); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::AgentRunning(_) + )); + Ok(()) + } + + #[test] + fn agent_running_disconnect_message_cancels_token_and_returns_user_to_home() -> Result<()> { + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + let token = CancellationToken::new(); + app.set_agent_cancel_for_test(token.clone()); + + let _ = app.update(Message::AgentRunning( + agent_running_handler::Message::Disconnect, + )); + + assert!( + token.is_cancelled(), + "expected Disconnect to cancel agent_cancel token" + ); + + assert_screen_is_home(&app) + } + + #[test] + fn agent_stopped_message_returns_user_to_home_without_error() -> Result<()> { + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + app.set_agent_cancel_for_test(CancellationToken::new()); + + let _ = app.update(Message::AgentStopped); + + assert!( + app.agent_cancel_for_test().is_none(), + "expected agent_cancel to be dropped on AgentStopped" + ); + + assert_screen_is_home(&app) + } + + #[test] + fn agent_failed_message_returns_user_to_home_with_error() -> Result<()> { + let mut app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + + let _ = app.update(Message::AgentFailed("agent failure".to_owned())); + + assert!(matches!( + app.current_screen_for_test(), + CurrentScreen::Home(home) if home.state_data.error.as_deref() == Some("agent failure") + )); + Ok(()) + } + + #[test] + fn tab_pressed_without_shift_focuses_the_next_widget() -> Result<()> { + let (mut app, _) = App::new(); + let _ = app.update(Message::TabPressed { shift: false }); + // No screen change expected — just verify the call returns. + assert_screen_is_home(&app) + } + + #[test] + fn tab_pressed_with_shift_focuses_the_previous_widget() -> Result<()> { + let (mut app, _) = App::new(); + let _ = app.update(Message::TabPressed { shift: true }); + assert_screen_is_home(&app) + } + + #[test] + fn an_unhandled_message_for_the_current_screen_is_logged_and_keeps_the_screen() -> Result<()> { + let (mut app, _) = App::new(); + // BalancerStarted is only meaningful from the StartBalancerForm screen. + let _ = app.update(Message::BalancerStarted(StartedBalancerDisplay { + balancer_address: "127.0.0.1:8060".to_owned(), + web_admin_panel_address: None, + })); + assert_screen_is_home(&app) + } + + #[test] + fn view_with_home_screen_renders_the_beta_overlay_branch() -> Result<()> { + let (app, _) = App::new(); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_running_balancer_screen_renders_without_the_beta_overlay_branch() -> Result<()> { + let app = app_with_screen(screen_running(fresh_running_data())); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_agent_running_screen_renders_the_agent_running_branch() -> Result<()> { + let app = app_with_screen(screen_agent_running(fresh_agent_running_data())); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_join_balancer_form_renders_the_join_form_branch() -> Result<()> { + let app = app_with_screen(screen_join_form(fresh_join_form_data())); + let _element = app.view(); + Ok(()) + } + + #[test] + fn view_with_start_balancer_form_renders_the_start_form_branch() -> Result<()> { + let app = app_with_screen(screen_start_form(fresh_start_form_data())); + let _element = app.view(); + Ok(()) + } + + #[test] + fn start_balancer_with_web_admin_panel_address_builds_web_admin_configuration() -> Result<()> { + let form = StartBalancerFormData { + balancer_address: bound_address_field()?, + inference_address: bound_address_field()?, + web_admin_panel_address: bound_address_field()?, + add_model_later: true, + ..fresh_start_form_data() + }; + + let mut app = app_with_screen(screen_start_form(form)); + + let _ = app.update(Message::StartBalancerForm( + start_balancer_form_handler::Message::Confirm, + )); + + let token = app.balancer_cancel_for_test(); + assert!( + token.is_some(), + "expected balancer_cancel token to be set after Confirm with web admin address" + ); + + if let Some(token) = token { + token.cancel(); + } + + Ok(()) + } + + #[test] + fn subscription_returns_a_batch_without_panicking() -> Result<()> { + let (app, _) = App::new(); + let _subscription = app.subscription(); + Ok(()) } } diff --git a/paddler_gui/src/connect_address_field.rs b/paddler_gui/src/connect_address_field.rs new file mode 100644 index 00000000..18db58f4 --- /dev/null +++ b/paddler_gui/src/connect_address_field.rs @@ -0,0 +1,118 @@ +use std::net::SocketAddr; + +#[derive(Default)] +pub enum ConnectAddressField { + #[default] + Empty, + Valid { + raw: String, + socket_addr: SocketAddr, + }, + Invalid { + raw: String, + error: String, + }, +} + +impl ConnectAddressField { + #[must_use] + pub fn from_user_input(raw: String) -> Self { + if raw.is_empty() { + return Self::Empty; + } + + match raw.parse::() { + Ok(socket_addr) => Self::Valid { raw, socket_addr }, + Err(_) => Self::Invalid { + raw, + error: "Invalid address, expected format: IP:port".to_owned(), + }, + } + } + + #[must_use] + pub fn raw_text(&self) -> &str { + match self { + Self::Empty => "", + Self::Valid { raw, .. } | Self::Invalid { raw, .. } => raw, + } + } + + #[must_use] + pub fn error_text(&self) -> Option<&str> { + match self { + Self::Invalid { error, .. } => Some(error), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use super::ConnectAddressField; + + #[test] + fn empty_input_resolves_to_empty() { + assert!(matches!( + ConnectAddressField::from_user_input(String::new()), + ConnectAddressField::Empty + )); + } + + #[test] + fn unparseable_input_resolves_to_invalid() { + assert!(matches!( + ConnectAddressField::from_user_input("not-a-socket".to_owned()), + ConnectAddressField::Invalid { .. } + )); + } + + #[test] + fn well_formed_input_resolves_to_valid_without_binding() { + assert!(matches!( + ConnectAddressField::from_user_input("127.0.0.1:8061".to_owned()), + ConnectAddressField::Valid { .. } + )); + } + + #[test] + fn an_address_currently_bound_by_another_process_is_still_accepted_as_valid() + -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + + assert!(matches!( + ConnectAddressField::from_user_input(addr.to_string()), + ConnectAddressField::Valid { .. } + )); + Ok(()) + } + + #[test] + fn raw_text_returns_inner_raw_for_valid_and_invalid_variants() { + assert_eq!(ConnectAddressField::Empty.raw_text(), ""); + assert_eq!( + ConnectAddressField::Invalid { + raw: "raw-text".to_owned(), + error: "ignored".to_owned() + } + .raw_text(), + "raw-text" + ); + } + + #[test] + fn error_text_returns_error_only_for_invalid_variant() { + assert!(ConnectAddressField::Empty.error_text().is_none()); + assert_eq!( + ConnectAddressField::Invalid { + raw: String::new(), + error: "the error".to_owned() + } + .error_text(), + Some("the error") + ); + } +} diff --git a/paddler_gui/src/current_screen.rs b/paddler_gui/src/current_screen.rs index 70c40943..525dc755 100644 --- a/paddler_gui/src/current_screen.rs +++ b/paddler_gui/src/current_screen.rs @@ -24,3 +24,27 @@ impl Default for CurrentScreen { ) } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + + use super::CurrentScreen; + + #[test] + fn default_current_screen_is_home_with_no_error() -> Result<()> { + let screen = CurrentScreen::default(); + + assert!(matches!( + screen, + CurrentScreen::Home(home) if home.state_data.error.is_none() + )); + + Ok(()) + } +} diff --git a/paddler_gui/src/detect_network_interfaces.rs b/paddler_gui/src/detect_network_interfaces.rs index c80a52ff..51e10d39 100644 --- a/paddler_gui/src/detect_network_interfaces.rs +++ b/paddler_gui/src/detect_network_interfaces.rs @@ -1,5 +1,6 @@ use crate::network_interface_address::NetworkInterfaceAddress; +#[must_use] pub fn detect_network_interfaces() -> Vec { let interfaces = match if_addrs::get_if_addrs() { Ok(interfaces) => interfaces, @@ -24,3 +25,33 @@ pub fn detect_network_interfaces() -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + + use super::detect_network_interfaces; + + #[test] + fn detected_addresses_are_ipv4_and_not_loopback() -> Result<()> { + for address in detect_network_interfaces() { + assert!( + address.ip_address.is_ipv4(), + "expected only ipv4 addresses, got {}", + address.ip_address + ); + assert!( + !address.ip_address.is_loopback(), + "expected loopback to be filtered, got {}", + address.ip_address + ); + } + + Ok(()) + } +} diff --git a/paddler_gui/src/drive_agent_stream.rs b/paddler_gui/src/drive_agent_stream.rs new file mode 100644 index 00000000..e0bec877 --- /dev/null +++ b/paddler_gui/src/drive_agent_stream.rs @@ -0,0 +1,15 @@ +use iced::futures::channel::mpsc::Sender; +use paddler_bootstrap::agent_runner::AgentRunner; +use paddler_bootstrap::agent_runner::AgentRunnerParams; + +use crate::drive_agent_stream_inner::drive_agent_stream_inner; +use crate::message::Message; + +pub async fn drive_agent_stream(params: AgentRunnerParams, output: Sender) { + let mut runner = AgentRunner::start(params); + + let snapshot_source = runner.slot_aggregated_status.clone(); + let completion_future = runner.wait_for_completion(); + + drive_agent_stream_inner(snapshot_source, completion_future, output).await; +} diff --git a/paddler_gui/src/drive_agent_stream_inner.rs b/paddler_gui/src/drive_agent_stream_inner.rs new file mode 100644 index 00000000..1ddaab4d --- /dev/null +++ b/paddler_gui/src/drive_agent_stream_inner.rs @@ -0,0 +1,81 @@ +use std::future::Future; +use std::sync::Arc; + +use anyhow::Result; +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler::produces_snapshot::ProducesSnapshot; +use paddler::subscribes_to_updates::SubscribesToUpdates; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + +use crate::agent_running_handler; +use crate::message::Message; + +pub async fn drive_agent_stream_inner( + snapshot_source: Arc, + completion_future: TCompletion, + mut output: Sender, +) where + TSource: ProducesSnapshot + + SubscribesToUpdates + + Send + + Sync + + 'static, + TCompletion: Future> + Send, +{ + let mut update_rx = snapshot_source.subscribe_to_updates(); + tokio::pin!(completion_future); + + loop { + match snapshot_source.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! { + changed = update_rx.changed() => { + if changed.is_err() { + return; + } + } + result = &mut completion_future => { + match result { + Ok(()) => { + if let Err(err) = output.send(Message::AgentStopped).await { + log::warn!( + "Failed to deliver AgentStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::AgentFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver AgentFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } + + return; + } + } + } +} diff --git a/paddler_gui/src/drive_balancer_loop_inner.rs b/paddler_gui/src/drive_balancer_loop_inner.rs new file mode 100644 index 00000000..5d234342 --- /dev/null +++ b/paddler_gui/src/drive_balancer_loop_inner.rs @@ -0,0 +1,106 @@ +use std::future::Future; + +use anyhow::Result; +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler_types::balancer_desired_state::BalancerDesiredState; +use tokio::sync::broadcast; +use tokio::sync::watch; + +use crate::message::Message; +use crate::running_balancer_handler; +use crate::running_balancer_snapshot::RunningBalancerSnapshot; + +pub async fn drive_balancer_loop_inner( + initial_desired_state: BalancerDesiredState, + mut pool_update_rx: watch::Receiver<()>, + mut holder_update_rx: watch::Receiver<()>, + mut desired_state_rx: broadcast::Receiver, + completion_future: TCompletion, + mut snapshot_fn: TSnapshotFn, + mut output: Sender, +) where + TSnapshotFn: FnMut(&BalancerDesiredState) -> Result + Send, + TCompletion: Future> + Send, +{ + let mut current_desired_state = initial_desired_state; + tokio::pin!(completion_future); + + loop { + match snapshot_fn(¤t_desired_state) { + Ok(snapshot) => { + if output + .send(Message::RunningBalancer( + running_balancer_handler::Message::SnapshotUpdated(Box::new(snapshot)), + )) + .await + .is_err() + { + return; + } + } + Err(error) => { + log::error!("Failed to build running balancer snapshot: {error}"); + + return; + } + } + + tokio::select! { + changed = pool_update_rx.changed() => { + if changed.is_err() { + return; + } + } + changed = holder_update_rx.changed() => { + if changed.is_err() { + return; + } + } + desired_state_result = desired_state_rx.recv() => { + match desired_state_result { + Ok(new_desired_state) => { + current_desired_state = new_desired_state; + } + Err(broadcast::error::RecvError::Lagged(missed)) => { + log::warn!( + "Desired-state broadcast lagged by {missed} messages; \ + continuing with the last known state" + ); + } + Err(broadcast::error::RecvError::Closed) => { + log::info!( + "Desired-state broadcast closed; ending snapshot stream" + ); + + return; + } + } + } + result = &mut completion_future => { + match result { + Ok(()) => { + if let Err(err) = output.send(Message::BalancerStopped).await { + log::warn!( + "Failed to deliver BalancerStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::BalancerFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } + + return; + } + } + } +} diff --git a/paddler_gui/src/drive_balancer_stream.rs b/paddler_gui/src/drive_balancer_stream.rs new file mode 100644 index 00000000..21857208 --- /dev/null +++ b/paddler_gui/src/drive_balancer_stream.rs @@ -0,0 +1,66 @@ +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler::subscribes_to_updates::SubscribesToUpdates as _; +use paddler_bootstrap::balancer_runner::BalancerRunner; +use paddler_bootstrap::balancer_runner::BalancerRunnerParams; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::drive_balancer_loop_inner::drive_balancer_loop_inner; +use crate::message::Message; +use crate::running_balancer_snapshot::RunningBalancerSnapshot; +use crate::started_balancer_display::StartedBalancerDisplay; + +pub async fn drive_balancer_stream( + params: BalancerRunnerParams, + started_display: StartedBalancerDisplay, + mut output: Sender, +) { + let mut runner = match BalancerRunner::start(params).await { + Ok(runner) => runner, + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output.send(Message::BalancerFailed(detail.clone())).await { + log::error!( + "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + + return; + } + }; + + let completion_future = runner.wait_for_completion(); + + if output + .send(Message::BalancerStarted(started_display)) + .await + .is_err() + { + return; + } + + let desired_state_rx = runner.balancer_desired_state_tx.subscribe(); + let initial_desired_state = runner.initial_desired_state.clone(); + let pool_update_rx = runner.agent_controller_pool.subscribe_to_updates(); + let holder_update_rx = runner + .balancer_applicable_state_holder + .subscribe_to_updates(); + + let pool = runner.agent_controller_pool.clone(); + let holder = runner.balancer_applicable_state_holder.clone(); + + let snapshot_fn = move |state: &BalancerDesiredState| { + RunningBalancerSnapshot::build(&pool, &holder, state.clone()) + }; + + drive_balancer_loop_inner( + initial_desired_state, + pool_update_rx, + holder_update_rx, + desired_state_rx, + completion_future, + snapshot_fn, + output, + ) + .await; +} diff --git a/paddler_gui/src/drive_shutdown_signal_stream.rs b/paddler_gui/src/drive_shutdown_signal_stream.rs new file mode 100644 index 00000000..3ce62bc9 --- /dev/null +++ b/paddler_gui/src/drive_shutdown_signal_stream.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use iced::futures::SinkExt as _; +use iced::futures::channel::mpsc::Sender; +use paddler_bootstrap::shutdown_signal::ShutdownSignals; + +use crate::message::Message; + +pub async fn drive_shutdown_signal_stream( + signals: Result, + mut output: Sender, +) { + let shutdown_signals = match signals { + Ok(shutdown_signals) => shutdown_signals, + Err(error) => { + log::error!("failed to register shutdown signal handlers: {error}"); + + return; + } + }; + + if let Err(error) = shutdown_signals.wait().await { + log::error!("shutdown signal listener failed: {error}"); + + return; + } + + if let Err(err) = output.send(Message::Quit).await { + log::warn!("Failed to deliver Quit message to iced runtime (receiver dropped): {err}"); + } +} diff --git a/paddler_gui/src/home_handler.rs b/paddler_gui/src/home_handler.rs index 41cf98b8..deef14ed 100644 --- a/paddler_gui/src/home_handler.rs +++ b/paddler_gui/src/home_handler.rs @@ -12,6 +12,7 @@ pub enum Action { } impl HomeData { + #[must_use] pub const fn update(message: Message) -> Action { match message { Message::StartBalancer => Action::StartBalancer, @@ -19,3 +20,26 @@ impl HomeData { } } } + +#[cfg(test)] +mod tests { + use super::Action; + use super::HomeData; + use super::Message; + + #[test] + fn start_balancer_message_dispatches_to_start_balancer_action() { + assert!(matches!( + HomeData::update(Message::StartBalancer), + Action::StartBalancer + )); + } + + #[test] + fn join_balancer_message_dispatches_to_join_balancer_action() { + assert!(matches!( + HomeData::update(Message::JoinBalancer), + Action::JoinBalancer + )); + } +} diff --git a/paddler_gui/src/join_balancer_form_data.rs b/paddler_gui/src/join_balancer_form_data.rs index 47bca49c..680204ff 100644 --- a/paddler_gui/src/join_balancer_form_data.rs +++ b/paddler_gui/src/join_balancer_form_data.rs @@ -1,8 +1,9 @@ +use crate::connect_address_field::ConnectAddressField; +use crate::slot_count_field::SlotCountField; + #[derive(Default)] pub struct JoinBalancerFormData { pub agent_name: String, - pub balancer_address: String, - pub balancer_address_error: Option, - pub slots_count: String, - pub slots_error: Option, + pub balancer_address: ConnectAddressField, + pub slots_count: SlotCountField, } diff --git a/paddler_gui/src/join_balancer_form_handler.rs b/paddler_gui/src/join_balancer_form_handler.rs index acf37150..c54c9e2b 100644 --- a/paddler_gui/src/join_balancer_form_handler.rs +++ b/paddler_gui/src/join_balancer_form_handler.rs @@ -1,6 +1,8 @@ -use std::net::SocketAddr; +use std::mem; +use crate::connect_address_field::ConnectAddressField; use crate::join_balancer_form_data::JoinBalancerFormData; +use crate::slot_count_field::SlotCountField; #[derive(Debug, Clone)] pub enum Message { @@ -30,16 +32,12 @@ impl JoinBalancerFormData { Action::None } Message::SetBalancerAddress(address) => { - self.balancer_address = address; - self.balancer_address_error = None; + self.balancer_address = ConnectAddressField::from_user_input(address); 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; - } + self.slots_count = SlotCountField::from_user_input(slots); Action::None } @@ -49,49 +47,40 @@ impl JoinBalancerFormData { } fn validate_and_connect(&mut self) -> Action { - self.balancer_address_error = None; - self.slots_error = None; - - if self.balancer_address.is_empty() { - self.balancer_address_error = Some("Cluster address is required.".to_owned()); - } else if self.balancer_address.parse::().is_err() { - self.balancer_address_error = - Some("Invalid address, expected format: IP:port".to_owned()); - } + let balancer_address = mem::take(&mut self.balancer_address); + let slots_count = mem::take(&mut self.slots_count); - 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 - } - } + let required_balancer_address = match balancer_address { + ConnectAddressField::Empty => ConnectAddressField::Invalid { + raw: String::new(), + error: "Cluster address is required.".to_owned(), + }, + other => other, + }; + let required_slots_count = match slots_count { + SlotCountField::Empty => SlotCountField::Invalid { + raw: String::new(), + error: "Number of slots is required.".to_owned(), + }, + other => other, }; - if self.balancer_address_error.is_some() || self.slots_error.is_some() { + let address_valid = matches!(required_balancer_address, ConnectAddressField::Valid { .. }); + let slots_valid = matches!(required_slots_count, SlotCountField::Valid { .. }); + + if !address_valid || !slots_valid { + self.balancer_address = required_balancer_address; + self.slots_count = required_slots_count; return Action::None; } - let Some(slots) = slots else { + let ConnectAddressField::Valid { + raw: address_raw, .. + } = required_balancer_address + else { + return Action::None; + }; + let SlotCountField::Valid { value: slots, .. } = required_slots_count else { return Action::None; }; @@ -103,8 +92,250 @@ impl JoinBalancerFormData { Action::ConnectAgent { agent_name, - management_address: self.balancer_address.clone(), + management_address: address_raw, slots, } } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use std::net::SocketAddr; + use std::net::TcpListener; + + use anyhow::Result; + + use super::Action; + use super::ConnectAddressField; + use super::JoinBalancerFormData; + use super::Message; + use super::SlotCountField; + + fn valid_field(raw: &str) -> Result { + let socket_addr: SocketAddr = raw.parse()?; + Ok(ConnectAddressField::Valid { + raw: raw.to_owned(), + socket_addr, + }) + } + + #[test] + fn set_agent_name_records_typed_value_into_form_state() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + assert!(matches!( + data.update(Message::SetAgentName("alice".to_owned())), + Action::None + )); + assert_eq!(data.agent_name, "alice"); + + Ok(()) + } + + #[test] + fn set_balancer_address_with_unparseable_input_records_invalid_state() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + let _ = data.update(Message::SetBalancerAddress("not-a-socket-addr".to_owned())); + + assert!(matches!( + data.balancer_address, + ConnectAddressField::Invalid { .. } + )); + + Ok(()) + } + + #[test] + fn set_balancer_address_with_empty_input_records_empty_state() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: ConnectAddressField::Invalid { + raw: "stale".to_owned(), + error: "stale".to_owned(), + }, + ..JoinBalancerFormData::default() + }; + + let _ = data.update(Message::SetBalancerAddress(String::new())); + + assert!(matches!(data.balancer_address, ConnectAddressField::Empty)); + + Ok(()) + } + + #[test] + fn set_balancer_address_with_already_bound_port_still_records_valid_state() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + + let mut data = JoinBalancerFormData::default(); + let _ = data.update(Message::SetBalancerAddress(addr.to_string())); + + assert!(matches!( + data.balancer_address, + ConnectAddressField::Valid { .. } + )); + + Ok(()) + } + + #[test] + fn set_slots_count_accepts_digit_input() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + let _ = data.update(Message::SetSlotsCount("42".to_owned())); + + assert!(matches!(data.slots_count, SlotCountField::Valid { .. })); + + Ok(()) + } + + #[test] + fn set_slots_count_with_non_digit_input_records_invalid_state() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + let _ = data.update(Message::SetSlotsCount("abc".to_owned())); + + assert!(matches!(data.slots_count, SlotCountField::Invalid { .. })); + + Ok(()) + } + + #[test] + fn set_slots_count_with_empty_input_records_empty_state() -> Result<()> { + let mut data = JoinBalancerFormData { + slots_count: SlotCountField::Valid { + raw: "10".to_owned(), + value: 10, + }, + ..JoinBalancerFormData::default() + }; + + let _ = data.update(Message::SetSlotsCount(String::new())); + + assert!(matches!(data.slots_count, SlotCountField::Empty)); + + Ok(()) + } + + #[test] + fn cancel_message_returns_cancel_action() -> Result<()> { + let mut data = JoinBalancerFormData::default(); + + assert!(matches!(data.update(Message::Cancel), Action::Cancel)); + + Ok(()) + } + + #[test] + fn connecting_without_a_cluster_address_records_required_error() -> Result<()> { + let mut data = JoinBalancerFormData { + slots_count: SlotCountField::Valid { + raw: "1".to_owned(), + value: 1, + }, + ..JoinBalancerFormData::default() + }; + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert!(matches!( + &data.balancer_address, + ConnectAddressField::Invalid { error, .. } if error.contains("required") + )); + + Ok(()) + } + + #[test] + fn connecting_without_a_slot_count_records_required_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: valid_field("127.0.0.1:9001")?, + ..JoinBalancerFormData::default() + }; + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert!(matches!( + &data.slots_count, + SlotCountField::Invalid { error, .. } if error.contains("required") + )); + + Ok(()) + } + + #[test] + fn connecting_with_a_zero_slot_count_records_must_be_greater_than_zero_error() -> Result<()> { + let mut data = JoinBalancerFormData { + balancer_address: valid_field("127.0.0.1:9002")?, + slots_count: SlotCountField::from_user_input("0".to_owned()), + ..JoinBalancerFormData::default() + }; + + let action = data.update(Message::Connect); + + assert!(matches!(action, Action::None)); + assert!(matches!( + &data.slots_count, + SlotCountField::Invalid { error, .. } if error.contains("greater than zero") + )); + + Ok(()) + } + + #[test] + fn connecting_with_valid_input_and_no_agent_name_yields_connect_agent_with_name_none() + -> Result<()> { + let raw_address = "127.0.0.1:9003".to_owned(); + let mut data = JoinBalancerFormData { + balancer_address: valid_field(&raw_address)?, + slots_count: SlotCountField::Valid { + raw: "4".to_owned(), + value: 4, + }, + ..JoinBalancerFormData::default() + }; + + let action = data.update(Message::Connect); + + assert!(matches!( + action, + Action::ConnectAgent { + agent_name: None, + ref management_address, + slots: 4, + } if management_address == &raw_address + )); + + Ok(()) + } + + #[test] + fn connecting_with_valid_input_and_a_filled_agent_name_yields_connect_agent_with_some_name() + -> Result<()> { + let mut data = JoinBalancerFormData { + agent_name: "primary".to_owned(), + balancer_address: valid_field("127.0.0.1:9004")?, + slots_count: SlotCountField::Valid { + raw: "2".to_owned(), + value: 2, + }, + }; + + let action = data.update(Message::Connect); + + assert!(matches!( + action, + Action::ConnectAgent { ref agent_name, .. } if agent_name.as_deref() == Some("primary") + )); + + Ok(()) + } +} diff --git a/paddler_gui/src/lib.rs b/paddler_gui/src/lib.rs new file mode 100644 index 00000000..c7073f45 --- /dev/null +++ b/paddler_gui/src/lib.rs @@ -0,0 +1,136 @@ +pub mod address_field; +pub mod agent_running_data; +pub mod agent_running_handler; +pub mod app; +pub mod connect_address_field; +pub mod current_screen; +pub mod detect_network_interfaces; +pub mod drive_agent_stream; +pub mod drive_agent_stream_inner; +pub mod drive_balancer_loop_inner; +pub mod drive_balancer_stream; +pub mod drive_shutdown_signal_stream; +pub mod home_data; +pub mod home_handler; +pub mod join_balancer_form_data; +pub mod join_balancer_form_handler; +pub mod message; +pub mod model_preset; +pub mod network_interface_address; +pub mod running_balancer_data; +pub mod running_balancer_handler; +pub mod running_balancer_snapshot; +#[expect(unsafe_code, reason = "statum macros generate link_section statics")] +pub mod screen; +pub mod slot_count_field; +pub mod start_balancer_form_data; +pub mod start_balancer_form_handler; +pub mod started_balancer_display; +pub mod ui; + +use clap::Parser; +use clap::Subcommand; +#[cfg(feature = "web_admin_panel")] +use esbuild_metafile::instance::initialize_instance; +use iced::Size; +use iced::Theme; + +use crate::app::App; + +#[cfg(feature = "web_admin_panel")] +const ESBUILD_META_CONTENTS: &str = include_str!("../../esbuild-meta.json"); + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, PartialEq, Eq)] +pub enum Commands { + /// Launch the desktop GUI application (default if no subcommand is given) + Launch, +} + +pub fn init_logging() { + let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .try_init(); +} + +pub fn run() -> iced::Result { + init_logging(); + + #[cfg(feature = "web_admin_panel")] + initialize_instance(ESBUILD_META_CONTENTS); + + log::info!("paddler_gui: ready"); + + let Cli { command } = Cli::parse(); + + match command { + Some(Commands::Launch) | None => iced::application(App::new, App::update, App::view) + .font(include_bytes!( + "../../resources/fonts/JetBrainsMono-Regular.ttf" + )) + .font(include_bytes!( + "../../resources/fonts/JetBrainsMono-Bold.ttf" + )) + .theme(Theme::Light) + .window_size(Size::new(800.0, 800.0)) + .subscription(App::subscription) + .run(), + } +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use clap::Parser as _; + + use super::Cli; + use super::Commands; + use super::init_logging; + + #[test] + fn init_logging_is_idempotent_across_repeated_invocations() -> Result<()> { + init_logging(); + init_logging(); + Ok(()) + } + + #[test] + fn cli_without_subcommand_parses_as_default_launch_intent() -> Result<()> { + let cli = Cli::try_parse_from(["paddler_gui"])?; + + assert!( + cli.command.is_none(), + "expected no subcommand to leave Cli.command as None" + ); + + Ok(()) + } + + #[test] + fn cli_with_launch_subcommand_parses_into_launch_variant() -> Result<()> { + let cli = Cli::try_parse_from(["paddler_gui", "launch"])?; + + assert!(matches!(cli.command, Some(Commands::Launch))); + + Ok(()) + } + + #[test] + fn cli_rejects_unknown_subcommands() -> Result<()> { + let parse_result = Cli::try_parse_from(["paddler_gui", "bogus"]); + + assert!(parse_result.is_err()); + + Ok(()) + } +} diff --git a/paddler_gui/src/main.rs b/paddler_gui/src/main.rs index 9c7a7527..23072480 100644 --- a/paddler_gui/src/main.rs +++ b/paddler_gui/src/main.rs @@ -1,72 +1,3 @@ -mod agent_running_data; -mod agent_running_handler; -mod app; -mod current_screen; -mod detect_network_interfaces; -mod home_data; -mod home_handler; -mod join_balancer_form_data; -mod join_balancer_form_handler; -mod message; -mod model_preset; -mod network_interface_address; -mod running_balancer_data; -mod running_balancer_handler; -mod running_balancer_snapshot; -#[expect(unsafe_code, reason = "statum macros generate link_section statics")] -mod screen; -mod start_balancer_form_data; -mod start_balancer_form_handler; -mod ui; - -use app::App; -use clap::Parser; -use clap::Subcommand; -#[cfg(feature = "web_admin_panel")] -use esbuild_metafile::instance::initialize_instance; -use iced::Size; -use iced::Theme; -use log::info; - -#[cfg(feature = "web_admin_panel")] -const ESBUILD_META_CONTENTS: &str = include_str!("../../esbuild-meta.json"); - -#[derive(Parser)] -#[command(version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Launch the desktop GUI application (default if no subcommand is given) - Launch, -} - -fn launch_gui() -> iced::Result { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - - #[cfg(feature = "web_admin_panel")] - initialize_instance(ESBUILD_META_CONTENTS); - - info!("paddler_gui: ready"); - - iced::application(App::new, App::update, App::view) - .font(include_bytes!( - "../../resources/fonts/JetBrainsMono-Regular.ttf" - )) - .font(include_bytes!( - "../../resources/fonts/JetBrainsMono-Bold.ttf" - )) - .theme(Theme::Light) - .window_size(Size::new(800.0, 800.0)) - .subscription(App::subscription) - .run() -} - fn main() -> iced::Result { - match Cli::parse().command { - Some(Commands::Launch) | None => launch_gui(), - } + paddler_gui::run() } diff --git a/paddler_gui/src/message.rs b/paddler_gui/src/message.rs index 026c2ebf..e91cfd18 100644 --- a/paddler_gui/src/message.rs +++ b/paddler_gui/src/message.rs @@ -3,6 +3,7 @@ use crate::home_handler; use crate::join_balancer_form_handler; use crate::running_balancer_handler; use crate::start_balancer_form_handler; +use crate::started_balancer_display::StartedBalancerDisplay; #[derive(Debug, Clone)] pub enum Message { @@ -11,7 +12,7 @@ pub enum Message { JoinBalancerForm(join_balancer_form_handler::Message), RunningBalancer(running_balancer_handler::Message), AgentRunning(agent_running_handler::Message), - BalancerStarted, + BalancerStarted(StartedBalancerDisplay), BalancerStopped, BalancerFailed(String), AgentStopped, diff --git a/paddler_gui/src/model_preset.rs b/paddler_gui/src/model_preset.rs index 923d7ef0..1b9a972f 100644 --- a/paddler_gui/src/model_preset.rs +++ b/paddler_gui/src/model_preset.rs @@ -14,6 +14,7 @@ pub struct ModelPreset { } impl ModelPreset { + #[must_use] pub fn available_presets() -> Vec { vec![ Self { @@ -43,6 +44,7 @@ impl ModelPreset { ] } + #[must_use] pub fn to_balancer_desired_state(&self) -> BalancerDesiredState { let multimodal_projection = self .multimodal_projection @@ -66,3 +68,78 @@ impl fmt::Display for ModelPreset { write!(formatter, "{}", self.display_name) } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use paddler_types::agent_desired_model::AgentDesiredModel; + + use super::ModelPreset; + + #[test] + #[expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + fn available_presets_returns_at_least_one_preset_per_supported_model() -> Result<()> { + let presets = ModelPreset::available_presets(); + + assert!( + presets.len() >= 2, + "expected at least two presets, got {}", + presets.len() + ); + + Ok(()) + } + + #[test] + fn preset_without_multimodal_projection_serializes_projection_as_none() -> Result<()> { + let preset = ModelPreset::available_presets() + .into_iter() + .find(|preset| preset.multimodal_projection.is_none()) + .ok_or_else(|| anyhow::anyhow!("expected a preset without multimodal_projection"))?; + + let desired = preset.to_balancer_desired_state(); + + assert!(matches!( + desired.multimodal_projection, + AgentDesiredModel::None + )); + + Ok(()) + } + + #[test] + fn preset_with_multimodal_projection_serializes_projection_as_huggingface() -> Result<()> { + let preset = ModelPreset::available_presets() + .into_iter() + .find(|preset| preset.multimodal_projection.is_some()) + .ok_or_else(|| anyhow::anyhow!("expected a preset with multimodal_projection"))?; + + let desired = preset.to_balancer_desired_state(); + + assert!(matches!( + desired.multimodal_projection, + AgentDesiredModel::HuggingFace(_) + )); + + Ok(()) + } + + #[test] + fn display_impl_returns_the_preset_display_name() -> Result<()> { + let preset = ModelPreset::available_presets() + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("expected at least one preset"))?; + + assert_eq!( + format!("{preset}"), + preset.display_name, + "Display impl did not match display_name" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/running_balancer_handler.rs b/paddler_gui/src/running_balancer_handler.rs index f4903460..09ffb2bb 100644 --- a/paddler_gui/src/running_balancer_handler.rs +++ b/paddler_gui/src/running_balancer_handler.rs @@ -34,3 +34,94 @@ impl RunningBalancerData { } } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use paddler_types::agent_desired_model::AgentDesiredModel; + use paddler_types::balancer_desired_state::BalancerDesiredState; + + use super::Action; + use super::Message; + use super::RunningBalancerData; + use super::RunningBalancerSnapshot; + + fn fresh_data() -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + } + } + + #[test] + fn snapshot_updated_replaces_snapshot_and_returns_none() -> Result<()> { + let mut data = fresh_data(); + let new_snapshot = RunningBalancerSnapshot { + balancer_desired_state: BalancerDesiredState { + model: AgentDesiredModel::LocalToAgent("/some/model.gguf".to_owned()), + ..BalancerDesiredState::default() + }, + ..RunningBalancerSnapshot::default() + }; + + let action = data.update(Message::SnapshotUpdated(Box::new(new_snapshot))); + + assert!(matches!(action, Action::None)); + assert!(matches!( + &data.snapshot.balancer_desired_state.model, + AgentDesiredModel::LocalToAgent(path) if path == "/some/model.gguf" + )); + + Ok(()) + } + + #[test] + fn stop_message_sets_stopping_flag_and_returns_stop_action() -> Result<()> { + let mut data = fresh_data(); + + let action = data.update(Message::Stop); + + assert!(matches!(action, Action::Stop)); + assert!( + data.stopping, + "expected stopping flag to flip to true after Stop" + ); + + Ok(()) + } + + #[test] + fn copy_to_clipboard_message_forwards_content_as_action() -> Result<()> { + let mut data = fresh_data(); + + let action = data.update(Message::CopyToClipboard("address-value".to_owned())); + + assert!(matches!( + action, + Action::CopyToClipboard(content) if content == "address-value" + )); + + Ok(()) + } + + #[test] + fn open_url_message_forwards_url_as_action() -> Result<()> { + let mut data = fresh_data(); + + let action = data.update(Message::OpenUrl("http://example.test".to_owned())); + + assert!(matches!( + action, + Action::OpenUrl(url) if url == "http://example.test" + )); + + Ok(()) + } +} diff --git a/paddler_gui/src/screen.rs b/paddler_gui/src/screen.rs index a89daf7d..3f7dd417 100644 --- a/paddler_gui/src/screen.rs +++ b/paddler_gui/src/screen.rs @@ -6,6 +6,7 @@ use statum::machine; use statum::state; use statum::transition; +use crate::address_field::AddressField; use crate::agent_running_data::AgentRunningData; use crate::detect_network_interfaces::detect_network_interfaces; use crate::home_data::HomeData; @@ -28,10 +29,12 @@ pub struct Screen {} #[transition] impl Screen { + #[must_use] pub fn join_balancer(self) -> Screen { self.transition_with(JoinBalancerFormData::default()) } + #[must_use] pub fn start_balancer(self) -> Screen { let suggested_address = detect_network_interfaces() .first() @@ -40,15 +43,16 @@ impl Screen { self.transition_with(StartBalancerFormData { add_model_later: false, - balancer_address: format!("{suggested_address}:8060"), - balancer_address_error: None, - inference_address: format!("{suggested_address}:8061"), - inference_address_error: None, + balancer_address: AddressField::required_from_user_input(format!( + "{suggested_address}:8060" + )), + inference_address: AddressField::required_from_user_input(format!( + "{suggested_address}:8061" + )), model_error: None, selected_model: None, starting: false, - web_admin_panel_address: String::new(), - web_admin_panel_address_error: None, + web_admin_panel_address: AddressField::Empty, web_admin_panel_address_placeholder: format!("{suggested_address}:8062"), }) } @@ -56,10 +60,12 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn cancel(self) -> Screen { self.transition_with(HomeData { error: None }) } + #[must_use] pub fn connect(self) -> Screen { self.transition_map(|form_data: JoinBalancerFormData| { let name = if form_data.agent_name.is_empty() { @@ -69,7 +75,7 @@ impl Screen { }; AgentRunningData { - balancer_address: form_data.balancer_address, + balancer_address: form_data.balancer_address.raw_text().to_owned(), connected: false, snapshot: AgentControllerSnapshot { desired_slots_total: 0, @@ -92,10 +98,12 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn disconnect(self) -> Screen { self.transition_with(HomeData { error: None }) } + #[must_use] pub fn agent_failed(self, error: String) -> Screen { self.transition_with(HomeData { error: Some(error) }) } @@ -103,20 +111,26 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn cancel(self) -> Screen { self.transition_with(HomeData { error: None }) } - pub fn balancer_started(self) -> Screen { - self.transition_map(|form_data: StartBalancerFormData| RunningBalancerData { - balancer_address: form_data.balancer_address, + #[must_use] + pub fn balancer_started( + self, + balancer_address: String, + web_admin_panel_address: Option, + ) -> Screen { + self.transition_map(|_form_data: StartBalancerFormData| RunningBalancerData { + balancer_address, snapshot: RunningBalancerSnapshot::default(), stopping: false, - web_admin_panel_address: Some(form_data.web_admin_panel_address) - .filter(|address| !address.is_empty()), + web_admin_panel_address, }) } + #[must_use] pub fn balancer_failed(self, error: String) -> Screen { self.transition_with(HomeData { error: Some(error) }) } @@ -124,11 +138,290 @@ impl Screen { #[transition] impl Screen { + #[must_use] pub fn balancer_stopped(self) -> Screen { self.transition_with(HomeData { error: None }) } + #[must_use] pub fn balancer_failed(self, error: String) -> Screen { self.transition_with(HomeData { error: Some(error) }) } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + + use super::AddressField; + use super::AgentRunning; + use super::HomeData; + use super::JoinBalancerForm; + use super::JoinBalancerFormData; + use super::RunningBalancer; + use super::RunningBalancerData; + use super::RunningBalancerSnapshot; + use super::Screen; + use super::StartBalancerForm; + use super::StartBalancerFormData; + use crate::connect_address_field::ConnectAddressField; + + fn home() -> Screen { + Screen::::builder() + .state_data(HomeData { error: None }) + .build() + } + + fn join_form(prefilled: JoinBalancerFormData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn start_form(prefilled: StartBalancerFormData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn agent_running(prefilled: super::AgentRunningData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn running(prefilled: RunningBalancerData) -> Screen { + Screen::::builder() + .state_data(prefilled) + .build() + } + + fn fresh_start_form_data() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: AddressField::Invalid { + raw: "127.0.0.1:8060".to_owned(), + error: "placeholder".to_owned(), + }, + inference_address: AddressField::Invalid { + raw: "127.0.0.1:8061".to_owned(), + error: "placeholder".to_owned(), + }, + model_error: None, + selected_model: None, + starting: true, + web_admin_panel_address: AddressField::Empty, + web_admin_panel_address_placeholder: String::new(), + } + } + + fn fresh_running_data() -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + } + } + + fn fresh_agent_running_data() -> super::AgentRunningData { + use std::collections::BTreeSet; + + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + + super::AgentRunningData { + balancer_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: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } + } + + #[test] + fn home_to_join_balancer_form_starts_with_empty_form_state() -> Result<()> { + let _moved = home().join_balancer(); + Ok(()) + } + + #[test] + fn home_to_start_balancer_form_seeds_addresses_from_detected_interfaces() -> Result<()> { + let next = home().start_balancer(); + + assert!( + !next.state_data.balancer_address.raw_text().is_empty(), + "expected start form to be seeded with a balancer_address" + ); + assert!( + next.state_data + .balancer_address + .raw_text() + .ends_with(":8060"), + "expected default balancer port suffix :8060, got {}", + next.state_data.balancer_address.raw_text() + ); + + Ok(()) + } + + #[test] + fn join_balancer_form_cancel_returns_home_without_error() -> Result<()> { + let next = join_form(JoinBalancerFormData::default()).cancel(); + + assert!( + next.state_data.error.is_none(), + "cancel should not surface an error on home" + ); + Ok(()) + } + + #[test] + fn join_balancer_form_connect_with_empty_agent_name_sets_name_none_on_agent_screen() + -> Result<()> { + let form = join_form(JoinBalancerFormData { + balancer_address: ConnectAddressField::Invalid { + raw: "127.0.0.1:8060".to_owned(), + error: "placeholder".to_owned(), + }, + ..JoinBalancerFormData::default() + }); + + let next = form.connect(); + + assert!( + next.state_data.snapshot.name.is_none(), + "expected agent name None when form field empty" + ); + Ok(()) + } + + #[test] + fn join_balancer_form_connect_with_filled_agent_name_sets_some_name_on_agent_screen() + -> Result<()> { + let form = join_form(JoinBalancerFormData { + agent_name: "primary".to_owned(), + balancer_address: ConnectAddressField::Invalid { + raw: "127.0.0.1:8060".to_owned(), + error: "placeholder".to_owned(), + }, + ..JoinBalancerFormData::default() + }); + + let next = form.connect(); + + assert_eq!( + next.state_data.snapshot.name.as_deref(), + Some("primary"), + "expected agent name Some(\"primary\")" + ); + Ok(()) + } + + #[test] + fn agent_running_disconnect_returns_home_without_error() -> Result<()> { + let next = agent_running(fresh_agent_running_data()).disconnect(); + assert!( + next.state_data.error.is_none(), + "disconnect should not surface an error on home" + ); + Ok(()) + } + + #[test] + fn agent_running_agent_failed_returns_home_with_error_message() -> Result<()> { + let next = agent_running(fresh_agent_running_data()).agent_failed("oops".to_owned()); + + assert_eq!(next.state_data.error.as_deref(), Some("oops")); + + Ok(()) + } + + #[test] + fn start_balancer_form_cancel_returns_home_without_error() -> Result<()> { + let next = start_form(fresh_start_form_data()).cancel(); + assert!( + next.state_data.error.is_none(), + "cancel should not surface an error on home" + ); + Ok(()) + } + + #[test] + fn start_balancer_form_balancer_started_carries_addresses_into_running_balancer_data() + -> Result<()> { + let form = start_form(fresh_start_form_data()); + + let next = form.balancer_started( + "127.0.0.1:8060".to_owned(), + Some("127.0.0.1:8062".to_owned()), + ); + + assert_eq!(next.state_data.balancer_address, "127.0.0.1:8060"); + assert_eq!( + next.state_data.web_admin_panel_address.as_deref(), + Some("127.0.0.1:8062") + ); + + Ok(()) + } + + #[test] + fn start_balancer_form_balancer_started_with_no_web_admin_address_resolves_to_none() + -> Result<()> { + let form = start_form(fresh_start_form_data()); + + let next = form.balancer_started("127.0.0.1:8060".to_owned(), None); + + assert!( + next.state_data.web_admin_panel_address.is_none(), + "expected None web_admin_panel_address to remain None" + ); + Ok(()) + } + + #[test] + fn start_balancer_form_balancer_failed_returns_home_with_error_message() -> Result<()> { + let next = start_form(fresh_start_form_data()).balancer_failed("nope".to_owned()); + + assert_eq!(next.state_data.error.as_deref(), Some("nope")); + + Ok(()) + } + + #[test] + fn running_balancer_balancer_stopped_returns_home_without_error() -> Result<()> { + let next = running(fresh_running_data()).balancer_stopped(); + assert!( + next.state_data.error.is_none(), + "balancer_stopped should not surface an error on home" + ); + Ok(()) + } + + #[test] + fn running_balancer_balancer_failed_returns_home_with_error_message() -> Result<()> { + let next = running(fresh_running_data()).balancer_failed("kaboom".to_owned()); + + assert_eq!(next.state_data.error.as_deref(), Some("kaboom")); + + Ok(()) + } + + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; +} diff --git a/paddler_gui/src/slot_count_field.rs b/paddler_gui/src/slot_count_field.rs new file mode 100644 index 00000000..d2cda642 --- /dev/null +++ b/paddler_gui/src/slot_count_field.rs @@ -0,0 +1,153 @@ +use std::num::IntErrorKind; + +#[derive(Default)] +pub enum SlotCountField { + #[default] + Empty, + Valid { + raw: String, + value: i32, + }, + Invalid { + raw: String, + error: String, + }, +} + +impl SlotCountField { + #[must_use] + pub fn from_user_input(raw: String) -> Self { + if raw.is_empty() { + return Self::Empty; + } + + if !raw.chars().all(|character| character.is_ascii_digit()) { + return Self::Invalid { + raw, + error: "Invalid number of slots.".to_owned(), + }; + } + + match raw.parse::() { + Ok(value) if value > 0 => Self::Valid { raw, value }, + Ok(_) => Self::Invalid { + raw, + error: "Invalid number of slots (the number should be greater than zero)." + .to_owned(), + }, + Err(error) => { + let message = match error.kind() { + IntErrorKind::PosOverflow => "Number of slots is too large.", + _ => "Invalid number of slots.", + }; + Self::Invalid { + raw, + error: message.to_owned(), + } + } + } + } + + #[must_use] + pub fn raw_text(&self) -> &str { + match self { + Self::Empty => "", + Self::Valid { raw, .. } | Self::Invalid { raw, .. } => raw, + } + } + + #[must_use] + pub fn error_text(&self) -> Option<&str> { + match self { + Self::Invalid { error, .. } => Some(error), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::SlotCountField; + + #[test] + fn empty_input_resolves_to_empty_variant() { + assert!(matches!( + SlotCountField::from_user_input(String::new()), + SlotCountField::Empty + )); + } + + #[test] + fn positive_digit_input_resolves_to_valid_variant() { + assert!(matches!( + SlotCountField::from_user_input("42".to_owned()), + SlotCountField::Valid { value: 42, .. } + )); + } + + #[test] + fn zero_input_resolves_to_invalid_with_greater_than_zero_message() { + assert!(matches!( + SlotCountField::from_user_input("0".to_owned()), + SlotCountField::Invalid { error, .. } if error.contains("greater than zero") + )); + } + + #[test] + fn non_digit_input_resolves_to_invalid_with_generic_message() { + assert!(matches!( + SlotCountField::from_user_input("abc".to_owned()), + SlotCountField::Invalid { error, .. } if error == "Invalid number of slots." + )); + } + + #[test] + fn digit_input_overflowing_i32_resolves_to_too_large_message() { + assert!(matches!( + SlotCountField::from_user_input("9999999999".to_owned()), + SlotCountField::Invalid { error, .. } if error == "Number of slots is too large." + )); + } + + #[test] + fn raw_text_returns_inner_raw_for_valid_and_invalid_variants() { + assert_eq!(SlotCountField::Empty.raw_text(), ""); + assert_eq!( + SlotCountField::Valid { + raw: "5".to_owned(), + value: 5 + } + .raw_text(), + "5" + ); + assert_eq!( + SlotCountField::Invalid { + raw: "abc".to_owned(), + error: "Invalid number of slots.".to_owned() + } + .raw_text(), + "abc" + ); + } + + #[test] + fn error_text_returns_error_only_for_invalid_variant() { + assert!(SlotCountField::Empty.error_text().is_none()); + assert!( + SlotCountField::Valid { + raw: "5".to_owned(), + value: 5 + } + .error_text() + .is_none() + ); + assert_eq!( + SlotCountField::Invalid { + raw: "abc".to_owned(), + error: "Invalid number of slots.".to_owned() + } + .error_text(), + Some("Invalid number of slots.") + ); + } +} diff --git a/paddler_gui/src/start_balancer_form_data.rs b/paddler_gui/src/start_balancer_form_data.rs index 662c13de..35075193 100644 --- a/paddler_gui/src/start_balancer_form_data.rs +++ b/paddler_gui/src/start_balancer_form_data.rs @@ -1,15 +1,13 @@ +use crate::address_field::AddressField; use crate::model_preset::ModelPreset; pub struct StartBalancerFormData { pub add_model_later: bool, - pub balancer_address: String, - pub balancer_address_error: Option, - pub inference_address: String, - pub inference_address_error: Option, + pub balancer_address: AddressField, + pub inference_address: AddressField, pub model_error: Option, pub selected_model: Option, pub starting: bool, - pub web_admin_panel_address: String, - pub web_admin_panel_address_error: Option, + pub web_admin_panel_address: AddressField, pub web_admin_panel_address_placeholder: String, } diff --git a/paddler_gui/src/start_balancer_form_handler.rs b/paddler_gui/src/start_balancer_form_handler.rs index f3254518..e8ce000e 100644 --- a/paddler_gui/src/start_balancer_form_handler.rs +++ b/paddler_gui/src/start_balancer_form_handler.rs @@ -1,46 +1,12 @@ -use std::io; -use std::net::SocketAddr; -use std::net::TcpListener; +use std::mem; +use paddler_ports::bound_port::BoundPort; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::address_field::AddressField; use crate::model_preset::ModelPreset; use crate::start_balancer_form_data::StartBalancerFormData; -enum PortCheck { - Available, - InUse, - BindFailed(io::Error), -} - -fn check_port(address: &SocketAddr) -> PortCheck { - match TcpListener::bind(address) { - Ok(_) => PortCheck::Available, - Err(error) if error.kind() == io::ErrorKind::AddrInUse => PortCheck::InUse, - Err(error) => PortCheck::BindFailed(error), - } -} - -fn validate_optional_address(raw: &str) -> Result, String> { - if raw.is_empty() { - return Ok(None); - } - - let addr = raw - .parse::() - .map_err(|error| format!("Invalid address ({error}), expected format: IP:port"))?; - - match check_port(&addr) { - PortCheck::Available => Ok(Some(addr)), - PortCheck::InUse => Err(format!("Port {} is already in use", addr.port())), - PortCheck::BindFailed(error) => Err(format!("Cannot bind to {addr}: {error}")), - } -} - -fn validate_required_address(raw: &str) -> Result { - validate_optional_address(raw)?.ok_or_else(|| "Address is required.".to_owned()) -} - #[expect( clippy::large_enum_variant, reason = "ephemeral value, immediately consumed" @@ -64,9 +30,11 @@ pub enum Action { None, Cancel, StartBalancer { - management_addr: SocketAddr, - inference_addr: SocketAddr, - web_admin_panel_addr: Option, + management_port: BoundPort, + inference_port: BoundPort, + web_admin_panel_port: Option, + balancer_display_address: String, + web_admin_panel_display_address: Option, desired_state: BalancerDesiredState, }, } @@ -81,20 +49,17 @@ impl StartBalancerFormData { Action::None } Message::SetBalancerAddress(address) => { - self.balancer_address = address; - self.balancer_address_error = None; + self.balancer_address = AddressField::required_from_user_input(address); Action::None } Message::SetInferenceAddress(address) => { - self.inference_address = address; - self.inference_address_error = None; + self.inference_address = AddressField::required_from_user_input(address); Action::None } Message::SetWebAdminPanelAddress(address) => { - self.web_admin_panel_address = address; - self.web_admin_panel_address_error = None; + self.web_admin_panel_address = AddressField::optional_from_user_input(address); Action::None } @@ -113,51 +78,66 @@ impl StartBalancerFormData { } fn validate_and_confirm(&mut self) -> Action { - self.balancer_address_error = None; - self.inference_address_error = None; - self.web_admin_panel_address_error = None; self.model_error = None; if !self.add_model_later && self.selected_model.is_none() { self.model_error = Some("Please select a model.".to_owned()); } - let management_addr = match validate_required_address(&self.balancer_address) { - Ok(addr) => Some(addr), - Err(message) => { - self.balancer_address_error = Some(message); - None - } - }; - - let inference_addr = match validate_required_address(&self.inference_address) { - Ok(addr) => Some(addr), - Err(message) => { - self.inference_address_error = Some(message); - None - } - }; - - let web_admin_panel_addr = match validate_optional_address(&self.web_admin_panel_address) { - Ok(addr) => addr, - Err(message) => { - self.web_admin_panel_address_error = Some(message); - None - } - }; - - if self.model_error.is_some() - || self.balancer_address_error.is_some() - || self.inference_address_error.is_some() - || self.web_admin_panel_address_error.is_some() - { + let balancer_address = mem::take(&mut self.balancer_address); + let inference_address = mem::take(&mut self.inference_address); + let web_admin_panel_address = mem::take(&mut self.web_admin_panel_address); + + let model_error_present = self.model_error.is_some(); + let any_required_address_invalid = matches!( + balancer_address, + AddressField::Empty | AddressField::Invalid { .. } + ) || matches!( + inference_address, + AddressField::Empty | AddressField::Invalid { .. } + ); + let web_admin_panel_invalid = + matches!(web_admin_panel_address, AddressField::Invalid { .. }); + + if model_error_present || any_required_address_invalid || web_admin_panel_invalid { + self.balancer_address = match balancer_address { + AddressField::Empty => AddressField::Invalid { + raw: String::new(), + error: "Address is required.".to_owned(), + }, + other => other, + }; + self.inference_address = match inference_address { + AddressField::Empty => AddressField::Invalid { + raw: String::new(), + error: "Address is required.".to_owned(), + }, + other => other, + }; + self.web_admin_panel_address = web_admin_panel_address; return Action::None; } - let (Some(management_addr), Some(inference_addr)) = (management_addr, inference_addr) + let AddressField::Bound { + raw: balancer_display_address, + port: management_port, + } = balancer_address else { return Action::None; }; + let AddressField::Bound { + port: inference_port, + .. + } = inference_address + else { + return Action::None; + }; + let (web_admin_panel_port, web_admin_panel_display_address) = match web_admin_panel_address + { + AddressField::Bound { raw, port } => (Some(port), Some(raw)), + AddressField::Empty => (None, None), + AddressField::Invalid { .. } => return Action::None, + }; let desired_state = if self.add_model_later { BalancerDesiredState::default() @@ -171,9 +151,11 @@ impl StartBalancerFormData { self.starting = true; Action::StartBalancer { - management_addr, - inference_addr, - web_admin_panel_addr, + management_port, + inference_port, + web_admin_panel_port, + balancer_display_address, + web_admin_panel_display_address, desired_state, } } @@ -181,79 +163,337 @@ impl StartBalancerFormData { #[cfg(test)] mod tests { - use std::net::SocketAddr; - use std::net::TcpListener; + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] use anyhow::Result; - use anyhow::bail; + use paddler_ports::bind_ephemeral_port::bind_ephemeral_port; + use paddler_ports::bound_port::BoundPort; + use paddler_types::agent_desired_model::AgentDesiredModel; + + use super::Action; + use super::AddressField; + use super::Message; + use super::StartBalancerFormData; + use crate::model_preset::ModelPreset; + + fn empty_form() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: AddressField::Empty, + inference_address: AddressField::Empty, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: AddressField::Empty, + web_admin_panel_address_placeholder: String::new(), + } + } - use super::PortCheck; - use super::check_port; - use super::validate_optional_address; - use super::validate_required_address; + fn first_preset() -> Result { + ModelPreset::available_presets() + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("at least one preset must exist")) + } - const LOOPBACK_ANY_PORT: &str = "127.0.0.1:0"; - const UNASSIGNED_TEST_NET_ADDRESS: &str = "192.0.2.1:0"; + fn bound_address_field() -> Result { + let bound: BoundPort = bind_ephemeral_port()?; + Ok(AddressField::Bound { + raw: bound.socket_addr.to_string(), + port: bound, + }) + } #[test] - fn reports_in_use_when_port_is_bound() -> Result<()> { - let listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; - let bound_address = listener.local_addr()?; - - match check_port(&bound_address) { - PortCheck::InUse => Ok(()), - PortCheck::Available => bail!("bound port reported as Available"), - PortCheck::BindFailed(error) => { - bail!("bound port reported as BindFailed: {error}") - } - } + fn set_balancer_address_with_unparseable_input_records_invalid_address() -> Result<()> { + let mut data = empty_form(); + + let _action = data.update(Message::SetBalancerAddress("not-a-socket-addr".to_owned())); + + assert!(matches!( + data.balancer_address, + AddressField::Invalid { .. } + )); + Ok(()) } #[test] - fn reports_available_when_port_is_free() -> Result<()> { - let listener = TcpListener::bind(LOOPBACK_ANY_PORT)?; - let bound_address = listener.local_addr()?; + fn set_balancer_address_with_bindable_input_records_bound_listener() -> Result<()> { + let bound = bind_ephemeral_port()?; + let raw = bound.socket_addr.to_string(); + drop(bound); - drop(listener); + let mut data = empty_form(); - match check_port(&bound_address) { - PortCheck::Available => Ok(()), - PortCheck::InUse => bail!("free port reported as InUse"), - PortCheck::BindFailed(error) => { - bail!("free port reported as BindFailed: {error}") - } - } + let _action = data.update(Message::SetBalancerAddress(raw)); + + assert!(matches!(data.balancer_address, AddressField::Bound { .. })); + Ok(()) } #[test] - fn reports_bind_failed_for_non_addr_in_use_error() -> Result<()> { - let unassigned_address: SocketAddr = UNASSIGNED_TEST_NET_ADDRESS.parse()?; + fn set_balancer_address_with_empty_input_records_empty_state() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = AddressField::Invalid { + raw: "stale".to_owned(), + error: "stale".to_owned(), + }; - match check_port(&unassigned_address) { - PortCheck::BindFailed(_) => Ok(()), - PortCheck::InUse => { - bail!("non-AddrInUse bind failure reported as InUse") - } - PortCheck::Available => { - bail!("bind should fail against an unassigned address") + let _action = data.update(Message::SetBalancerAddress(String::new())); + + assert!(matches!(data.balancer_address, AddressField::Empty)); + Ok(()) + } + + #[test] + fn set_inference_address_with_empty_input_records_empty_state() -> Result<()> { + let mut data = empty_form(); + let _action = data.update(Message::SetInferenceAddress(String::new())); + assert!(matches!(data.inference_address, AddressField::Empty)); + Ok(()) + } + + #[test] + fn set_web_admin_panel_address_with_empty_input_records_empty_state() -> Result<()> { + let mut data = empty_form(); + let _action = data.update(Message::SetWebAdminPanelAddress(String::new())); + assert!(matches!(data.web_admin_panel_address, AddressField::Empty)); + Ok(()) + } + + #[test] + fn selecting_a_model_clears_a_previously_set_model_error() -> Result<()> { + let mut data = empty_form(); + data.model_error = Some("stale".to_owned()); + + let _ = data.update(Message::SelectModel(first_preset()?)); + + assert!( + data.model_error.is_none(), + "expected model_error to be cleared after SelectModel" + ); + + Ok(()) + } + + #[test] + fn toggling_add_model_later_on_clears_a_previously_set_model_error() -> Result<()> { + let mut data = empty_form(); + data.model_error = Some("stale".to_owned()); + + let _ = data.update(Message::ToggleAddModelLater(true)); + + assert!(data.add_model_later); + assert!(data.model_error.is_none()); + Ok(()) + } + + #[test] + fn toggling_add_model_later_off_leaves_a_previously_set_model_error_in_place() -> Result<()> { + let mut data = empty_form(); + data.add_model_later = true; + data.model_error = Some("preserved".to_owned()); + + let _ = data.update(Message::ToggleAddModelLater(false)); + + assert!(!data.add_model_later); + assert_eq!(data.model_error.as_deref(), Some("preserved")); + Ok(()) + } + + #[test] + fn cancel_message_returns_cancel_action() -> Result<()> { + let mut data = empty_form(); + + assert!(matches!(data.update(Message::Cancel), Action::Cancel)); + Ok(()) + } + + #[test] + fn confirming_without_a_selected_model_records_a_model_required_error() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert!(data.model_error.is_some()); + Ok(()) + } + + #[test] + fn confirming_with_empty_balancer_address_records_a_required_error() -> Result<()> { + let mut data = empty_form(); + data.inference_address = bound_address_field()?; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert!(matches!( + data.balancer_address, + AddressField::Invalid { .. } + )); + Ok(()) + } + + #[test] + fn confirming_with_invalid_inference_address_keeps_the_invalid_state() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = AddressField::Invalid { + raw: "not-a-socket".to_owned(), + error: "Invalid address".to_owned(), + }; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert!(matches!( + data.inference_address, + AddressField::Invalid { .. } + )); + Ok(()) + } + + #[test] + fn confirming_with_invalid_web_admin_panel_address_keeps_the_invalid_state() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.web_admin_panel_address = AddressField::Invalid { + raw: "not-a-socket".to_owned(), + error: "Invalid address".to_owned(), + }; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!(action, Action::None)); + assert!(matches!( + data.web_admin_panel_address, + AddressField::Invalid { .. } + )); + Ok(()) + } + + #[test] + fn confirming_with_valid_input_and_selected_model_returns_start_balancer_action() -> Result<()> + { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!( + action, + Action::StartBalancer { desired_state, .. } if matches!(desired_state.model, AgentDesiredModel::HuggingFace(_)) + )); + assert!(data.starting); + + Ok(()) + } + + #[test] + fn confirming_with_add_model_later_returns_start_balancer_with_default_desired_state() + -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.add_model_later = true; + + let action = data.update(Message::Confirm); + + assert!(matches!( + action, + Action::StartBalancer { desired_state, .. } if matches!(desired_state.model, AgentDesiredModel::None) + )); + + Ok(()) + } + + #[test] + fn confirming_with_valid_web_admin_panel_address_carries_a_bound_port() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.web_admin_panel_address = bound_address_field()?; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!( + action, + Action::StartBalancer { + web_admin_panel_port: Some(_), + .. } - } + )); + + Ok(()) } #[test] - fn required_address_rejects_empty_input() -> Result<()> { - match validate_required_address("") { - Err(_) => Ok(()), - Ok(address) => bail!("empty required input should not parse, got {address}"), - } + fn confirming_with_valid_addresses_carries_the_typed_strings_forward() -> Result<()> { + let balancer_bound = bind_ephemeral_port()?; + let inference_bound = bind_ephemeral_port()?; + let web_admin_bound = bind_ephemeral_port()?; + let typed_balancer = balancer_bound.socket_addr.to_string(); + let typed_web_admin = web_admin_bound.socket_addr.to_string(); + + let mut data = empty_form(); + data.balancer_address = AddressField::Bound { + raw: typed_balancer.clone(), + port: balancer_bound, + }; + data.inference_address = AddressField::Bound { + raw: inference_bound.socket_addr.to_string(), + port: inference_bound, + }; + data.web_admin_panel_address = AddressField::Bound { + raw: typed_web_admin.clone(), + port: web_admin_bound, + }; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!( + action, + Action::StartBalancer { + ref balancer_display_address, + web_admin_panel_display_address: Some(ref web_admin), + .. + } if balancer_display_address == &typed_balancer && web_admin == &typed_web_admin + )); + + Ok(()) } #[test] - fn optional_address_treats_empty_as_none() -> Result<()> { - match validate_optional_address("") { - Ok(None) => Ok(()), - Ok(Some(address)) => bail!("empty optional input should not parse to {address}"), - Err(error) => bail!("empty optional input should not error: {error}"), - } + fn confirming_with_no_web_admin_panel_address_carries_none_display() -> Result<()> { + let mut data = empty_form(); + data.balancer_address = bound_address_field()?; + data.inference_address = bound_address_field()?; + data.selected_model = Some(first_preset()?); + + let action = data.update(Message::Confirm); + + assert!(matches!( + action, + Action::StartBalancer { + web_admin_panel_display_address: None, + .. + } + )); + + Ok(()) } } diff --git a/paddler_gui/src/started_balancer_display.rs b/paddler_gui/src/started_balancer_display.rs new file mode 100644 index 00000000..e46b66f8 --- /dev/null +++ b/paddler_gui/src/started_balancer_display.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone)] +pub struct StartedBalancerDisplay { + pub balancer_address: String, + pub web_admin_panel_address: Option, +} diff --git a/paddler_gui/src/ui/agent_status_label.rs b/paddler_gui/src/ui/agent_status_label.rs new file mode 100644 index 00000000..640ec7e3 --- /dev/null +++ b/paddler_gui/src/ui/agent_status_label.rs @@ -0,0 +1,151 @@ +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +#[must_use] +pub fn agent_status_label(snapshot: &AgentControllerSnapshot) -> String { + let is_downloading = + snapshot.download_total > 0 && snapshot.download_current < snapshot.download_total; + + if is_downloading { + #[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; + + return format!("Downloading ({percentage:.0}%)"); + } + + if snapshot.model_path.is_none() { + return "Waiting for model...".to_owned(); + } + + match snapshot.state_application_status { + AgentStateApplicationStatus::Applied => "OK".to_owned(), + AgentStateApplicationStatus::Fresh => "Pending".to_owned(), + AgentStateApplicationStatus::AttemptedAndRetrying => "Retrying".to_owned(), + AgentStateApplicationStatus::Stuck => "Retrying, but seems stuck?".to_owned(), + AgentStateApplicationStatus::AttemptedAndNotAppliable => "Needs your help".to_owned(), + } +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use std::collections::BTreeSet; + + use anyhow::Result; + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + + use super::agent_status_label; + + fn snapshot_with( + download_current: usize, + download_total: usize, + model_path: Option<&str>, + status: AgentStateApplicationStatus, + ) -> AgentControllerSnapshot { + AgentControllerSnapshot { + desired_slots_total: 0, + download_current, + download_filename: None, + download_total, + id: "snapshot-id".to_owned(), + issues: BTreeSet::new(), + model_path: model_path.map(str::to_owned), + name: None, + slots_processing: 0, + slots_total: 0, + state_application_status: status, + uses_chat_template_override: false, + } + } + + #[test] + fn label_reports_download_progress_percentage_when_a_download_is_in_progress() -> Result<()> { + let snapshot = snapshot_with(25, 100, None, AgentStateApplicationStatus::Fresh); + + assert_eq!(agent_status_label(&snapshot), "Downloading (25%)"); + Ok(()) + } + + #[test] + fn label_says_waiting_for_model_when_no_model_is_loaded_and_no_download_is_active() -> Result<()> + { + let snapshot = snapshot_with(0, 0, None, AgentStateApplicationStatus::Fresh); + + assert_eq!(agent_status_label(&snapshot), "Waiting for model..."); + Ok(()) + } + + #[test] + fn label_says_ok_when_state_is_applied() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::Applied, + ); + + assert_eq!(agent_status_label(&snapshot), "OK"); + Ok(()) + } + + #[test] + fn label_says_pending_when_state_is_fresh() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::Fresh, + ); + + assert_eq!(agent_status_label(&snapshot), "Pending"); + Ok(()) + } + + #[test] + fn label_says_retrying_when_state_is_attempted_and_retrying() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::AttemptedAndRetrying, + ); + + assert_eq!(agent_status_label(&snapshot), "Retrying"); + Ok(()) + } + + #[test] + fn label_warns_about_stuck_when_state_is_stuck() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::Stuck, + ); + + assert_eq!(agent_status_label(&snapshot), "Retrying, but seems stuck?"); + Ok(()) + } + + #[test] + fn label_asks_for_help_when_state_is_attempted_and_not_appliable() -> Result<()> { + let snapshot = snapshot_with( + 0, + 0, + Some("/models/model.gguf"), + AgentStateApplicationStatus::AttemptedAndNotAppliable, + ); + + assert_eq!(agent_status_label(&snapshot), "Needs your help"); + Ok(()) + } +} diff --git a/paddler_gui/src/ui/display_last_path_part.rs b/paddler_gui/src/ui/display_last_path_part.rs new file mode 100644 index 00000000..ba18fbad --- /dev/null +++ b/paddler_gui/src/ui/display_last_path_part.rs @@ -0,0 +1,42 @@ +use std::path::Path; + +#[must_use] +pub fn display_last_path_part(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_owned() +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + + use super::display_last_path_part; + + #[test] + fn returns_only_the_file_name_when_path_contains_separators() -> Result<()> { + assert_eq!( + display_last_path_part("/var/models/model.gguf"), + "model.gguf", + "expected the file name portion" + ); + Ok(()) + } + + #[test] + fn returns_the_input_unchanged_when_there_is_no_separator() -> Result<()> { + assert_eq!( + display_last_path_part("just-a-name"), + "just-a-name", + "expected the original input when no separator is present" + ); + Ok(()) + } +} diff --git a/paddler_gui/src/ui/format_desired_model.rs b/paddler_gui/src/ui/format_desired_model.rs new file mode 100644 index 00000000..3260e5a1 --- /dev/null +++ b/paddler_gui/src/ui/format_desired_model.rs @@ -0,0 +1,64 @@ +use paddler_types::agent_desired_model::AgentDesiredModel; + +#[must_use] +pub fn format_desired_model(desired_model: &AgentDesiredModel) -> String { + match desired_model { + AgentDesiredModel::HuggingFace(reference) => { + format!( + "HuggingFace {}/{} ({})", + reference.repo_id, reference.filename, reference.revision, + ) + } + AgentDesiredModel::LocalToAgent(path) => format!("Local: {path}"), + AgentDesiredModel::None => "(not set)".to_owned(), + } +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use paddler_types::agent_desired_model::AgentDesiredModel; + use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + + use super::format_desired_model; + + #[test] + fn formats_huggingface_reference_with_repo_filename_and_revision() -> Result<()> { + let model = AgentDesiredModel::HuggingFace(HuggingFaceModelReference { + repo_id: "org/repo".to_owned(), + filename: "model.gguf".to_owned(), + revision: "main".to_owned(), + }); + + assert_eq!( + format_desired_model(&model), + "HuggingFace org/repo/model.gguf (main)" + ); + + Ok(()) + } + + #[test] + fn formats_local_to_agent_with_path_prefix() -> Result<()> { + let model = AgentDesiredModel::LocalToAgent("/var/models/model.gguf".to_owned()); + + assert_eq!( + format_desired_model(&model), + "Local: /var/models/model.gguf" + ); + + Ok(()) + } + + #[test] + fn formats_none_as_not_set_placeholder() -> Result<()> { + assert_eq!(format_desired_model(&AgentDesiredModel::None), "(not set)"); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/mod.rs b/paddler_gui/src/ui/mod.rs index 5a95352c..ddf9f93f 100644 --- a/paddler_gui/src/ui/mod.rs +++ b/paddler_gui/src/ui/mod.rs @@ -1,21 +1,24 @@ +pub mod agent_status_label; +pub mod display_last_path_part; +pub mod format_desired_model; pub mod variables; +pub mod view_agent_card; pub mod view_agent_running; +pub mod view_form_field; pub mod view_home; pub mod view_join_balancer_form; pub mod view_running_balancer; pub mod view_start_balancer_form; -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_checkbox; -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; +pub mod font; +pub mod style_agent_container; +pub mod style_button_disconnect; +pub mod style_button_primary; +pub mod style_card_container; +pub mod style_download_progress_bar; +pub mod style_field_checkbox; +pub mod style_field_container; +pub mod style_field_pick_list; +pub mod style_field_pick_list_menu; +pub mod style_field_text_input; +pub mod style_status_indicator; diff --git a/paddler_gui/src/ui/style_agent_container.rs b/paddler_gui/src/ui/style_agent_container.rs index 72609cc4..60ef374b 100644 --- a/paddler_gui/src/ui/style_agent_container.rs +++ b/paddler_gui/src/ui/style_agent_container.rs @@ -6,6 +6,7 @@ use iced::widget::container; use super::variables::COLOR_AGENT_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_agent_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -19,3 +20,36 @@ pub fn style_agent_container(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Background; + use iced::Theme; + + use super::COLOR_AGENT_BACKGROUND; + use super::COLOR_BORDER; + use super::style_agent_container; + + #[test] + fn agent_container_paints_orange_background_with_black_border() -> Result<()> { + let style = style_agent_container(&Theme::Light); + + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == COLOR_AGENT_BACKGROUND + )); + assert_eq!( + style.border.color, COLOR_BORDER, + "expected COLOR_BORDER border, got {:?}", + style.border.color + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_button_disconnect.rs b/paddler_gui/src/ui/style_button_disconnect.rs index 7973c5ca..b3aed33f 100644 --- a/paddler_gui/src/ui/style_button_disconnect.rs +++ b/paddler_gui/src/ui/style_button_disconnect.rs @@ -6,6 +6,7 @@ use iced::widget::button; use super::variables::COLOR_ERROR; +#[must_use] pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); @@ -20,3 +21,31 @@ pub fn style_button_disconnect(theme: &Theme, status: button::Status) -> button: ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Background; + use iced::Theme; + use iced::widget::button; + + use super::COLOR_ERROR; + use super::style_button_disconnect; + + #[test] + fn disconnect_button_paints_error_red_background() -> Result<()> { + let style = style_button_disconnect(&Theme::Light, button::Status::Active); + + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == COLOR_ERROR + )); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_button_primary.rs b/paddler_gui/src/ui/style_button_primary.rs index 9ed1bcad..e9fb393f 100644 --- a/paddler_gui/src/ui/style_button_primary.rs +++ b/paddler_gui/src/ui/style_button_primary.rs @@ -6,6 +6,7 @@ use iced::widget::button; use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::Style { let base = button::primary(theme, status); @@ -20,3 +21,36 @@ pub fn style_button_primary(theme: &Theme, status: button::Status) -> button::St ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Background; + use iced::Theme; + use iced::widget::button; + + use super::COLOR_BODY_BACKGROUND; + use super::COLOR_BORDER; + use super::style_button_primary; + + #[test] + fn primary_button_paints_border_color_background_with_body_background_text() -> Result<()> { + let style = style_button_primary(&Theme::Light, button::Status::Active); + + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == COLOR_BORDER + )); + assert_eq!( + style.text_color, COLOR_BODY_BACKGROUND, + "expected text_color == COLOR_BODY_BACKGROUND" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_card_container.rs b/paddler_gui/src/ui/style_card_container.rs index 66a5a254..d9814e17 100644 --- a/paddler_gui/src/ui/style_card_container.rs +++ b/paddler_gui/src/ui/style_card_container.rs @@ -6,6 +6,7 @@ use iced::widget::container; use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_card_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -19,3 +20,29 @@ pub fn style_card_container(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_card_container; + + #[test] + fn card_container_has_border_in_outline_color() -> Result<()> { + let style = style_card_container(&Theme::Light); + + assert_eq!( + style.border.color, COLOR_BORDER, + "expected COLOR_BORDER border, got {:?}", + style.border.color + ); + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_download_progress_bar.rs b/paddler_gui/src/ui/style_download_progress_bar.rs index 915291d7..498196d1 100644 --- a/paddler_gui/src/ui/style_download_progress_bar.rs +++ b/paddler_gui/src/ui/style_download_progress_bar.rs @@ -6,6 +6,7 @@ use iced::widget::progress_bar; use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { progress_bar::Style { background: Background::Color(COLOR_BODY_BACKGROUND), @@ -17,3 +18,30 @@ pub fn style_download_progress_bar(_theme: &Theme) -> progress_bar::Style { }, } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Background; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_download_progress_bar; + + #[test] + fn download_progress_bar_fills_with_border_color() -> Result<()> { + let style = style_download_progress_bar(&Theme::Light); + + assert!(matches!( + style.bar, + Background::Color(color) if color == COLOR_BORDER + )); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_checkbox.rs b/paddler_gui/src/ui/style_field_checkbox.rs index df80122d..a7e1d413 100644 --- a/paddler_gui/src/ui/style_field_checkbox.rs +++ b/paddler_gui/src/ui/style_field_checkbox.rs @@ -7,6 +7,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_checkbox(_theme: &Theme, status: checkbox::Status) -> checkbox::Style { let is_checked = matches!( status, @@ -32,3 +33,48 @@ pub fn style_field_checkbox(_theme: &Theme, status: checkbox::Status) -> checkbo text_color: Some(COLOR_BODY_FONT), } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Background; + use iced::Theme; + use iced::widget::checkbox; + + use super::COLOR_BODY_BACKGROUND; + use super::COLOR_BORDER; + use super::style_field_checkbox; + + #[test] + fn checkbox_in_checked_state_paints_background_with_border_color() -> Result<()> { + let style = + style_field_checkbox(&Theme::Light, checkbox::Status::Active { is_checked: true }); + + assert!(matches!( + style.background, + Background::Color(color) if color == COLOR_BORDER + )); + + Ok(()) + } + + #[test] + fn checkbox_in_unchecked_state_paints_background_with_body_background_color() -> Result<()> { + let style = style_field_checkbox( + &Theme::Light, + checkbox::Status::Active { is_checked: false }, + ); + + assert!(matches!( + style.background, + Background::Color(color) if color == COLOR_BODY_BACKGROUND + )); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_container.rs b/paddler_gui/src/ui/style_field_container.rs index df18be3d..03cc055b 100644 --- a/paddler_gui/src/ui/style_field_container.rs +++ b/paddler_gui/src/ui/style_field_container.rs @@ -5,6 +5,7 @@ use iced::widget::container; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_container(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -17,3 +18,34 @@ pub fn style_field_container(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_field_container; + + #[test] + fn field_container_casts_a_solid_offset_shadow_in_border_color() -> Result<()> { + let style = style_field_container(&Theme::Light); + + assert_eq!( + style.shadow.color, COLOR_BORDER, + "expected shadow color == COLOR_BORDER" + ); + assert!( + (style.shadow.offset.x - 4.0).abs() <= f32::EPSILON + && (style.shadow.offset.y - 4.0).abs() <= f32::EPSILON, + "expected shadow offset (4.0, 4.0)" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_pick_list.rs b/paddler_gui/src/ui/style_field_pick_list.rs index b9fe77b8..7a8e0296 100644 --- a/paddler_gui/src/ui/style_field_pick_list.rs +++ b/paddler_gui/src/ui/style_field_pick_list.rs @@ -7,6 +7,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_list::Style { let base = pick_list::default(theme, status); @@ -22,3 +23,30 @@ pub fn style_field_pick_list(theme: &Theme, status: pick_list::Status) -> pick_l }, } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Theme; + use iced::widget::pick_list; + + use super::COLOR_BORDER; + use super::style_field_pick_list; + + #[test] + fn pick_list_displays_outlined_border_in_body_font_color() -> Result<()> { + let style = style_field_pick_list(&Theme::Light, pick_list::Status::Active); + + assert_eq!( + style.border.color, COLOR_BORDER, + "expected border in COLOR_BORDER" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_pick_list_menu.rs b/paddler_gui/src/ui/style_field_pick_list_menu.rs index bce4cb4a..e922c1dd 100644 --- a/paddler_gui/src/ui/style_field_pick_list_menu.rs +++ b/paddler_gui/src/ui/style_field_pick_list_menu.rs @@ -8,6 +8,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { let base = menu::default(theme); @@ -24,3 +25,29 @@ pub fn style_field_pick_list_menu(theme: &Theme) -> menu::Style { ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Theme; + + use super::COLOR_BORDER; + use super::style_field_pick_list_menu; + + #[test] + fn pick_list_open_menu_outlines_options_with_border_color() -> Result<()> { + let style = style_field_pick_list_menu(&Theme::Light); + + assert_eq!( + style.border.color, COLOR_BORDER, + "expected menu border in COLOR_BORDER" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_field_text_input.rs b/paddler_gui/src/ui/style_field_text_input.rs index ff0e4613..6fd96ce0 100644 --- a/paddler_gui/src/ui/style_field_text_input.rs +++ b/paddler_gui/src/ui/style_field_text_input.rs @@ -7,6 +7,7 @@ use super::variables::COLOR_BODY_BACKGROUND; use super::variables::COLOR_BODY_FONT; use super::variables::COLOR_BORDER; +#[must_use] pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text_input::Style { let base = text_input::default(theme, status); @@ -23,3 +24,34 @@ pub fn style_field_text_input(theme: &Theme, status: text_input::Status) -> text ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Theme; + use iced::widget::text_input; + + use super::COLOR_BORDER; + use super::style_field_text_input; + + #[test] + fn text_input_outlines_with_border_color_at_two_pixels() -> Result<()> { + let style = style_field_text_input(&Theme::Light, text_input::Status::Active); + + assert_eq!( + style.border.color, COLOR_BORDER, + "expected border in COLOR_BORDER" + ); + assert!( + (style.border.width - 2.0).abs() <= f32::EPSILON, + "expected border width of 2.0" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/style_status_indicator.rs b/paddler_gui/src/ui/style_status_indicator.rs index b80b49c7..2c075ba8 100644 --- a/paddler_gui/src/ui/style_status_indicator.rs +++ b/paddler_gui/src/ui/style_status_indicator.rs @@ -4,6 +4,7 @@ use iced::Color; use iced::Theme; use iced::widget::container; +#[must_use] pub fn style_status_indicator(theme: &Theme) -> container::Style { let base = container::transparent(theme); @@ -17,3 +18,35 @@ pub fn style_status_indicator(theme: &Theme) -> container::Style { ..base } } + +#[cfg(test)] +mod tests { + #![expect( + clippy::unnecessary_wraps, + reason = "tests use Result<()> uniformly so the ? operator can be added without churn" + )] + + use anyhow::Result; + use iced::Background; + use iced::Color; + use iced::Theme; + + use super::style_status_indicator; + + #[test] + fn status_indicator_paints_a_pale_green_pill_against_a_muted_green_border() -> Result<()> { + let style = style_status_indicator(&Theme::Light); + + assert!(matches!( + style.background, + Some(Background::Color(color)) if color == Color::from_rgb8(0xEE, 0xFF, 0xEE) + )); + assert_eq!( + style.border.color, + Color::from_rgb8(0xCC, 0xDD, 0xCC), + "expected muted green border" + ); + + Ok(()) + } +} diff --git a/paddler_gui/src/ui/view_agent_card.rs b/paddler_gui/src/ui/view_agent_card.rs index c680f0ed..3605b98d 100644 --- a/paddler_gui/src/ui/view_agent_card.rs +++ b/paddler_gui/src/ui/view_agent_card.rs @@ -6,21 +6,15 @@ 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 super::agent_status_label::agent_status_label; +use super::display_last_path_part::display_last_path_part; 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; -fn display_last_path_part(path: &str) -> String { - std::path::Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(path) - .to_owned() -} pub fn view_agent_card( snapshot: &AgentControllerSnapshot, @@ -61,26 +55,7 @@ pub fn view_agent_card( 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" - )] - 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_owned() - } else { - match &snapshot.state_application_status { - AgentStateApplicationStatus::Applied => "OK".to_owned(), - AgentStateApplicationStatus::Fresh => "Pending".to_owned(), - AgentStateApplicationStatus::AttemptedAndRetrying => "Retrying".to_owned(), - AgentStateApplicationStatus::Stuck => "Retrying, but seems stuck?".to_owned(), - AgentStateApplicationStatus::AttemptedAndNotAppliable => "Needs your help".to_owned(), - } - }; + let status_label = agent_status_label(snapshot); let mut status_row_left = column![].spacing(SPACING_HALF); diff --git a/paddler_gui/src/ui/view_join_balancer_form.rs b/paddler_gui/src/ui/view_join_balancer_form.rs index 5b6ccd47..c3e0c22f 100644 --- a/paddler_gui/src/ui/view_join_balancer_form.rs +++ b/paddler_gui/src/ui/view_join_balancer_form.rs @@ -30,7 +30,7 @@ pub fn view_join_balancer_form(data: &JoinBalancerFormData) -> Element<'_, Messa .style(button::text) .on_press(Message::Cancel); - let balancer_address_input = text_input("IP:port", &data.balancer_address) + let balancer_address_input = text_input("IP:port", data.balancer_address.raw_text()) .on_input(Message::SetBalancerAddress) .padding(SPACING_BASE) .style(style_field_text_input) @@ -42,12 +42,15 @@ pub fn view_join_balancer_form(data: &JoinBalancerFormData) -> Element<'_, Messa .style(style_field_text_input) .into(); - let slots_input = text_input("e.g. 1", &data.slots_count) + let slots_input = text_input("e.g. 1", data.slots_count.raw_text()) .on_input(Message::SetSlotsCount) .padding(SPACING_BASE) .style(style_field_text_input) .into(); + let balancer_address_error = data.balancer_address.error_text().map(str::to_owned); + let slots_error = data.slots_count.error_text().map(str::to_owned); + column![ container(text("Join a cluster").size(FONT_SIZE_L2).font(BOLD)) .padding([0.0, SPACING_BASE]), @@ -56,10 +59,10 @@ pub fn view_join_balancer_form(data: &JoinBalancerFormData) -> Element<'_, Messa view_form_field( "Cluster address", balancer_address_input, - data.balancer_address_error.as_ref() + balancer_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()), + view_form_field("Slots", slots_input, slots_error.as_ref()), container( row![cancel_button, confirm_button] .align_y(Center) diff --git a/paddler_gui/src/ui/view_running_balancer.rs b/paddler_gui/src/ui/view_running_balancer.rs index a8c4b6f3..855e2b78 100644 --- a/paddler_gui/src/ui/view_running_balancer.rs +++ b/paddler_gui/src/ui/view_running_balancer.rs @@ -8,10 +8,10 @@ use iced::widget::row; use iced::widget::svg; use iced::widget::svg::Handle as SvgHandle; use iced::widget::text; -use paddler_types::agent_desired_model::AgentDesiredModel; use super::font::BOLD; use super::font::REGULAR; +use super::format_desired_model::format_desired_model; use super::style_button_disconnect::style_button_disconnect; use super::style_card_container::style_card_container; use super::style_status_indicator::style_status_indicator; @@ -23,19 +23,6 @@ use super::view_agent_card::view_agent_card; use crate::running_balancer_data::RunningBalancerData; use crate::running_balancer_handler::Message; -fn format_desired_model(desired_model: &AgentDesiredModel) -> String { - match desired_model { - AgentDesiredModel::HuggingFace(reference) => { - format!( - "HuggingFace {}/{} ({})", - reference.repo_id, reference.filename, reference.revision, - ) - } - AgentDesiredModel::LocalToAgent(path) => format!("Local: {path}"), - AgentDesiredModel::None => "(not set)".to_owned(), - } -} - pub fn view_running_balancer(data: &RunningBalancerData) -> Element<'_, Message> { let copy_icon = svg(SvgHandle::from_memory( include_bytes!("../../../resources/icons/copy.svg").as_slice(), diff --git a/paddler_gui/src/ui/view_start_balancer_form.rs b/paddler_gui/src/ui/view_start_balancer_form.rs index d0b9f6c7..a342d4bd 100644 --- a/paddler_gui/src/ui/view_start_balancer_form.rs +++ b/paddler_gui/src/ui/view_start_balancer_form.rs @@ -48,13 +48,13 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes .style(button::text) .on_press(Message::Cancel); - let balancer_address_input = text_input("IP:port", &data.balancer_address) + let balancer_address_input = text_input("IP:port", data.balancer_address.raw_text()) .on_input(Message::SetBalancerAddress) .padding(SPACING_BASE) .style(style_field_text_input) .into(); - let inference_address_input = text_input("IP:port", &data.inference_address) + let inference_address_input = text_input("IP:port", data.inference_address.raw_text()) .on_input(Message::SetInferenceAddress) .padding(SPACING_BASE) .style(style_field_text_input) @@ -62,13 +62,18 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes let web_admin_panel_address_input = text_input( &data.web_admin_panel_address_placeholder, - &data.web_admin_panel_address, + data.web_admin_panel_address.raw_text(), ) .on_input(Message::SetWebAdminPanelAddress) .padding(SPACING_BASE) .style(style_field_text_input) .into(); + let balancer_address_error = data.balancer_address.error_text().map(str::to_owned); + let inference_address_error = data.inference_address.error_text().map(str::to_owned); + let web_admin_panel_address_error = + data.web_admin_panel_address.error_text().map(str::to_owned); + let model_input: Element<'_, Message> = if data.add_model_later { text_input("Model will be added later", "") .padding(SPACING_BASE) @@ -119,17 +124,17 @@ pub fn view_start_balancer_form(data: &StartBalancerFormData) -> Element<'_, Mes view_form_field( "Cluster address", balancer_address_input, - data.balancer_address_error.as_ref() + balancer_address_error.as_ref() ), view_form_field( "Inference address", inference_address_input, - data.inference_address_error.as_ref() + inference_address_error.as_ref() ), view_form_field( "Web admin panel (optional)", web_admin_panel_address_input, - data.web_admin_panel_address_error.as_ref() + web_admin_panel_address_error.as_ref() ), model_field, row![cancel_button, confirm_button] diff --git a/paddler_gui_tests/Cargo.toml b/paddler_gui_tests/Cargo.toml new file mode 100644 index 00000000..dc6bcc18 --- /dev/null +++ b/paddler_gui_tests/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "paddler_gui_tests" +authors.workspace = true +description = "Integration tests for paddler_gui" +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[features] +default = [] +web_admin_panel = ["paddler_gui/web_admin_panel"] + +[dependencies] +anyhow = { workspace = true } +iced = { workspace = true } +iced_test = { workspace = true } +log = { workspace = true } +nix = { workspace = true } +paddler = { workspace = true } +paddler_bootstrap = { workspace = true } +paddler_gui = { workspace = true } +paddler_types = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } + +[dev-dependencies] +serial_test = { workspace = true } + +[lints] +workspace = true diff --git a/paddler_gui_tests/src/bind_addresses.rs b/paddler_gui_tests/src/bind_addresses.rs new file mode 100644 index 00000000..58cee5bd --- /dev/null +++ b/paddler_gui_tests/src/bind_addresses.rs @@ -0,0 +1,7 @@ +use std::net::SocketAddr; + +#[derive(Clone, Copy)] +pub struct BindAddresses { + pub inference_addr: SocketAddr, + pub management_addr: SocketAddr, +} diff --git a/paddler_gui_tests/src/bind_ephemeral_socket.rs b/paddler_gui_tests/src/bind_ephemeral_socket.rs new file mode 100644 index 00000000..21d9854d --- /dev/null +++ b/paddler_gui_tests/src/bind_ephemeral_socket.rs @@ -0,0 +1,18 @@ +use std::net::SocketAddr; +use std::net::TcpListener; + +use anyhow::Context as _; +use anyhow::Result; + +pub fn bind_ephemeral_socket() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").context("failed to bind ephemeral loopback socket")?; + + let local_addr = listener + .local_addr() + .context("failed to read local address of bound listener")?; + + drop(listener); + + Ok(local_addr) +} diff --git a/paddler_gui_tests/src/lib.rs b/paddler_gui_tests/src/lib.rs new file mode 100644 index 00000000..66a455ab --- /dev/null +++ b/paddler_gui_tests/src/lib.rs @@ -0,0 +1,4 @@ +pub mod bind_addresses; +pub mod bind_ephemeral_socket; +pub mod make_agent_controller_snapshot; +pub mod make_balancer_runner_params; diff --git a/paddler_gui_tests/src/make_agent_controller_snapshot.rs b/paddler_gui_tests/src/make_agent_controller_snapshot.rs new file mode 100644 index 00000000..bf325b2c --- /dev/null +++ b/paddler_gui_tests/src/make_agent_controller_snapshot.rs @@ -0,0 +1,52 @@ +use std::collections::BTreeSet; + +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +pub struct AgentSnapshotFixture { + pub desired_slots_total: i32, + pub download_current: usize, + pub download_filename: Option, + pub download_total: usize, + pub id: String, + pub model_path: Option, + pub name: Option, + pub slots_processing: i32, + pub slots_total: i32, + pub state_application_status: AgentStateApplicationStatus, +} + +impl Default for AgentSnapshotFixture { + fn default() -> Self { + Self { + desired_slots_total: 4, + download_current: 0, + download_filename: None, + download_total: 0, + id: "fixture-agent-id".to_owned(), + model_path: None, + name: None, + slots_processing: 0, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Fresh, + } + } +} + +#[must_use] +pub fn make_agent_controller_snapshot(fixture: AgentSnapshotFixture) -> AgentControllerSnapshot { + AgentControllerSnapshot { + desired_slots_total: fixture.desired_slots_total, + download_current: fixture.download_current, + download_filename: fixture.download_filename, + download_total: fixture.download_total, + id: fixture.id, + issues: BTreeSet::new(), + model_path: fixture.model_path, + name: fixture.name, + slots_processing: fixture.slots_processing, + slots_total: fixture.slots_total, + state_application_status: fixture.state_application_status, + uses_chat_template_override: false, + } +} diff --git a/paddler_gui_tests/src/make_balancer_runner_params.rs b/paddler_gui_tests/src/make_balancer_runner_params.rs new file mode 100644 index 00000000..d7392e7d --- /dev/null +++ b/paddler_gui_tests/src/make_balancer_runner_params.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +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::balancer_runner::BalancerRunnerParams; +use tokio_util::sync::CancellationToken; + +use crate::bind_addresses::BindAddresses; + +#[must_use] +pub fn make_balancer_runner_params( + addrs: BindAddresses, + cancellation_token: CancellationToken, +) -> BalancerRunnerParams { + BalancerRunnerParams { + buffered_request_timeout: Duration::from_secs(10), + inference_listener: None, + inference_service_configuration: InferenceServiceConfiguration { + addr: addrs.inference_addr, + cors_allowed_hosts: vec![], + inference_item_timeout: Duration::from_secs(30), + }, + management_listener: None, + management_service_configuration: ManagementServiceConfiguration { + addr: addrs.management_addr, + cors_allowed_hosts: vec![], + }, + max_buffered_requests: 30, + openai_listener: None, + openai_service_configuration: None, + cancellation_token, + state_database_type: StateDatabaseType::Memory(Box::default()), + statsd_prefix: "paddler_test_".to_owned(), + statsd_service_configuration: None, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: None, + } +} diff --git a/paddler_gui_tests/tests/agent_card_shows_download_progress_while_the_model_is_downloading.rs b/paddler_gui_tests/tests/agent_card_shows_download_progress_while_the_model_is_downloading.rs new file mode 100644 index 00000000..729342eb --- /dev/null +++ b/paddler_gui_tests/tests/agent_card_shows_download_progress_while_the_model_is_downloading.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::message::Message; +use paddler_gui::ui::view_agent_card::view_agent_card; +use paddler_gui_tests::make_agent_controller_snapshot::AgentSnapshotFixture; +use paddler_gui_tests::make_agent_controller_snapshot::make_agent_controller_snapshot; + +#[test] +fn a_named_agent_in_the_middle_of_downloading_renders_a_progress_bar_and_percentage() -> Result<()> +{ + let snapshot = make_agent_controller_snapshot(AgentSnapshotFixture { + name: Some("downloading-agent".to_owned()), + download_current: 25, + download_total: 100, + ..AgentSnapshotFixture::default() + }); + + let mut simulator = simulator(view_agent_card::(&snapshot)); + + if simulator.find("downloading-agent").is_err() { + bail!("expected the agent name to render"); + } + if simulator.find("Status: Downloading (25%)").is_err() { + bail!("expected the download-progress status to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/agent_card_shows_loaded_model_and_open_issues.rs b/paddler_gui_tests/tests/agent_card_shows_loaded_model_and_open_issues.rs new file mode 100644 index 00000000..88c73341 --- /dev/null +++ b/paddler_gui_tests/tests/agent_card_shows_loaded_model_and_open_issues.rs @@ -0,0 +1,48 @@ +use std::collections::BTreeSet; + +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::message::Message; +use paddler_gui::ui::view_agent_card::view_agent_card; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_issue::AgentIssue; +use paddler_types::agent_issue_params::ModelPath; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +#[test] +fn an_unnamed_agent_with_a_loaded_model_and_open_issues_renders_each_section() -> Result<()> { + let mut issues = BTreeSet::new(); + issues.insert(AgentIssue::ModelFileDoesNotExist(ModelPath { + model_path: "/var/models/model.gguf".to_owned(), + })); + + let snapshot = AgentControllerSnapshot { + desired_slots_total: 4, + download_current: 0, + download_filename: None, + download_total: 0, + id: "unnamed-agent-id".to_owned(), + issues, + model_path: Some("/var/models/model.gguf".to_owned()), + name: None, + slots_processing: 1, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Applied, + uses_chat_template_override: false, + }; + + let mut simulator = simulator(view_agent_card::(&snapshot)); + + if simulator.find("model.gguf").is_err() { + bail!("expected the model file name to render"); + } + if simulator.find("Status: OK").is_err() { + bail!("expected the status label to render"); + } + if simulator.find("1 issues").is_err() { + bail!("expected the issues count to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/copying_the_balancer_address_to_the_clipboard.rs b/paddler_gui_tests/tests/copying_the_balancer_address_to_the_clipboard.rs new file mode 100644 index 00000000..183caaa7 --- /dev/null +++ b/paddler_gui_tests/tests/copying_the_balancer_address_to_the_clipboard.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_handler::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; + +#[test] +fn clicking_copy_address_sends_the_copy_to_clipboard_message_with_the_balancer_address() +-> Result<()> { + let data = RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + }; + let mut simulator = simulator(view_running_balancer(&data)); + + simulator.click("Copy address")?; + + match simulator.into_messages().next() { + Some(Message::CopyToClipboard(addr)) if addr == "127.0.0.1:8060" => Ok(()), + other => bail!("expected CopyToClipboard(127.0.0.1:8060), got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/disconnecting_an_agent_from_the_balancer.rs b/paddler_gui_tests/tests/disconnecting_an_agent_from_the_balancer.rs new file mode 100644 index 00000000..04673d4b --- /dev/null +++ b/paddler_gui_tests/tests/disconnecting_an_agent_from_the_balancer.rs @@ -0,0 +1,44 @@ +use std::collections::BTreeSet; + +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::agent_running_data::AgentRunningData; +use paddler_gui::agent_running_handler::Message; +use paddler_gui::ui::view_agent_running::view_agent_running; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +fn fresh_agent_running_data() -> AgentRunningData { + AgentRunningData { + balancer_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: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } +} + +#[test] +fn clicking_disconnect_sends_the_disconnect_message() -> Result<()> { + let data = fresh_agent_running_data(); + let mut simulator = simulator(view_agent_running(&data)); + + simulator.click("Disconnect")?; + + match simulator.into_messages().next() { + Some(Message::Disconnect) => Ok(()), + other => bail!("expected Disconnect message, got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/form_fields_render_their_label_input_and_optional_error.rs b/paddler_gui_tests/tests/form_fields_render_their_label_input_and_optional_error.rs new file mode 100644 index 00000000..b61a8bd6 --- /dev/null +++ b/paddler_gui_tests/tests/form_fields_render_their_label_input_and_optional_error.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use anyhow::bail; +use iced::widget::text_input; +use iced_test::simulator; +use paddler_gui::message::Message; +use paddler_gui::ui::view_form_field::view_form_field; + +#[test] +fn a_form_field_without_an_error_renders_only_label_and_input() -> Result<()> { + let input = text_input("placeholder", "value").into(); + let mut simulator = simulator(view_form_field::("Cluster address", input, None)); + + if simulator.find("Cluster address").is_err() { + bail!("expected the form field label to render"); + } + + Ok(()) +} + +#[test] +fn a_form_field_with_an_error_renders_the_error_text_below_the_input() -> Result<()> { + let input = text_input("placeholder", "value").into(); + let error = "Address is required.".to_owned(); + let mut simulator = simulator(view_form_field::( + "Cluster address", + input, + Some(&error), + )); + + if simulator.find("Cluster address").is_err() { + bail!("expected the label to render"); + } + if simulator.find("Address is required.").is_err() { + bail!("expected the error text to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/home_screen_shows_an_error_after_a_failed_balancer_or_agent_run.rs b/paddler_gui_tests/tests/home_screen_shows_an_error_after_a_failed_balancer_or_agent_run.rs new file mode 100644 index 00000000..b35dc380 --- /dev/null +++ b/paddler_gui_tests/tests/home_screen_shows_an_error_after_a_failed_balancer_or_agent_run.rs @@ -0,0 +1,18 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::home_data::HomeData; +use paddler_gui::ui::view_home::view_home; + +#[test] +fn home_displays_the_error_message_that_was_carried_over_from_the_previous_screen() -> Result<()> { + let data = HomeData { + error: Some("Balancer crashed during startup".to_owned()), + }; + let mut simulator = simulator(view_home(&data)); + + match simulator.find("Balancer crashed during startup") { + Ok(_) => Ok(()), + Err(error) => bail!("expected to find the error text in the rendered tree: {error}"), + } +} diff --git a/paddler_gui_tests/tests/home_screen_starts_the_user_on_a_balancer_or_agent_flow.rs b/paddler_gui_tests/tests/home_screen_starts_the_user_on_a_balancer_or_agent_flow.rs new file mode 100644 index 00000000..c546aa32 --- /dev/null +++ b/paddler_gui_tests/tests/home_screen_starts_the_user_on_a_balancer_or_agent_flow.rs @@ -0,0 +1,32 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::home_data::HomeData; +use paddler_gui::home_handler::Message; +use paddler_gui::ui::view_home::view_home; + +#[test] +fn clicking_start_a_cluster_takes_the_user_to_the_start_balancer_form() -> Result<()> { + let data = HomeData { error: None }; + let mut simulator = simulator(view_home(&data)); + + simulator.click("Start a cluster")?; + + match simulator.into_messages().next() { + Some(Message::StartBalancer) => Ok(()), + other => bail!("expected StartBalancer message, got {other:?}"), + } +} + +#[test] +fn clicking_join_a_cluster_takes_the_user_to_the_join_balancer_form() -> Result<()> { + let data = HomeData { error: None }; + let mut simulator = simulator(view_home(&data)); + + simulator.click("Join a cluster")?; + + match simulator.into_messages().next() { + Some(Message::JoinBalancer) => Ok(()), + other => bail!("expected JoinBalancer message, got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/joining_a_balancer_collects_and_submits_agent_details.rs b/paddler_gui_tests/tests/joining_a_balancer_collects_and_submits_agent_details.rs new file mode 100644 index 00000000..ff025471 --- /dev/null +++ b/paddler_gui_tests/tests/joining_a_balancer_collects_and_submits_agent_details.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::join_balancer_form_data::JoinBalancerFormData; +use paddler_gui::join_balancer_form_handler::Message; +use paddler_gui::ui::view_join_balancer_form::view_join_balancer_form; + +#[test] +fn clicking_connect_sends_the_connect_message() -> Result<()> { + let data = JoinBalancerFormData::default(); + let mut simulator = simulator(view_join_balancer_form(&data)); + + simulator.click("Connect")?; + + match simulator.into_messages().next() { + Some(Message::Connect) => Ok(()), + other => bail!("expected Connect message, got {other:?}"), + } +} + +#[test] +fn clicking_cancel_sends_the_cancel_message() -> Result<()> { + let data = JoinBalancerFormData::default(); + let mut simulator = simulator(view_join_balancer_form(&data)); + + simulator.click("Cancel")?; + + match simulator.into_messages().next() { + Some(Message::Cancel) => Ok(()), + other => bail!("expected Cancel message, got {other:?}"), + } +} + +#[test] +fn agent_name_field_is_findable_for_user_input() -> Result<()> { + let data = JoinBalancerFormData { + agent_name: "primary".to_owned(), + ..JoinBalancerFormData::default() + }; + let mut simulator = simulator(view_join_balancer_form(&data)); + + match simulator.find("primary") { + Ok(_) => Ok(()), + Err(error) => bail!("expected the populated agent name to render: {error}"), + } +} diff --git a/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs new file mode 100644 index 00000000..5d7b8d18 --- /dev/null +++ b/paddler_gui_tests/tests/joining_a_balancer_shows_validation_errors_to_the_user.rs @@ -0,0 +1,32 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::connect_address_field::ConnectAddressField; +use paddler_gui::join_balancer_form_data::JoinBalancerFormData; +use paddler_gui::slot_count_field::SlotCountField; +use paddler_gui::ui::view_join_balancer_form::view_join_balancer_form; + +#[test] +fn the_cluster_address_and_slots_errors_render_under_their_inputs_when_set() -> Result<()> { + let data = JoinBalancerFormData { + balancer_address: ConnectAddressField::Invalid { + raw: String::new(), + error: "Cluster address is required.".to_owned(), + }, + slots_count: SlotCountField::Invalid { + raw: String::new(), + error: "Number of slots is required.".to_owned(), + }, + ..JoinBalancerFormData::default() + }; + let mut simulator = simulator(view_join_balancer_form(&data)); + + if simulator.find("Cluster address is required.").is_err() { + bail!("expected cluster address error to render"); + } + if simulator.find("Number of slots is required.").is_err() { + bail!("expected slots error to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs b/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs new file mode 100644 index 00000000..87466b7b --- /dev/null +++ b/paddler_gui_tests/tests/opening_the_web_admin_panel_from_the_running_balancer_screen.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_handler::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; + +fn data_with_panel(address: Option<&str>) -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: address.map(str::to_owned), + } +} + +#[test] +fn clicking_open_in_browser_sends_the_open_url_message_when_a_panel_address_is_set() -> Result<()> { + let data = data_with_panel(Some("127.0.0.1:8062")); + let mut simulator = simulator(view_running_balancer(&data)); + + simulator.click("Open in browser")?; + + match simulator.into_messages().next() { + Some(Message::OpenUrl(url)) if url == "http://127.0.0.1:8062" => Ok(()), + other => bail!("expected OpenUrl(http://127.0.0.1:8062), got {other:?}"), + } +} + +#[test] +fn the_open_in_browser_button_is_hidden_when_no_panel_address_is_set() -> Result<()> { + let data = data_with_panel(None); + let mut simulator = simulator(view_running_balancer(&data)); + + if simulator.find("Open in browser").is_ok() { + bail!("expected the open-in-browser button to be absent without a panel address"); + } + Ok(()) +} diff --git a/paddler_gui_tests/tests/os_signals_quit_the_app.rs b/paddler_gui_tests/tests/os_signals_quit_the_app.rs new file mode 100644 index 00000000..43e11a48 --- /dev/null +++ b/paddler_gui_tests/tests/os_signals_quit_the_app.rs @@ -0,0 +1,57 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use nix::sys::signal::Signal; +use nix::sys::signal::kill; +use nix::unistd::Pid; +use paddler_bootstrap::shutdown_signal::register_shutdown_signals; +use paddler_gui::drive_shutdown_signal_stream::drive_shutdown_signal_stream; +use paddler_gui::message::Message; +use serial_test::serial; + +const SIGNAL_DELIVERY_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test] +#[serial] +async fn if_signal_registration_fails_the_app_logs_and_keeps_running_without_quitting() -> Result<()> +{ + let (output, mut receiver) = mpsc::channel::(1); + + drive_shutdown_signal_stream( + Err(anyhow::anyhow!("simulated registration failure")), + output, + ) + .await; + + match receiver.next().await { + None => Ok(()), + Some(message) => bail!("expected no message after registration failure, got {message:?}"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn sigterm_delivered_to_the_process_makes_the_app_quit() -> Result<()> { + let signals = register_shutdown_signals()?; + let (output, mut receiver) = mpsc::channel::(1); + + let driver = tokio::spawn(drive_shutdown_signal_stream(Ok(signals), output)); + + // Give the signal handler a moment to register before we raise the signal. + tokio::task::yield_now().await; + kill(Pid::this(), Signal::SIGTERM)?; + + let received = tokio::time::timeout(SIGNAL_DELIVERY_TIMEOUT, receiver.next()).await?; + + match received { + Some(Message::Quit) => {} + other => bail!("expected Message::Quit, got {other:?}"), + } + + tokio::time::timeout(SIGNAL_DELIVERY_TIMEOUT, driver).await??; + + Ok(()) +} diff --git a/paddler_gui_tests/tests/running_a_balancer_loop_handles_snapshot_update_and_completion_failures.rs b/paddler_gui_tests/tests/running_a_balancer_loop_handles_snapshot_update_and_completion_failures.rs new file mode 100644 index 00000000..95b2dbd4 --- /dev/null +++ b/paddler_gui_tests/tests/running_a_balancer_loop_handles_snapshot_update_and_completion_failures.rs @@ -0,0 +1,288 @@ +use std::future::pending; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler_gui::drive_balancer_loop_inner::drive_balancer_loop_inner; +use paddler_gui::message::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_types::balancer_desired_state::BalancerDesiredState; +use tokio::sync::broadcast; +use tokio::sync::watch; + +const BALANCER_LOOP_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test] +async fn snapshot_build_failure_exits_the_balancer_loop() -> Result<()> { + let (_, pool_update_rx) = watch::channel(()); + let (_, holder_update_rx) = watch::channel(()); + let (_, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Err(anyhow!("snapshot build failed")), + output, + )); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(receiver.next().await.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn pool_update_channel_closure_exits_the_balancer_loop_after_a_snapshot() -> Result<()> { + let (_, holder_update_rx) = watch::channel(()); + let (_, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + // Drop the pool sender so `changed()` will fail on the first await. + let (_, pool_update_rx) = watch::channel(()); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let first_message = tokio::time::timeout(BALANCER_LOOP_TIMEOUT, receiver.next()).await?; + assert!(matches!(first_message, Some(Message::RunningBalancer(_)))); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + Ok(()) +} + +#[tokio::test] +async fn desired_state_broadcast_closure_exits_the_balancer_loop_after_a_snapshot() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + // Wait for the first snapshot to land, then close the desired-state broadcast. + let first_message = tokio::time::timeout(BALANCER_LOOP_TIMEOUT, receiver.next()).await?; + assert!(matches!(first_message, Some(Message::RunningBalancer(_)))); + + drop(desired_state_tx); + drop(pool_update_tx); + drop(holder_update_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + Ok(()) +} + +#[tokio::test] +async fn completion_with_ok_emits_balancer_stopped_message() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (_desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Ok(()) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let mut observed_stopped = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerStopped) { + observed_stopped = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_stopped); + Ok(()) +} + +#[tokio::test] +async fn completion_with_error_emits_balancer_failed_message() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (_desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Err(anyhow!("runner crashed")) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let mut observed_failed = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerFailed(_)) { + observed_failed = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_failed); + Ok(()) +} + +#[tokio::test] +async fn snapshot_send_failure_after_first_iteration_exits_the_balancer_loop() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, receiver) = mpsc::channel::(1); + drop(receiver); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + pending::>(), + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + drop(pool_update_tx); + drop(holder_update_tx); + drop(desired_state_tx); + + Ok(()) +} + +#[tokio::test] +async fn desired_state_lagged_broadcast_continues_the_balancer_loop() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(1); + let (output, mut receiver) = mpsc::channel::(64); + + // Fill the broadcast buffer beyond capacity to trigger Lagged on first recv. + for _ in 0..3 { + desired_state_tx.send(BalancerDesiredState::default()).ok(); + } + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Ok(()) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + let mut observed_stopped = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerStopped) { + observed_stopped = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + drop(desired_state_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_stopped); + Ok(()) +} + +#[tokio::test] +async fn desired_state_update_replaces_current_state_in_the_balancer_loop() -> Result<()> { + let (pool_update_tx, pool_update_rx) = watch::channel(()); + let (holder_update_tx, holder_update_rx) = watch::channel(()); + let (desired_state_tx, desired_state_rx) = broadcast::channel::(16); + let (output, mut receiver) = mpsc::channel::(64); + + let driver = tokio::spawn(drive_balancer_loop_inner( + BalancerDesiredState::default(), + pool_update_rx, + holder_update_rx, + desired_state_rx, + async { Ok(()) }, + |_| Ok(RunningBalancerSnapshot::default()), + output, + )); + + // Wait for the first snapshot, then publish a desired-state update. + let first = tokio::time::timeout(BALANCER_LOOP_TIMEOUT, receiver.next()).await?; + assert!(matches!(first, Some(Message::RunningBalancer(_)))); + + desired_state_tx.send(BalancerDesiredState::default()).ok(); + + let mut observed_stopped = false; + let collect = async { + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerStopped) { + observed_stopped = true; + break; + } + } + }; + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, collect).await?; + + drop(pool_update_tx); + drop(holder_update_tx); + drop(desired_state_tx); + + tokio::time::timeout(BALANCER_LOOP_TIMEOUT, driver).await??; + + assert!(observed_stopped); + Ok(()) +} diff --git a/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs new file mode 100644 index 00000000..4bc73596 --- /dev/null +++ b/paddler_gui_tests/tests/running_an_agent_handles_snapshot_and_update_failures.rs @@ -0,0 +1,167 @@ +use std::future::pending; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler::produces_snapshot::ProducesSnapshot; +use paddler::subscribes_to_updates::SubscribesToUpdates; +use paddler_gui::drive_agent_stream_inner::drive_agent_stream_inner; +use paddler_gui::message::Message; +use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; +use tokio::sync::watch; + +const AGENT_STREAM_TIMEOUT: Duration = Duration::from_secs(5); + +struct FailingSnapshotSource; + +impl ProducesSnapshot for FailingSnapshotSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Err(anyhow!("simulated snapshot failure")) + } +} + +impl SubscribesToUpdates for FailingSnapshotSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + let (_, rx) = watch::channel(()); + rx + } +} + +struct ImmediatelyDisconnectedUpdateSource; + +impl ProducesSnapshot for ImmediatelyDisconnectedUpdateSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) + } +} + +impl SubscribesToUpdates for ImmediatelyDisconnectedUpdateSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + let (_, rx) = watch::channel(()); + rx + } +} + +#[tokio::test] +async fn snapshot_failure_exits_the_agent_stream_before_emitting_any_message() -> Result<()> { + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_agent_stream_inner( + Arc::new(FailingSnapshotSource), + pending::>(), + output, + )); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + assert!(receiver.next().await.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn update_channel_disconnection_exits_the_agent_stream_after_the_first_snapshot() -> Result<()> +{ + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_agent_stream_inner( + Arc::new(ImmediatelyDisconnectedUpdateSource), + pending::>(), + output, + )); + + let first_message = tokio::time::timeout(AGENT_STREAM_TIMEOUT, receiver.next()).await?; + + assert!(matches!(first_message, Some(Message::AgentRunning(_)))); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} + +struct StaticUpdateSource(watch::Receiver<()>); + +impl ProducesSnapshot for StaticUpdateSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) + } +} + +impl SubscribesToUpdates for StaticUpdateSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + self.0.clone() + } +} + +#[tokio::test] +async fn agent_runner_completion_with_error_emits_agent_failed_message() -> Result<()> { + let (update_tx, update_rx) = watch::channel(()); + let source = Arc::new(StaticUpdateSource(update_rx)); + let (output, mut receiver) = mpsc::channel::(8); + + let completion = async { Err(anyhow!("agent runner exited unexpectedly")) }; + + let driver = tokio::spawn(drive_agent_stream_inner(source, completion, output)); + + // Hold the sender alive long enough to ensure the completion future resolves first. + let collected = async { + let mut observed_failed = false; + while let Some(message) = receiver.next().await { + if matches!(message, Message::AgentFailed(_)) { + observed_failed = true; + break; + } + } + observed_failed + }; + + let observed_failed = tokio::time::timeout(AGENT_STREAM_TIMEOUT, collected).await?; + drop(update_tx); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + assert!(observed_failed); + Ok(()) +} + +struct StaticSource; + +impl ProducesSnapshot for StaticSource { + type Snapshot = SlotAggregatedStatusSnapshot; + + fn make_snapshot(&self) -> anyhow::Result { + Ok(SlotAggregatedStatusSnapshot::default()) + } +} + +impl SubscribesToUpdates for StaticSource { + fn subscribe_to_updates(&self) -> watch::Receiver<()> { + let (_, rx) = watch::channel(()); + rx + } +} + +#[tokio::test] +async fn snapshot_send_failure_after_first_iteration_exits_the_agent_stream() -> Result<()> { + let (output, receiver) = mpsc::channel::(8); + drop(receiver); + + let driver = tokio::spawn(drive_agent_stream_inner( + Arc::new(StaticSource), + pending::>(), + output, + )); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} diff --git a/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs b/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs new file mode 100644 index 00000000..c63933e6 --- /dev/null +++ b/paddler_gui_tests/tests/running_an_agent_stops_cleanly_when_the_user_disconnects.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler_bootstrap::agent_runner::AgentRunnerParams; +use paddler_gui::drive_agent_stream::drive_agent_stream; +use paddler_gui::message::Message; +use paddler_gui_tests::bind_ephemeral_socket::bind_ephemeral_socket; +use tokio_util::sync::CancellationToken; + +const AGENT_STREAM_TIMEOUT: Duration = Duration::from_secs(10); + +fn ephemeral_management_address() -> Result { + Ok(bind_ephemeral_socket()?.to_string()) +} + +#[tokio::test] +#[expect( + clippy::needless_continue, + reason = "explicit continue documents the no-op branch for readability" +)] +async fn cancellation_token_makes_the_agent_send_a_stopped_message_and_finish() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = AgentRunnerParams { + agent_name: Some("test-agent".to_owned()), + cancellation_token: cancellation_token.clone(), + management_address: ephemeral_management_address()?, + slots: 1, + }; + + let (output, mut receiver) = mpsc::channel::(8); + + let driver = tokio::spawn(drive_agent_stream(params, output)); + + cancellation_token.cancel(); + + let mut observed_stopped = false; + + let collect = async { + while let Some(message) = receiver.next().await { + match message { + Message::AgentStopped => { + observed_stopped = true; + break; + } + Message::AgentFailed(_) => break, + _ => continue, + } + } + }; + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, collect).await?; + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + if !observed_stopped { + bail!("expected AgentStopped to be observed before the stream finished"); + } + + Ok(()) +} + +#[tokio::test] +async fn when_the_ui_goes_away_the_agent_stream_exits_without_panicking() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = AgentRunnerParams { + agent_name: None, + cancellation_token: cancellation_token.clone(), + management_address: ephemeral_management_address()?, + slots: 1, + }; + + let (output, receiver) = mpsc::channel::(8); + drop(receiver); + + let driver = tokio::spawn(drive_agent_stream(params, output)); + + cancellation_token.cancel(); + + tokio::time::timeout(AGENT_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} diff --git a/paddler_gui_tests/tests/showing_an_agents_connection_status_to_the_user.rs b/paddler_gui_tests/tests/showing_an_agents_connection_status_to_the_user.rs new file mode 100644 index 00000000..b28e3c89 --- /dev/null +++ b/paddler_gui_tests/tests/showing_an_agents_connection_status_to_the_user.rs @@ -0,0 +1,55 @@ +use std::collections::BTreeSet; + +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::agent_running_data::AgentRunningData; +use paddler_gui::ui::view_agent_running::view_agent_running; +use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + +fn data_with_connected(connected: bool) -> AgentRunningData { + AgentRunningData { + balancer_address: "127.0.0.1:8060".to_owned(), + connected, + 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: None, + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + }, + } +} + +#[test] +fn the_status_text_says_connected_when_the_agent_has_joined_the_cluster() -> Result<()> { + let data = data_with_connected(true); + let mut simulator = simulator(view_agent_running(&data)); + + if simulator + .find("Connected to the cluster at 127.0.0.1:8060") + .is_err() + { + bail!("expected connected status to render with the balancer address"); + } + Ok(()) +} + +#[test] +fn the_status_text_says_connecting_before_the_agent_has_joined() -> Result<()> { + let data = data_with_connected(false); + let mut simulator = simulator(view_agent_running(&data)); + + if simulator.find("Connecting to the cluster...").is_err() { + bail!("expected connecting status to render before connection completes"); + } + Ok(()) +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs new file mode 100644 index 00000000..1ff0a1a4 --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_collects_and_submits_cluster_details.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::address_field::AddressField; +use paddler_gui::start_balancer_form_data::StartBalancerFormData; +use paddler_gui::start_balancer_form_handler::Message; +use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; + +#[expect( + clippy::missing_const_for_fn, + reason = "non-const helper keeps the surface uniform with other fixture helpers" +)] +fn empty_form() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: AddressField::Empty, + inference_address: AddressField::Empty, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: AddressField::Empty, + web_admin_panel_address_placeholder: String::new(), + } +} + +#[test] +fn clicking_start_cluster_sends_the_confirm_message_when_the_form_is_idle() -> Result<()> { + let data = empty_form(); + let mut simulator = simulator(view_start_balancer_form(&data)); + + simulator.click("Start cluster")?; + + match simulator.into_messages().next() { + Some(Message::Confirm) => Ok(()), + other => bail!("expected Confirm message, got {other:?}"), + } +} + +#[test] +fn the_start_button_does_not_react_to_clicks_while_the_balancer_is_already_starting() -> Result<()> +{ + let data = StartBalancerFormData { + starting: true, + ..empty_form() + }; + let mut simulator = simulator(view_start_balancer_form(&data)); + + // The button label changes to "Starting..." and has no on_press. + let click_result = simulator.click("Starting..."); + + if click_result.is_err() { + bail!("expected a Starting... button to be present in the tree"); + } + + if simulator.into_messages().next().is_some() { + bail!("expected no message when the inert Starting button is clicked"); + } + + Ok(()) +} + +#[test] +fn clicking_cancel_sends_the_cancel_message() -> Result<()> { + let data = empty_form(); + let mut simulator = simulator(view_start_balancer_form(&data)); + + simulator.click("Cancel")?; + + match simulator.into_messages().next() { + Some(Message::Cancel) => Ok(()), + other => bail!("expected Cancel message, got {other:?}"), + } +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs new file mode 100644 index 00000000..913f07f1 --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_lets_the_user_choose_a_model_or_add_one_later.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::address_field::AddressField; +use paddler_gui::start_balancer_form_data::StartBalancerFormData; +use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; + +#[expect( + clippy::missing_const_for_fn, + reason = "non-const helper keeps the surface uniform with other fixture helpers" +)] +fn empty_form() -> StartBalancerFormData { + StartBalancerFormData { + add_model_later: false, + balancer_address: AddressField::Empty, + inference_address: AddressField::Empty, + model_error: None, + selected_model: None, + starting: false, + web_admin_panel_address: AddressField::Empty, + web_admin_panel_address_placeholder: String::new(), + } +} + +#[test] +fn with_add_model_later_unchecked_the_form_shows_a_model_picker() -> Result<()> { + let data = empty_form(); + let mut simulator = simulator(view_start_balancer_form(&data)); + + // The "Model" label is always present; the absence of "Model will be added later" placeholder + // confirms we're rendering the pick_list branch. + if simulator.find("Model").is_err() { + bail!("expected the Model label to render"); + } + if simulator.find("Model will be added later").is_ok() { + bail!("did not expect the add-later placeholder while pick_list branch is rendered"); + } + + Ok(()) +} + +#[test] +fn with_add_model_later_checked_the_form_shows_a_disabled_placeholder_input() -> Result<()> { + let data = StartBalancerFormData { + add_model_later: true, + ..empty_form() + }; + let mut simulator = simulator(view_start_balancer_form(&data)); + + if simulator.find("Add a model later").is_err() { + bail!("expected the add-model-later checkbox label to render"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs b/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs new file mode 100644 index 00000000..01b16f06 --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_shows_validation_errors_to_the_user.rs @@ -0,0 +1,43 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::address_field::AddressField; +use paddler_gui::start_balancer_form_data::StartBalancerFormData; +use paddler_gui::ui::view_start_balancer_form::view_start_balancer_form; + +#[test] +fn each_address_field_renders_its_own_error_below_the_input_when_set() -> Result<()> { + let data = StartBalancerFormData { + add_model_later: false, + balancer_address: AddressField::Invalid { + raw: String::new(), + error: "Address is required.".to_owned(), + }, + inference_address: AddressField::Invalid { + raw: String::new(), + error: "Invalid inference address.".to_owned(), + }, + model_error: Some("Please select a model.".to_owned()), + selected_model: None, + starting: false, + web_admin_panel_address: AddressField::Invalid { + raw: String::new(), + error: "Invalid web admin address.".to_owned(), + }, + web_admin_panel_address_placeholder: String::new(), + }; + let mut simulator = simulator(view_start_balancer_form(&data)); + + for expected in [ + "Address is required.", + "Invalid inference address.", + "Please select a model.", + "Invalid web admin address.", + ] { + if simulator.find(expected).is_err() { + bail!("expected to find error text {expected:?} in the rendered tree"); + } + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs new file mode 100644 index 00000000..6a3d88df --- /dev/null +++ b/paddler_gui_tests/tests/starting_a_balancer_succeeds_or_fails_visibly.rs @@ -0,0 +1,183 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use iced::futures::StreamExt as _; +use iced::futures::channel::mpsc; +use paddler_gui::drive_balancer_stream::drive_balancer_stream; +use paddler_gui::message::Message; +use paddler_gui::started_balancer_display::StartedBalancerDisplay; +use paddler_gui_tests::bind_addresses::BindAddresses; +use paddler_gui_tests::bind_ephemeral_socket::bind_ephemeral_socket; +use paddler_gui_tests::make_balancer_runner_params::make_balancer_runner_params; +use tokio_util::sync::CancellationToken; + +const BALANCER_STREAM_TIMEOUT: Duration = Duration::from_secs(30); + +const INVALID_BIND_ADDR: &str = "192.0.2.1:1"; + +#[tokio::test] +async fn an_invalid_bind_address_tells_the_user_the_balancer_failed_to_start() -> Result<()> { + let invalid: SocketAddr = INVALID_BIND_ADDR.parse()?; + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: invalid, + management_addr: invalid, + }, + cancellation_token, + ); + + let (output, mut receiver) = mpsc::channel::(8); + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: INVALID_BIND_ADDR.to_owned(), + web_admin_panel_address: None, + }, + output, + )); + + let collect = async { + let mut observed_failed = false; + while let Some(message) = receiver.next().await { + if matches!(message, Message::BalancerFailed(_)) { + observed_failed = true; + break; + } + } + observed_failed + }; + + let observed_failed = tokio::time::timeout(BALANCER_STREAM_TIMEOUT, collect).await?; + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + if !observed_failed { + bail!("expected BalancerFailed message for an invalid bind address"); + } + + Ok(()) +} + +#[tokio::test] +#[expect( + clippy::needless_continue, + reason = "explicit continue documents the no-op branch for readability" +)] +async fn a_running_balancer_reports_started_and_then_stopped_when_the_user_cancels() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: bind_ephemeral_socket()?, + management_addr: bind_ephemeral_socket()?, + }, + cancellation_token.clone(), + ); + + let (output, mut receiver) = mpsc::channel::(16); + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: "ignored-during-test".to_owned(), + web_admin_panel_address: None, + }, + output, + )); + + let mut observed_started = false; + let mut observed_stopped = false; + + let collect = async { + while let Some(message) = receiver.next().await { + match message { + Message::BalancerStarted(_) => { + observed_started = true; + cancellation_token.cancel(); + } + Message::BalancerStopped => { + observed_stopped = true; + break; + } + Message::BalancerFailed(error) => { + bail!("unexpected BalancerFailed during happy path: {error}"); + } + _ => continue, + } + } + Ok::<(), anyhow::Error>(()) + }; + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, collect).await??; + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + if !observed_started { + bail!("expected BalancerStarted to be observed"); + } + if !observed_stopped { + bail!("expected BalancerStopped to be observed after cancellation"); + } + + Ok(()) +} + +#[tokio::test] +async fn when_the_ui_goes_away_the_balancer_stream_exits_without_panicking() -> Result<()> { + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: bind_ephemeral_socket()?, + management_addr: bind_ephemeral_socket()?, + }, + cancellation_token.clone(), + ); + + let (output, receiver) = mpsc::channel::(1); + drop(receiver); + + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: "ignored-during-test".to_owned(), + web_admin_panel_address: None, + }, + output, + )); + + cancellation_token.cancel(); + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} + +#[tokio::test] +async fn a_failed_start_with_a_disconnected_ui_logs_the_error_and_exits_cleanly() -> Result<()> { + let invalid: SocketAddr = INVALID_BIND_ADDR.parse()?; + let cancellation_token = CancellationToken::new(); + let params = make_balancer_runner_params( + BindAddresses { + inference_addr: invalid, + management_addr: invalid, + }, + cancellation_token, + ); + + let (output, receiver) = mpsc::channel::(1); + drop(receiver); + + let driver = tokio::spawn(drive_balancer_stream( + params, + StartedBalancerDisplay { + balancer_address: INVALID_BIND_ADDR.to_owned(), + web_admin_panel_address: None, + }, + output, + )); + + tokio::time::timeout(BALANCER_STREAM_TIMEOUT, driver).await??; + + Ok(()) +} diff --git a/paddler_gui_tests/tests/stopping_a_running_balancer.rs b/paddler_gui_tests/tests/stopping_a_running_balancer.rs new file mode 100644 index 00000000..0b0f7025 --- /dev/null +++ b/paddler_gui_tests/tests/stopping_a_running_balancer.rs @@ -0,0 +1,44 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_handler::Message; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; + +fn data_with_stopping(stopping: bool) -> RunningBalancerData { + RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping, + web_admin_panel_address: None, + } +} + +#[test] +fn clicking_stop_cluster_sends_the_stop_message_when_the_balancer_is_running() -> Result<()> { + let data = data_with_stopping(false); + let mut simulator = simulator(view_running_balancer(&data)); + + simulator.click("Stop cluster")?; + + match simulator.into_messages().next() { + Some(Message::Stop) => Ok(()), + other => bail!("expected Stop message, got {other:?}"), + } +} + +#[test] +fn the_stop_button_does_not_react_to_clicks_while_the_balancer_is_already_stopping() -> Result<()> { + let data = data_with_stopping(true); + let mut simulator = simulator(view_running_balancer(&data)); + + // The inert button has text "Stopping...". + simulator.click("Stopping...")?; + + if simulator.into_messages().next().is_some() { + bail!("expected no message when the inert Stopping button is clicked"); + } + + Ok(()) +} diff --git a/paddler_gui_tests/tests/viewing_the_list_of_agents_connected_to_a_running_balancer.rs b/paddler_gui_tests/tests/viewing_the_list_of_agents_connected_to_a_running_balancer.rs new file mode 100644 index 00000000..f8efc2b4 --- /dev/null +++ b/paddler_gui_tests/tests/viewing_the_list_of_agents_connected_to_a_running_balancer.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use anyhow::bail; +use iced_test::simulator; +use paddler_gui::running_balancer_data::RunningBalancerData; +use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; +use paddler_gui::ui::view_running_balancer::view_running_balancer; +use paddler_gui_tests::make_agent_controller_snapshot::AgentSnapshotFixture; +use paddler_gui_tests::make_agent_controller_snapshot::make_agent_controller_snapshot; + +#[test] +fn when_no_agents_are_connected_the_screen_shows_a_waiting_message() -> Result<()> { + let data = RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot: RunningBalancerSnapshot::default(), + stopping: false, + web_admin_panel_address: None, + }; + let mut simulator = simulator(view_running_balancer(&data)); + + if simulator.find("Waiting for agents to connect...").is_err() { + bail!("expected the waiting message to render when agent list is empty"); + } + + Ok(()) +} + +#[test] +fn each_connected_agent_appears_as_its_own_card() -> Result<()> { + let agent = make_agent_controller_snapshot(AgentSnapshotFixture { + name: Some("primary-agent".to_owned()), + model_path: Some("/models/model.gguf".to_owned()), + ..AgentSnapshotFixture::default() + }); + + let snapshot = RunningBalancerSnapshot { + agent_snapshots: vec![agent], + ..RunningBalancerSnapshot::default() + }; + + let data = RunningBalancerData { + balancer_address: "127.0.0.1:8060".to_owned(), + snapshot, + stopping: false, + web_admin_panel_address: None, + }; + let mut simulator = simulator(view_running_balancer(&data)); + + if simulator.find("primary-agent").is_err() { + bail!("expected the agent card to render the agent name"); + } + + Ok(()) +} diff --git a/paddler_ports/Cargo.toml b/paddler_ports/Cargo.toml new file mode 100644 index 00000000..e20e6bbd --- /dev/null +++ b/paddler_ports/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "paddler_ports" +authors.workspace = true +description = "Shared port reservation and validation utilities" +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/paddler_ports/src/bind_ephemeral_port.rs b/paddler_ports/src/bind_ephemeral_port.rs new file mode 100644 index 00000000..f0f2d4cc --- /dev/null +++ b/paddler_ports/src/bind_ephemeral_port.rs @@ -0,0 +1,34 @@ +use std::net::TcpListener; + +use anyhow::Context as _; +use anyhow::Result; + +use crate::bound_port::BoundPort; + +pub fn bind_ephemeral_port() -> Result { + let listener = + TcpListener::bind("127.0.0.1:0").context("failed to bind ephemeral loopback port")?; + + let socket_addr = listener + .local_addr() + .context("failed to read local address of bound listener")?; + + Ok(BoundPort { + socket_addr, + listener, + }) +} + +#[cfg(test)] +mod tests { + use super::bind_ephemeral_port; + + #[test] + fn binding_returns_a_listener_with_a_loopback_address() -> anyhow::Result<()> { + let bound = bind_ephemeral_port()?; + + assert!(bound.socket_addr.ip().is_loopback()); + assert!(bound.socket_addr.port() > 0); + Ok(()) + } +} diff --git a/paddler_ports/src/bound_port.rs b/paddler_ports/src/bound_port.rs new file mode 100644 index 00000000..be16402a --- /dev/null +++ b/paddler_ports/src/bound_port.rs @@ -0,0 +1,7 @@ +use std::net::SocketAddr; +use std::net::TcpListener; + +pub struct BoundPort { + pub socket_addr: SocketAddr, + pub listener: TcpListener, +} diff --git a/paddler_ports/src/check_optional_port.rs b/paddler_ports/src/check_optional_port.rs new file mode 100644 index 00000000..eb3de827 --- /dev/null +++ b/paddler_ports/src/check_optional_port.rs @@ -0,0 +1,44 @@ +use crate::bound_port::BoundPort; +use crate::check_port::check_port; +use crate::port_check_error::PortCheckError; + +pub fn check_optional_port(raw: &str) -> Result, PortCheckError> { + if raw.is_empty() { + return Ok(None); + } + + check_port(raw).map(Some) +} + +#[cfg(test)] +mod tests { + use super::PortCheckError; + use super::check_optional_port; + + #[test] + fn empty_input_resolves_to_no_bound_port() -> anyhow::Result<()> { + let result = check_optional_port("")?; + + assert!(result.is_none()); + Ok(()) + } + + #[test] + fn unparseable_input_propagates_the_check_port_error() { + let result = check_optional_port("not-a-socket-addr"); + + assert!(matches!(result, Err(PortCheckError::Unparseable { .. }))); + } + + #[test] + fn parseable_free_port_resolves_to_some_bound_port() -> anyhow::Result<()> { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + drop(listener); + + let result = check_optional_port(&address.to_string())?; + + assert!(result.is_some()); + Ok(()) + } +} diff --git a/paddler_ports/src/check_port.rs b/paddler_ports/src/check_port.rs new file mode 100644 index 00000000..457d9d88 --- /dev/null +++ b/paddler_ports/src/check_port.rs @@ -0,0 +1,83 @@ +use std::io; +use std::net::SocketAddr; +use std::net::TcpListener; + +use crate::bound_port::BoundPort; +use crate::port_check_error::PortCheckError; + +pub fn check_port(raw: &str) -> Result { + if raw.is_empty() { + return Err(PortCheckError::Empty); + } + + let socket_addr: SocketAddr = raw.parse()?; + + match TcpListener::bind(socket_addr) { + Ok(listener) => Ok(BoundPort { + socket_addr, + listener, + }), + Err(error) if error.kind() == io::ErrorKind::AddrInUse => { + Err(PortCheckError::InUse { socket_addr }) + } + Err(error) => Err(PortCheckError::BindFailed { + socket_addr, + source: error, + }), + } +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use super::PortCheckError; + use super::check_port; + + #[test] + fn empty_input_reports_empty_error() { + let result = check_port(""); + + assert!(matches!(result, Err(PortCheckError::Empty))); + } + + #[test] + fn unparseable_input_reports_unparseable_error() { + let result = check_port("not-a-socket-addr"); + + assert!(matches!(result, Err(PortCheckError::Unparseable { .. }))); + } + + #[test] + fn input_pointing_at_an_already_bound_port_reports_in_use() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + + let result = check_port(&address.to_string()); + + assert!(matches!(result, Err(PortCheckError::InUse { .. }))); + Ok(()) + } + + #[test] + fn input_pointing_at_an_unassigned_address_reports_bind_failed() { + let result = check_port("192.0.2.1:0"); + + assert!(matches!(result, Err(PortCheckError::BindFailed { .. }))); + } + + #[test] + fn input_pointing_at_a_free_port_returns_a_bound_listener() -> anyhow::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + drop(listener); + + let bound = check_port(&address.to_string())?; + + if bound.socket_addr.port() != address.port() { + anyhow::bail!("expected port {} to be preserved", address.port()); + } + + Ok(()) + } +} diff --git a/paddler_ports/src/lib.rs b/paddler_ports/src/lib.rs new file mode 100644 index 00000000..7d59d64a --- /dev/null +++ b/paddler_ports/src/lib.rs @@ -0,0 +1,5 @@ +pub mod bind_ephemeral_port; +pub mod bound_port; +pub mod check_optional_port; +pub mod check_port; +pub mod port_check_error; diff --git a/paddler_ports/src/port_check_error.rs b/paddler_ports/src/port_check_error.rs new file mode 100644 index 00000000..7d996622 --- /dev/null +++ b/paddler_ports/src/port_check_error.rs @@ -0,0 +1,30 @@ +use std::io; +use std::net::AddrParseError; +use std::net::SocketAddr; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PortCheckError { + #[error("Address is required.")] + Empty, + #[error("Invalid address ({source}), expected format: IP:port")] + Unparseable { + #[from] + source: AddrParseError, + }, + #[error("Port {} is already in use", socket_addr.port())] + InUse { socket_addr: SocketAddr }, + #[error("Cannot bind to {socket_addr}: {source}")] + BindFailed { + socket_addr: SocketAddr, + source: io::Error, + }, +} + +impl PortCheckError { + #[must_use] + pub fn user_facing_message(&self) -> String { + self.to_string() + } +} diff --git a/paddler_tests/Cargo.toml b/paddler_tests/Cargo.toml index cd927f8f..9ed23699 100644 --- a/paddler_tests/Cargo.toml +++ b/paddler_tests/Cargo.toml @@ -28,6 +28,7 @@ nix = { workspace = true } paddler = { workspace = true } paddler_bootstrap = { workspace = true } paddler_client = { workspace = true } +paddler_ports = { workspace = true } paddler_types = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/paddler_tests/src/balancer_addresses.rs b/paddler_tests/src/balancer_addresses.rs index 9831c56f..862d9d91 100644 --- a/paddler_tests/src/balancer_addresses.rs +++ b/paddler_tests/src/balancer_addresses.rs @@ -3,43 +3,34 @@ use std::net::TcpListener; use anyhow::Context as _; use anyhow::Result; +use paddler_ports::bind_ephemeral_port::bind_ephemeral_port; use url::Url; pub struct BalancerAddresses { pub compat_openai: SocketAddr, + pub compat_openai_listener: Option, pub inference: SocketAddr, + pub inference_listener: Option, pub management: SocketAddr, + pub management_listener: Option, } impl BalancerAddresses { pub fn pick() -> Result { - let inference_listener = - TcpListener::bind("127.0.0.1:0").context("failed to reserve inference service port")?; - let management_listener = TcpListener::bind("127.0.0.1:0") - .context("failed to reserve management service port")?; - let compat_openai_listener = TcpListener::bind("127.0.0.1:0") - .context("failed to reserve OpenAI-compat service port")?; - - let inference = inference_listener - .local_addr() - .context("failed to read inference listener local address")?; - let management = management_listener - .local_addr() - .context("failed to read management listener local address")?; - let compat_openai = compat_openai_listener - .local_addr() - .context("failed to read OpenAI-compat listener local address")?; - - drop(( - inference_listener, - management_listener, - compat_openai_listener, - )); + let inference_bound = + bind_ephemeral_port().context("failed to reserve inference service port")?; + let management_bound = + bind_ephemeral_port().context("failed to reserve management service port")?; + let compat_openai_bound = + bind_ephemeral_port().context("failed to reserve OpenAI-compat service port")?; Ok(Self { - compat_openai, - inference, - management, + compat_openai: compat_openai_bound.socket_addr, + compat_openai_listener: Some(compat_openai_bound.listener), + inference: inference_bound.socket_addr, + inference_listener: Some(inference_bound.listener), + management: management_bound.socket_addr, + management_listener: Some(management_bound.listener), }) } diff --git a/paddler_tests/src/start_in_process_cluster.rs b/paddler_tests/src/start_in_process_cluster.rs index 6e679c10..19536b03 100644 --- a/paddler_tests/src/start_in_process_cluster.rs +++ b/paddler_tests/src/start_in_process_cluster.rs @@ -32,21 +32,28 @@ pub async fn start_in_process_cluster( wait_for_slots_ready, }: InProcessClusterParams, ) -> Result { - let addresses = BalancerAddresses::pick()?; + let mut addresses = BalancerAddresses::pick()?; let cancel_token = CancellationToken::new(); + let inference_listener = addresses.inference_listener.take(); + let management_listener = addresses.management_listener.take(); + let openai_listener = addresses.compat_openai_listener.take(); + let balancer = BalancerRunner::start(BalancerRunnerParams { buffered_request_timeout, + inference_listener, inference_service_configuration: InferenceServiceConfiguration { addr: addresses.inference, cors_allowed_hosts: inference_cors_allowed_hosts, inference_item_timeout, }, + management_listener, management_service_configuration: ManagementServiceConfiguration { addr: addresses.management, cors_allowed_hosts: management_cors_allowed_hosts, }, max_buffered_requests, + openai_listener, openai_service_configuration: Some(OpenAIServiceConfiguration { addr: addresses.compat_openai, }), @@ -55,6 +62,8 @@ pub async fn start_in_process_cluster( statsd_prefix: "paddler_tests_".to_owned(), statsd_service_configuration: None, #[cfg(feature = "web_admin_panel")] + web_admin_panel_listener: None, + #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: None, }) .await diff --git a/paddler_tests/src/start_subprocess_cluster.rs b/paddler_tests/src/start_subprocess_cluster.rs index b676e8c7..936435f4 100644 --- a/paddler_tests/src/start_subprocess_cluster.rs +++ b/paddler_tests/src/start_subprocess_cluster.rs @@ -30,7 +30,7 @@ pub async fn start_subprocess_cluster( wait_for_slots_ready, }: SubprocessClusterParams, ) -> Result { - let addresses = BalancerAddresses::pick()?; + let mut addresses = BalancerAddresses::pick()?; let mut balancer_command = paddler_command(); @@ -65,6 +65,13 @@ pub async fn start_subprocess_cluster( .arg(allowed_host); } + // Release the reserved listeners so the subprocess can bind to the same addresses. + // The reservation prevented another concurrent test from claiming the same port; once + // the subprocess is spawned with those addresses on its CLI, it does its own bind. + addresses.inference_listener = None; + addresses.management_listener = None; + addresses.compat_openai_listener = None; + let balancer = balancer_command .spawn() .context("failed to spawn paddler balancer subprocess")?; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index c0b9b8ec..8a6cc6e8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "1.93.0" -components = ["clippy", "rust-analyzer", "rustfmt"] +components = ["clippy", "llvm-tools-preview", "rust-analyzer", "rustfmt"]