From 4689befd9151e5cd529170bb324b2162f3bd517d Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 11:06:52 +0100 Subject: [PATCH 1/9] fix join on old hyper lite --- browser/src/participant/inner_lite.rs | 1 + browser/src/participant/mod.rs | 1 + browser/src/participant/selectors.rs | 10 +++++----- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/browser/src/participant/inner_lite.rs b/browser/src/participant/inner_lite.rs index bd5b163..90a3d5f 100644 --- a/browser/src/participant/inner_lite.rs +++ b/browser/src/participant/inner_lite.rs @@ -60,6 +60,7 @@ impl ParticipantInnerLite { sender: UnboundedSender, state: watch::Sender, ) -> Result<()> { + debug!("Starting participant inner lite..."); let (mut browser, handler) = create_browser(&BrowserConfig::from(&participant_config)).await?; let browser_event_task_handle = Self::drive_browser_events(&participant_config.username, handler); let page = Self::create_page_retry(&participant_config, &mut browser).await?; diff --git a/browser/src/participant/mod.rs b/browser/src/participant/mod.rs index 60bdac5..5ae5b00 100644 --- a/browser/src/participant/mod.rs +++ b/browser/src/participant/mod.rs @@ -205,6 +205,7 @@ impl Participant { error!("Was not able to send ParticipantMessage::Close message") } } + pub fn join(&self) { let state = self.state.borrow(); if !state.running { diff --git a/browser/src/participant/selectors.rs b/browser/src/participant/selectors.rs index 838636e..fa2a761 100644 --- a/browser/src/participant/selectors.rs +++ b/browser/src/participant/selectors.rs @@ -22,17 +22,17 @@ pub mod classic { /// Selectors for UI elements in the lite frontend pub mod lite { /// Selector for the join button - pub const JOIN_BUTTON: &str = r#"button[data-test-id="join-button"]:not([disabled])"#; + pub const JOIN_BUTTON: &str = r#"button[data-testid="join-button"]:not([disabled])"#; /// Selector for the leave button - pub const LEAVE_BUTTON: &str = r#"[data-test-id="trigger-leave-call"]"#; + pub const LEAVE_BUTTON: &str = r#"[data-testid="trigger-leave-call"]"#; /// Selector for the mute/unmute button - pub const MUTE_BUTTON: &str = r#"[data-test-id="toggle-audio"]"#; + pub const MUTE_BUTTON: &str = r#"[data-testid="toggle-audio"]"#; /// Selector for the video on/off button - pub const VIDEO_BUTTON: &str = r#"[data-test-id="toggle-video"]"#; + pub const VIDEO_BUTTON: &str = r#"[data-testid="toggle-video"]"#; /// Selector for the screen share button - pub const SCREEN_SHARE_BUTTON: &str = r#"[data-test-id="toggle-screen-share"]"#; + pub const SCREEN_SHARE_BUTTON: &str = r#"[data-testid="toggle-screen-share"]"#; } From d82532db8b87378a9a5e12d8cffc48d8fbc4dee0 Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:06:09 +0100 Subject: [PATCH 2/9] comment --- browser/src/participant/inner.rs | 2 ++ browser/src/participant/inner_lite.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/browser/src/participant/inner.rs b/browser/src/participant/inner.rs index e98b080..6c08007 100644 --- a/browser/src/participant/inner.rs +++ b/browser/src/participant/inner.rs @@ -1,3 +1,5 @@ +//! Browser interaction for the hyper.video ("hyper core") frontend. + use super::{ commands::{ get_force_webrtc, diff --git a/browser/src/participant/inner_lite.rs b/browser/src/participant/inner_lite.rs index 90a3d5f..f848404 100644 --- a/browser/src/participant/inner_lite.rs +++ b/browser/src/participant/inner_lite.rs @@ -1,3 +1,5 @@ +//! Browser interaction for the hyper-lite ("hyper lite") frontend. + use super::{ messages::ParticipantMessage, ParticipantState, From 29eef9425a38ca2ad22921e397000a63ca71a711 Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:08:02 +0100 Subject: [PATCH 3/9] remove orchestrator workspace --- CLAUDE.md | 34 +---- Cargo.lock | 20 --- Cargo.toml | 2 - README.md | 1 - justfile | 12 -- nix/packages.nix | 8 -- orchestrator/Cargo.toml | 26 ---- orchestrator/example-config.yml | 110 ----------------- orchestrator/src/config.rs | 212 -------------------------------- orchestrator/src/lib.rs | 8 -- orchestrator/src/main.rs | 38 ------ orchestrator/src/runner.rs | 143 --------------------- 12 files changed, 2 insertions(+), 612 deletions(-) delete mode 100644 orchestrator/Cargo.toml delete mode 100644 orchestrator/example-config.yml delete mode 100644 orchestrator/src/config.rs delete mode 100644 orchestrator/src/lib.rs delete mode 100644 orchestrator/src/main.rs delete mode 100644 orchestrator/src/runner.rs diff --git a/CLAUDE.md b/CLAUDE.md index 45e8ade..4edf7bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,6 @@ This is a **Hyper.Video Browser Client Simulator** - a Rust-based testing framew The project is a Cargo workspace with multiple binaries for different use cases: - **client-simulator** (main TUI): Interactive terminal UI for manual testing - **client-simulator-http**: HTTP/WebSocket server for remote control -- **client-simulator-orchestrator**: Batch orchestration of multiple simulated clients - **client-simulator-stats-gatherer**: Analytics collection from ClickHouse ## Build & Development Commands @@ -28,10 +27,6 @@ just dev # dev mode (faster compilation) just serve # release mode just serve-dev # dev mode -# Run orchestrator (batch mode) -just orchestrator --config path/to/config.yaml -just orchestrator-dev --config path/to/config.yaml - # Run stats gatherer just stats-gatherer --clickhouse-url http://localhost:8123 --space-url https://... ``` @@ -66,7 +61,6 @@ The project includes Nix flake support for reproducible builds: # Build via Nix nix build .#client-simulator nix build .#client-simulator-http -nix build .#client-simulator-orchestrator nix build .#client-simulator-stats-gatherer # Run via Nix @@ -93,7 +87,6 @@ client-simulator/ # Main binary (TUI) ├── config/ # Configuration management ├── tui/ # Terminal UI (ratatui-based) ├── http/ # HTTP/WebSocket API server -├── orchestrator/ # Batch orchestration └── stats-gatherer/ # ClickHouse analytics ``` @@ -140,29 +133,7 @@ Exposes participants via REST API and WebSocket: - Stream logs over WebSocket - Useful for CI/CD integration -#### 5. Orchestrator Mode (`orchestrator/`) - -Batch mode for large-scale testing: -- YAML-based configuration with participant specs -- Distributes participants across multiple HTTP workers (round-robin) -- Supports participant-specific settings and staggered join times -- Configuration validation before execution - -Example orchestrator config structure: -```yaml -session_url: https://hyper.video/space/SPACE_ID -workers: - - url: http://worker1:8081 - - url: http://worker2:8081 -defaults: - headless: true - audio_enabled: true -participants_specs: - - username: "user-1" - wait_to_join_seconds: 5 -``` - -#### 6. Stats Gatherer (`stats-gatherer/`) +#### 5. Stats Gatherer (`stats-gatherer/`) Connects directly to ClickHouse to collect analytics: - Server-level metrics @@ -229,7 +200,7 @@ cargo nextest run -p client-simulator-browser 1. Add variant to `ParticipantMessage` enum in `browser/src/participant/messages.rs` 2. Handle in `ParticipantInner::run()` message loop (`browser/src/participant/inner.rs`) 3. Add public method to `Participant` struct in `browser/src/participant/mod.rs` -4. Expose in TUI/HTTP/orchestrator as needed +4. Expose in TUI/HTTP as needed ### Debugging Browser Issues @@ -277,7 +248,6 @@ Participants can use different WebRTC transport modes: - Noise suppression levels: `Off`, `Low`, `Medium`, `High` - Webcam resolutions: Multiple presets from 180p to 1080p -- Configurable per-participant in orchestrator mode ## Important File Locations diff --git a/Cargo.lock b/Cargo.lock index 1af9652..c4adf06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,26 +659,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "client-simulator-orchestrator" -version = "0.1.0" -dependencies = [ - "clap", - "client-simulator-browser", - "client-simulator-config", - "color-eyre", - "eyre", - "futures", - "serde", - "serde_yml", - "tokio", - "tokio-tungstenite 0.26.2", - "tracing", - "tracing-error", - "tracing-subscriber", - "url", -] - [[package]] name = "client-simulator-stats-gatherer" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 308faf3..15a32f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ members = [ "browser", "config", "http", - "orchestrator", "stats-gatherer", "tui", ] @@ -62,7 +61,6 @@ which = "8.0.0" client-simulator-browser = { path = "./browser" } client-simulator-config = { path = "./config" } -client-simulator-orchestrator = { path = "./orchestrator" } client-simulator-tui = { path = "./tui" } [workspace.lints.clippy] diff --git a/README.md b/README.md index e7acb57..5ff6e58 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,4 @@ This is a **Hyper.Video Browser Client Simulator** - a Rust-based testing framew The project is a Cargo workspace with multiple binaries for different use cases: - **client-simulator** (main TUI): Interactive terminal UI for manual testing - **client-simulator-http**: HTTP/WebSocket server for remote control -- **client-simulator-orchestrator**: Batch orchestration of multiple simulated clients - **client-simulator-stats-gatherer**: Analytics collection from ClickHouse diff --git a/justfile b/justfile index 16a6ea5..9f20931 100644 --- a/justfile +++ b/justfile @@ -25,17 +25,6 @@ serve-nix *flags="": # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- -orchestrator *flags="": - cargo run --release --bin client-simulator-orchestrator -- {{ flags }} - -orchestrator-dev *flags="": - cargo run --package client-simulator-orchestrator -- {{ flags }} - -orchestrator-nix *flags="": - nix run .#client-simulator-orchestrator -- {{ flags }} - -# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - stats-gatherer *flags="": cargo run --release --bin client-simulator-stats-gatherer -- {{ flags }} @@ -70,6 +59,5 @@ cachix-push: nix build --no-link --print-out-paths \ .#client-simulator \ .#client-simulator-http \ - .#client-simulator-orchestrator \ .#client-simulator-stats-gatherer \ | cachix push hyper-video diff --git a/nix/packages.nix b/nix/packages.nix index a646791..e4d2ab7 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -53,14 +53,6 @@ rec { env.LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; }; - client-simulator-orchestrator = mkSimulatorPackage { - pname = "client-simulator-orchestrator"; - description = "Hyper browser client simulator orchestrator"; - buildInputs = [ openssl clang ]; - cargoBuildFlags = [ "--package" "client-simulator-orchestrator" ]; - env.LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; - }; - client-simulator-stats-gatherer = mkSimulatorPackage { pname = "client-simulator-stats-gatherer"; description = "Hyper browser client simulator stats gatherer"; diff --git a/orchestrator/Cargo.toml b/orchestrator/Cargo.toml deleted file mode 100644 index 90ac1c0..0000000 --- a/orchestrator/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "client-simulator-orchestrator" -version.workspace = true -authors.workspace = true -edition.workspace = true -repository.workspace = true - -[dependencies] -clap = { workspace = true, features = ["derive", "env", "cargo", "string"] } -color-eyre = { workspace = true } -eyre = { workspace = true } -futures = { workspace = true } -serde = { workspace = true } -serde_yml = { workspace = true } -tokio = { workspace = true } -tokio-tungstenite = { workspace = true } -tracing = { workspace = true } -tracing-error = { workspace = true } -tracing-subscriber = { workspace = true } -url = { workspace = true } - -client-simulator-browser = { path = "../browser" } -client-simulator-config = { path = "../config" } - -[lints] -workspace = true diff --git a/orchestrator/example-config.yml b/orchestrator/example-config.yml deleted file mode 100644 index 106a9d8..0000000 --- a/orchestrator/example-config.yml +++ /dev/null @@ -1,110 +0,0 @@ -## Orchestrator example configuration -# Base Hyper session URL participants should join -session_url: 'https://latest.dev.hyper.video/AA7-H8M-FYF' - -# HTTP workers that host the client-simulator HTTP gateway (WebSocket endpoint at "/") -# Workers will be used in a round-robin order. -workers: - - url: 'ws://127.0.0.1:8081/' - - url: 'ws://127.0.0.1:8082/' - -# Global participant defaults (applied before per-participant overrides). -defaults: - headless: true - audio_enabled: true - video_enabled: true - screenshare_enabled: false - noise_suppression: rnnoise # one of: none, deepfilternet, rnnoise, iris-carthy, krisp-high, krisp-medium, krisp-low, krisp-high-with-bvc, krisp-medium-with-bvc - transport: webtransport # one of: webtransport, webrtc - resolution: auto # one of: auto, p144, p240, p360, p480, p720, p1080, p1440, p2160, p4320 - blur: false - fake_media: builtin # one of: none, builtin, or URL to video file - -# Orchestrator controls -run_seconds: 600 # total test duration per participant - -# Participants with their join timing and settings -participants_specs: - - username: 'alice' - wait_to_join_seconds: 0 # joins immediately - initial: # initial state of the participant (optional, overrides global defaults) - audio_enabled: true - video_enabled: true - screenshare_enabled: false - blur: false - - - username: 'bob' - wait_to_join_seconds: 5 # joins after 5 seconds - initial: - audio_enabled: true - video_enabled: false - screenshare_enabled: false - - - username: 'charlie' - wait_to_join_seconds: 10 # joins after 10 seconds - - - username: 'diana' - wait_to_join_seconds: 15 # joins after 15 seconds - - - username: 'eve' - wait_to_join_seconds: 20 - - - username: 'frank' - wait_to_join_seconds: 25 - - - username: 'grace' - wait_to_join_seconds: 30 - - - username: 'henry' - wait_to_join_seconds: 35 - - - username: 'iris' - wait_to_join_seconds: 40 - - - username: 'jack' - wait_to_join_seconds: 45 - - - username: 'kate' - wait_to_join_seconds: 50 - - - username: 'liam' - wait_to_join_seconds: 55 - - - username: 'maya' - wait_to_join_seconds: 60 - - - username: 'noah' - wait_to_join_seconds: 65 - - - username: 'olivia' - wait_to_join_seconds: 70 - - - username: 'peter' - wait_to_join_seconds: 75 - - - username: 'quinn' - wait_to_join_seconds: 80 - - - username: 'ruby' - wait_to_join_seconds: 85 - - - username: 'sam' - wait_to_join_seconds: 90 - - - username: 'tara' - wait_to_join_seconds: 95 - - - username: 'uma' - wait_to_join_seconds: 100 - - - username: 'victor' - wait_to_join_seconds: 105 - - - username: 'wendy' - wait_to_join_seconds: 110 - - - username: 'xavier' - wait_to_join_seconds: 115 - - - username: 'yara' - wait_to_join_seconds: 120 diff --git a/orchestrator/src/config.rs b/orchestrator/src/config.rs deleted file mode 100644 index 5cf1170..0000000 --- a/orchestrator/src/config.rs +++ /dev/null @@ -1,212 +0,0 @@ -use client_simulator_config::{ - Config as ClientConfig, - NoiseSuppression, - TransportMode, - WebcamResolution, -}; -use eyre::{ - eyre, - Result, -}; -use serde::{ - Deserialize, - Serialize, -}; -use url::Url; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkerUrl { - pub url: Url, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ParticipantDefaults { - #[serde(default)] - pub headless: Option, - #[serde(default)] - pub audio_enabled: Option, - #[serde(default)] - pub video_enabled: Option, - #[serde(default)] - pub screenshare_enabled: Option, - #[serde(default)] - pub noise_suppression: Option, - #[serde(default)] - pub transport: Option, - #[serde(default)] - pub resolution: Option, - #[serde(default)] - pub blur: Option, - #[serde(default)] - pub fake_media: Option, // "none", "builtin", or URL -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ParticipantInitial { - #[serde(default)] - pub audio_enabled: Option, - #[serde(default)] - pub video_enabled: Option, - #[serde(default)] - pub screenshare_enabled: Option, - #[serde(default)] - pub blur: Option, - #[serde(default)] - pub noise_suppression: Option, - #[serde(default)] - pub resolution: Option, - #[serde(default)] - pub fake_media: Option, // "none", "builtin", or URL -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ParticipantSpec { - #[serde(default)] - pub username: Option, - #[serde(default)] - pub wait_to_join_seconds: Option, - #[serde(default)] - pub initial: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OrchestratorConfig { - pub session_url: Url, - pub workers: Vec, - #[serde(default)] - pub defaults: Option, - #[serde(default)] - pub participants_specs: Option>, - #[serde(default)] - pub run_seconds: Option, -} - -pub fn parse_config(path: &std::path::Path) -> color_eyre::Result { - let bytes = std::fs::read(path)?; - let content = String::from_utf8(bytes)?; - let cfg = serde_yml::from_str::(&content)?; - Ok(cfg) -} - -// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- -// Helpers to derive effective participant configs - -#[derive(Debug, Clone)] -pub struct EffectiveParticipantConfig { - pub username: String, - pub client: ClientConfig, - pub remote_url: url::Url, - pub fake_media: Option, -} - -impl OrchestratorConfig { - pub fn total_participants(&self) -> usize { - match self.participants_specs.as_ref() { - Some(specs) if !specs.is_empty() => specs.len(), - _ => 0, - } - } - - pub fn participant_spec(&self, index: usize) -> ParticipantSpec { - self.participants_specs - .as_ref() - .and_then(|v| v.get(index).cloned()) - .unwrap_or_default() - } - - pub fn effective_participant(&self, index: usize) -> Result { - // Choose worker URL in round-robin fashion - let workers_len = self.workers.len(); - let remote_url = self - .workers - .get(index % workers_len) - .ok_or_else(|| eyre!("workers must be non-empty; validate config before use"))? - .url - .clone(); - - // Start from base ClientConfig with session URL - let mut client = ClientConfig { - url: Some(self.session_url.clone()), - ..ClientConfig::default() - }; - - // Apply global defaults - if let Some(d) = &self.defaults { - if let Some(v) = d.audio_enabled { - client.audio_enabled = v; - } - if let Some(v) = d.video_enabled { - client.video_enabled = v; - } - if let Some(v) = d.screenshare_enabled { - client.screenshare_enabled = v; - } - if let Some(v) = d.headless { - client.headless = v; - } - if let Some(v) = d.noise_suppression { - client.noise_suppression = v; - } - if let Some(v) = d.transport { - client.transport = v; - } - if let Some(v) = d.resolution { - client.resolution = v; - } - if let Some(v) = d.blur { - client.blur = v; - } - } - - // Apply spec overrides - let spec = self.participant_spec(index); - if let Some(init) = &spec.initial { - if let Some(v) = init.audio_enabled { - client.audio_enabled = v; - } - if let Some(v) = init.video_enabled { - client.video_enabled = v; - } - if let Some(v) = init.screenshare_enabled { - client.screenshare_enabled = v; - } - if let Some(v) = init.noise_suppression { - client.noise_suppression = v; - } - if let Some(v) = init.resolution { - client.resolution = v; - } - if let Some(v) = init.blur { - client.blur = v; - } - } - - let username = spec.username.unwrap_or_else(|| format!("orch-{}", index)); - - // Determine fake_media setting (spec override > global default > None) - let fake_media = if let Some(init) = &spec.initial { - init.fake_media.clone() - } else { - self.defaults.as_ref().and_then(|d| d.fake_media.clone()) - }; - - Ok(EffectiveParticipantConfig { - username, - client, - remote_url, - fake_media, - }) - } - - pub fn validate(&self) -> Result<()> { - if self.workers.is_empty() { - return Err(eyre!("config.workers must be non-empty")); - } - if self.total_participants() == 0 { - return Err(eyre!( - "configure either non-empty participants_specs or participants > 0" - )); - } - Ok(()) - } -} diff --git a/orchestrator/src/lib.rs b/orchestrator/src/lib.rs deleted file mode 100644 index 1ff1f9e..0000000 --- a/orchestrator/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod config; -pub mod runner; - -pub use config::{ - parse_config, - OrchestratorConfig, -}; -pub use runner::run; diff --git a/orchestrator/src/main.rs b/orchestrator/src/main.rs deleted file mode 100644 index e631db3..0000000 --- a/orchestrator/src/main.rs +++ /dev/null @@ -1,38 +0,0 @@ -use clap::Parser; -use client_simulator_orchestrator::{ - parse_config, - run, -}; -use color_eyre::Result; -use tracing_subscriber::{ - fmt, - layer::SubscriberExt, - util::SubscriberInitExt, - EnvFilter, - Layer, -}; - -#[derive(Parser, Debug, Clone)] -#[command(author, version, about)] -struct Args { - /// Path to orchestrator config file (yaml) - #[arg(long)] - config: std::path::PathBuf, -} - -fn init_logging() { - color_eyre::install().expect("color_eyre init"); - tracing_subscriber::registry() - .with(fmt::layer().with_filter(EnvFilter::from_default_env())) - .with(tracing_error::ErrorLayer::default()) - .init(); -} - -#[tokio::main] -async fn main() -> Result<()> { - init_logging(); - let args = Args::parse(); - let cfg = parse_config(&args.config)?; - cfg.validate()?; - run(cfg).await -} diff --git a/orchestrator/src/runner.rs b/orchestrator/src/runner.rs deleted file mode 100644 index cf8717f..0000000 --- a/orchestrator/src/runner.rs +++ /dev/null @@ -1,143 +0,0 @@ -use crate::config::OrchestratorConfig; -use client_simulator_browser::{ - auth::{ - BorrowedCookie, - HyperSessionCookieManger, - HyperSessionCookieStash, - }, - participant::transport_data::{ - FakeMediaQuery, - ParticipantConfigQuery, - }, -}; -use client_simulator_config::Config as SimClientConfig; -use color_eyre::Result; -use futures::{ - future::join_all, - StreamExt, -}; -use std::time::Duration; -use tokio::time::sleep; -use tokio_tungstenite::connect_async; -use tracing::{ - debug, - error, - info, -}; - -pub async fn run(cfg: OrchestratorConfig) -> Result<()> { - let mut handles = Vec::new(); - let total = cfg.total_participants(); - let run_for = Duration::from_secs(cfg.run_seconds.unwrap_or(60)); - - // Initialize cookie manager from the shared data dir - let data_dir = SimClientConfig::default().data_dir().to_path_buf(); - let cookie_manager: HyperSessionCookieManger = HyperSessionCookieStash::load_from_data_dir(&data_dir).into(); - - info!(total, run_secs = run_for.as_secs(), "orchestrator: starting run"); - - let mut joined_participants = Vec::new(); - let start_time = tokio::time::Instant::now(); - - // Main loop: check every second for participants that should join - while joined_participants.len() < total { - let elapsed_seconds = start_time.elapsed().as_secs(); - - // Check each participant to see if it's time to join - for idx in 0..total { - // Skip if already joined - if joined_participants.contains(&idx) { - continue; - } - - let spec = cfg.participant_spec(idx); - let wait_time = spec.wait_to_join_seconds.unwrap_or(0); - - if elapsed_seconds >= wait_time { - let base_url = cfg.session_url.origin().unicode_serialization(); - let effective = cfg.effective_participant(idx)?; - let username = effective.username.clone(); - debug!(idx, user = %username, remote = %effective.remote_url, base_url, wait_time, "orchestrator: preparing participant"); - - let mut query = ParticipantConfigQuery { - username: effective.username, - remote_url: effective.remote_url, - session_url: cfg.session_url.clone(), - base_url, - cookie: None, - fake_media: match effective.fake_media.as_deref() { - Some("none") => Some(FakeMediaQuery::None), - Some("builtin") => Some(FakeMediaQuery::Builtin), - Some(url) if url.starts_with("http") => { - match url::Url::parse(url) { - Ok(url) => Some(FakeMediaQuery::Url(url)), - Err(_) => Some(FakeMediaQuery::Builtin), // fallback to builtin - } - } - _ => Some(FakeMediaQuery::Builtin), // default to builtin - }, - audio_enabled: effective.client.audio_enabled, - video_enabled: effective.client.video_enabled, - headless: effective.client.headless, - screenshare_enabled: effective.client.screenshare_enabled, - noise_suppression: effective.client.noise_suppression, - transport: effective.client.transport, - resolution: effective.client.resolution, - blur: effective.client.blur, - }; - - // Ensure a valid cookie in the query before connecting; keep it alive during the task - let cookie_guard: Option = query.ensure_cookie(cookie_manager.clone()).await?; - if cookie_guard.is_some() { - debug!(idx, "orchestrator: fetched new cookie"); - } else { - debug!(idx, "orchestrator: reused existing cookie"); - } - - let handle = tokio::spawn(async move { - let url = match query.into_url() { - Ok(u) => u, - Err(e) => { - error!("invalid participant URL: {e}"); - return; - } - }; - let (ws, _resp) = match connect_async(url.to_string()).await { - Ok(ok) => ok, - Err(e) => { - error!("failed to connect to worker: {e}"); - return; - } - }; - info!(idx, "orchestrator: connected websocket"); - let (_sink, mut stream) = ws.split(); - - // Keep connection alive for the specified duration - let _recv_task = tokio::spawn(async move { - debug!("orchestrator: recv task started"); - while let Some(_res) = stream.next().await { /* ignore for now */ } - debug!("orchestrator: recv task exiting"); - }); - - info!(idx, "orchestrator: holding connection"); - sleep(run_for).await; - - // Keep cookie alive until task end - let _ = cookie_guard; - info!(idx, "orchestrator: participant finished"); - }); - - handles.push(handle); - joined_participants.push(idx); - info!(idx, user = %username, elapsed_seconds, "orchestrator: participant joined"); - } - } - - // Wait one second before checking again - sleep(Duration::from_secs(1)).await; - } - - join_all(handles).await; - info!("orchestrator: run completed"); - Ok(()) -} From 25e7ca1b0336ac21021eaad5eb4b30073ad4e7fd Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:10:43 +0100 Subject: [PATCH 4/9] replace remote worker with stub --- CLAUDE.md | 24 +- Cargo.lock | 352 +--------------------- Cargo.toml | 5 - README.md | 1 - browser/Cargo.toml | 2 - browser/src/participant/mod.rs | 17 +- browser/src/participant/remote.rs | 159 ---------- browser/src/participant/remote_stub.rs | 117 +++++++ browser/src/participant/store.rs | 4 +- browser/src/participant/transport_data.rs | 235 --------------- http/Cargo.toml | 26 -- http/src/error.rs | 32 -- http/src/lib.rs | 5 - http/src/main.rs | 87 ------ http/src/participant.rs | 155 ---------- http/src/router.rs | 26 -- justfile | 12 - nix/packages.nix | 8 - tui/src/tui/components/browser_start.rs | 4 +- 19 files changed, 135 insertions(+), 1136 deletions(-) delete mode 100644 browser/src/participant/remote.rs create mode 100644 browser/src/participant/remote_stub.rs delete mode 100644 browser/src/participant/transport_data.rs delete mode 100644 http/Cargo.toml delete mode 100644 http/src/error.rs delete mode 100644 http/src/lib.rs delete mode 100644 http/src/main.rs delete mode 100644 http/src/participant.rs delete mode 100644 http/src/router.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4edf7bf..5003ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,6 @@ This is a **Hyper.Video Browser Client Simulator** - a Rust-based testing framew The project is a Cargo workspace with multiple binaries for different use cases: - **client-simulator** (main TUI): Interactive terminal UI for manual testing -- **client-simulator-http**: HTTP/WebSocket server for remote control - **client-simulator-stats-gatherer**: Analytics collection from ClickHouse ## Build & Development Commands @@ -23,10 +22,6 @@ cargo build --release just run # release mode just dev # dev mode (faster compilation) -# Run HTTP server (for remote control) -just serve # release mode -just serve-dev # dev mode - # Run stats gatherer just stats-gatherer --clickhouse-url http://localhost:8123 --space-url https://... ``` @@ -60,12 +55,10 @@ The project includes Nix flake support for reproducible builds: ```bash # Build via Nix nix build .#client-simulator -nix build .#client-simulator-http nix build .#client-simulator-stats-gatherer # Run via Nix just run-nix -just serve-nix ``` ### Fetch Session Cookie @@ -86,7 +79,6 @@ client-simulator/ # Main binary (TUI) ├── browser/ # Browser automation core ├── config/ # Configuration management ├── tui/ # Terminal UI (ratatui-based) -├── http/ # HTTP/WebSocket API server └── stats-gatherer/ # ClickHouse analytics ``` @@ -99,14 +91,16 @@ The foundation of all simulation modes. Key responsibilities: - **Browser Lifecycle**: Launches headless/headed Chromium instances using `chromiumoxide` - **Participant**: Central abstraction representing a simulated user - `ParticipantInner`: Full browser-based participant (uses Chromium DevTools Protocol) - - `ParticipantInnerLite`: Lightweight participant (direct WebSocket, no browser) + - `ParticipantInnerLite`: Browser-based automation for the lite frontend + - `remote_stub.rs`: In-process placeholder for the future remote backend - **Authentication**: `HyperSessionCookieStash` manages persistent user sessions - **Media Handling**: Supports fake media sources (builtin, custom video/audio files) Key files: - `browser/src/participant/mod.rs`: Participant API and lifecycle - `browser/src/participant/inner.rs`: Full browser implementation -- `browser/src/participant/inner_lite.rs`: Lite WebSocket-only implementation +- `browser/src/participant/inner_lite.rs`: Lite frontend browser implementation +- `browser/src/participant/remote_stub.rs`: Endpoint-free remote participant stub - `browser/src/auth.rs`: Cookie/session management #### 2. Config Module (`config/`) @@ -125,14 +119,6 @@ Interactive terminal interface built with `ratatui`: - View logs in real-time - Persist configuration across sessions -#### 4. HTTP Server Mode (`http/`) - -Exposes participants via REST API and WebSocket: -- Create participants remotely -- Send control commands (join, toggle media, etc.) -- Stream logs over WebSocket -- Useful for CI/CD integration - #### 5. Stats Gatherer (`stats-gatherer/`) Connects directly to ClickHouse to collect analytics: @@ -200,7 +186,7 @@ cargo nextest run -p client-simulator-browser 1. Add variant to `ParticipantMessage` enum in `browser/src/participant/messages.rs` 2. Handle in `ParticipantInner::run()` message loop (`browser/src/participant/inner.rs`) 3. Add public method to `Participant` struct in `browser/src/participant/mod.rs` -4. Expose in TUI/HTTP as needed +4. Expose in the TUI as needed ### Debugging Browser Issues diff --git a/Cargo.lock b/Cargo.lock index c4adf06..41ce2ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,12 +97,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "arc-swap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" - [[package]] name = "arraydeque" version = "0.5.1" @@ -134,7 +128,7 @@ dependencies = [ "log", "pin-project-lite", "tokio", - "tungstenite 0.24.0", + "tungstenite", ] [[package]] @@ -149,107 +143,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ba2e2516bdf37af57fc6ff047855f54abad0066e5c4fdaaeb76dabb2e05bcf5" -dependencies = [ - "bindgen", - "cc", - "cmake", - "dunce", - "fs_extra", - "libloading", -] - -[[package]] -name = "axum" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98e529aee37b5c8206bb4bf4c44797127566d72f76952c970bd3d1e85de8f4e2" -dependencies = [ - "axum-core", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite 0.28.0", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ac7a6beb1182c7e30253ee75c3e918080bfb83f5a3023bcdf7209d85fd147e6" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-server" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "495c05f60d6df0093e8fb6e74aa5846a0ad06abaf96d76166283720bf740f8ab" -dependencies = [ - "arc-swap", - "bytes", - "fs-err", - "http", - "http-body", - "hyper", - "hyper-util", - "pin-project-lite", - "rustls", - "rustls-pemfile", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -290,26 +183,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", -] - [[package]] name = "bitflags" version = "2.9.4" @@ -380,20 +253,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.3" @@ -483,17 +345,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93a719913643003b84bd13022b4b7e703c09342cd03b679c4641c7d2e50dc34d" -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.5.48" @@ -597,7 +448,6 @@ dependencies = [ name = "client-simulator-browser" version = "0.1.0" dependencies = [ - "base64", "chromiumoxide", "chrono", "client-simulator-config", @@ -610,7 +460,6 @@ dependencies = [ "serde_json", "strum 0.27.2", "tokio", - "tokio-tungstenite 0.26.2", "tokio-util", "tracing", "url", @@ -638,27 +487,6 @@ dependencies = [ "url", ] -[[package]] -name = "client-simulator-http" -version = "0.1.0" -dependencies = [ - "axum", - "axum-server", - "clap", - "client-simulator-browser", - "client-simulator-config", - "color-eyre", - "eyre", - "futures", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tracing", - "tracing-error", - "tracing-subscriber", -] - [[package]] name = "client-simulator-stats-gatherer" version = "0.1.0" @@ -712,15 +540,6 @@ dependencies = [ "url", ] -[[package]] -name = "cmake" -version = "0.1.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" -dependencies = [ - "cc", -] - [[package]] name = "color-eyre" version = "0.6.5" @@ -1165,22 +984,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-err" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f150ffc8782f35521cec2b23727707cb4045706ba3c854e86bef66b3a8cdbd" -dependencies = [ - "autocfg", - "tokio", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.31" @@ -1317,12 +1120,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "gloo" version = "0.11.0" @@ -1519,12 +1316,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "human-panic" version = "2.0.3" @@ -1561,7 +1352,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1848,16 +1638,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.3", - "libc", -] - [[package]] name = "joinery" version = "2.1.0" @@ -1886,16 +1666,6 @@ version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" -[[package]] -name = "libloading" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" -dependencies = [ - "cfg-if", - "windows-targets 0.53.4", -] - [[package]] name = "libredox" version = "0.1.10" @@ -1980,12 +1750,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "maybe-backoff" version = "0.5.0" @@ -2010,12 +1774,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2054,16 +1812,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -2294,16 +2042,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2616,12 +2354,6 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustix" version = "0.38.44" @@ -2654,7 +2386,6 @@ version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ - "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -2662,15 +2393,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -2686,7 +2408,6 @@ version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2817,17 +2538,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -3262,30 +2972,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.26.2", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - [[package]] name = "tokio-util" version = "0.7.16" @@ -3356,7 +3042,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -3395,7 +3080,6 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3514,40 +3198,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.17", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.17", - "utf-8", -] - [[package]] name = "typenum" version = "1.18.0" diff --git a/Cargo.toml b/Cargo.toml index 15a32f3..bb70a78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ ".", "browser", "config", - "http", "stats-gatherer", "tui", ] @@ -16,9 +15,6 @@ edition = "2021" repository = "https://github.com/hypervideo/hyper.video" [workspace.dependencies] -axum = { version = "0.8.3" } -axum-server = "0.7.2" -base64 = "0.22.1" better-panic = "0.3.0" # TODO: fix for https://github.com/mattsse/chromiumoxide/issues/243 chromiumoxide = { git = "https://github.com/caido/dependency-chromiumoxide", branch = "ef-json-parsing", features = ["tokio-runtime", "bytes"], default-features = false } @@ -49,7 +45,6 @@ strum = { version = "0.27.1", features = ["derive"] } temp-dir = "0.1.16" thiserror = "2.0.6" tokio = { version = "1.46.1", default-features = false, features = ["macros", "rt-multi-thread", "sync", "signal"] } -tokio-tungstenite = "0.26.2" tokio-util = "0.7.12" tracing = "0.1.41" tracing-error = "0.2.0" diff --git a/README.md b/README.md index 5ff6e58..5c66a1c 100644 --- a/README.md +++ b/README.md @@ -2,5 +2,4 @@ This is a **Hyper.Video Browser Client Simulator** - a Rust-based testing framew The project is a Cargo workspace with multiple binaries for different use cases: - **client-simulator** (main TUI): Interactive terminal UI for manual testing -- **client-simulator-http**: HTTP/WebSocket server for remote control - **client-simulator-stats-gatherer**: Analytics collection from ClickHouse diff --git a/browser/Cargo.toml b/browser/Cargo.toml index 06cc388..f3377ca 100644 --- a/browser/Cargo.toml +++ b/browser/Cargo.toml @@ -6,7 +6,6 @@ edition.workspace = true repository.workspace = true [dependencies] -base64.workspace = true chromiumoxide.workspace = true chrono.workspace = true derive_more.workspace = true @@ -17,7 +16,6 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true tokio.workspace = true -tokio-tungstenite.workspace = true tokio-util.workspace = true tracing.workspace = true url.workspace = true diff --git a/browser/src/participant/mod.rs b/browser/src/participant/mod.rs index 5ae5b00..a06f928 100644 --- a/browser/src/participant/mod.rs +++ b/browser/src/participant/mod.rs @@ -5,8 +5,7 @@ use super::auth::{ }; use crate::participant::{ messages::ParticipantLogMessage, - remote::spawn_remote, - transport_data::ParticipantConfigQuery, + remote_stub::spawn_remote_stub, }; use chrono::Utc; use client_simulator_config::{ @@ -38,11 +37,10 @@ mod commands; mod inner; mod inner_lite; pub mod messages; -mod remote; +mod remote_stub; mod selectors; mod state; mod store; -pub mod transport_data; use inner::ParticipantInner; pub use state::ParticipantState; @@ -152,12 +150,13 @@ impl Participant { )) } - pub fn spawn_remote(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { + pub fn spawn_remote_stub(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { let session_url = config.url.clone().ok_or_eyre("No session URL provided in the config")?; let base_url = session_url.origin().unicode_serialization(); let cookie = cookie_manager.give_cookie(&base_url); - let query = ParticipantConfigQuery::new(config, cookie.as_ref())?; - let name = query.username.clone(); + let name = cookie.as_ref().map(|entry| entry.username()); + let participant_config = ParticipantConfig::new(config, name)?; + let name = participant_config.username.clone(); let (sender, receiver) = unbounded_channel::(); let task_cancellation_token = CancellationToken::new(); @@ -170,9 +169,9 @@ impl Participant { biased; _ = task_cancellation_token.cancelled() => {}, - result = spawn_remote(receiver, state_sender, query, cookie, cookie_manager) => { + result = spawn_remote_stub(receiver, state_sender, participant_config) => { if let Err(err) = result { - error!("Failed to spawn remote participant: {err}"); + error!("Failed to spawn remote participant stub: {err}"); } } }; diff --git a/browser/src/participant/remote.rs b/browser/src/participant/remote.rs deleted file mode 100644 index 6fef605..0000000 --- a/browser/src/participant/remote.rs +++ /dev/null @@ -1,159 +0,0 @@ -use crate::{ - auth::{ - BorrowedCookie, - HyperSessionCookieManger, - }, - participant::{ - messages::ParticipantMessage, - transport_data::{ - ParticipantConfigQuery, - ParticipantResponseMessage, - }, - ParticipantState, - }, -}; -use eyre::{ - eyre, - Result, -}; -use futures::{ - SinkExt, - StreamExt, -}; -use tokio::{ - sync::{ - mpsc::UnboundedReceiver, - watch, - }, - task::JoinHandle, -}; -use tokio_tungstenite::{ - connect_async, - tungstenite::{ - protocol::Message, - Error, - }, -}; - -pub async fn spawn_remote( - mut receiver: UnboundedReceiver, - state_sender: watch::Sender, - mut query: ParticipantConfigQuery, - cookie: Option, - cookie_manager: HyperSessionCookieManger, -) -> Result<()> { - let maybe_new_cookie = query.ensure_cookie(cookie_manager).await?; - - info!("Connecting to WebSocket: {}", query.remote_url); - - let (ws_stream, response) = match connect_async(query.into_url()?.to_string()).await { - Ok(result) => result, - Err(e) => { - // Check if the error contains an HTTP response - if let Error::Http(ref response) = e { - let status = response.status(); - let headers = response.headers(); - let body = response.body().clone().unwrap_or_default(); - let body_str = String::from_utf8(body.clone()).unwrap_or_else(|_| format!("Non-UTF8 body: {:?}", body)); - error!( - "WebSocket connection failed: status={}, headers={:?}, body={}", - status, headers, body_str - ); - } else { - error!("WebSocket connection failed: {}", e); - } - return Err(eyre!("Failed to connect to WebSocket: {}", e)); - } - }; - - info!("WebSocket connected: {:?}", response); - - let (mut outgoing, mut incoming) = ws_stream.split(); - - // Handle outgoing messages - let send_task: JoinHandle<()> = tokio::spawn(async move { - info!("Starting send task"); - while let Some(message) = receiver.recv().await { - debug!("Sending message: {:?}", message); - match serde_json::to_string(&message) { - Ok(text) => { - if let Err(e) = outgoing.send(Message::Text(text.into())).await { - error!("Error sending message: {}", e); - break; - } - } - Err(e) => { - error!("Error serializing message: {}", e); - } - } - } - info!("Send task completed"); - }); - - // Handle incoming messages - let recv_task: JoinHandle<()> = tokio::spawn(async move { - info!("Starting receive task"); - while let Some(result) = incoming.next().await { - match result { - Ok(msg) => match msg { - Message::Text(text) => { - trace!("Received message, will try to deserialize it: {}", text); - let message_result: Result = serde_json::from_str(&text); - match message_result { - Ok(message) => { - debug!("Received message: {:?}", message); - - state_sender.send_modify(|state| { - *state = message.state; - }); - - if let Some(log) = message.log { - log.write(); - } - } - Err(e) => { - error!("Error deserializing message: {}", e); - } - } - } - Message::Binary(_) => { - trace!("Received binary message"); - } - Message::Ping(ping) => { - debug!("Received ping: {:?}", ping); - } - Message::Pong(pong) => { - debug!("Received pong: {:?}", pong); - } - Message::Close(_) => { - debug!("Received close message"); - break; - } - Message::Frame(_) => { - trace!("Received frame message"); - } - }, - Err(e) => { - error!("Error receiving message: {}", e); - break; - } - } - } - info!("Receive task completed"); - }); - - tokio::select! { - result = send_task => { - result?; - } - result = recv_task => { - result?; - } - }; - - // Moving the both possible cookies here to have them live as long as the remote participant is alive - // to avoid giving the same cookie to another participant. - let _ = (cookie, maybe_new_cookie); - - Ok(()) -} diff --git a/browser/src/participant/remote_stub.rs b/browser/src/participant/remote_stub.rs new file mode 100644 index 0000000..9510cb9 --- /dev/null +++ b/browser/src/participant/remote_stub.rs @@ -0,0 +1,117 @@ +use crate::participant::{ + messages::{ + ParticipantLogMessage, + ParticipantMessage, + }, + ParticipantState, +}; +use client_simulator_config::ParticipantConfig; +use eyre::Result; +use tokio::sync::{ + mpsc::UnboundedReceiver, + watch, +}; + +pub async fn spawn_remote_stub( + mut receiver: UnboundedReceiver, + state_sender: watch::Sender, + participant_config: ParticipantConfig, +) -> Result<()> { + let username = participant_config.username.clone(); + let configured_remote_url = participant_config.app_config.remote_url(); + + state_sender.send_modify(|state| { + state.username = username.clone(); + state.running = true; + state.joined = true; + state.muted = !participant_config.app_config.audio_enabled; + state.video_activated = participant_config.app_config.video_enabled; + state.noise_suppression = participant_config.app_config.noise_suppression; + state.transport_mode = participant_config.app_config.transport; + state.webcam_resolution = participant_config.app_config.resolution; + state.background_blur = participant_config.app_config.blur; + state.screenshare_activated = participant_config.app_config.screenshare_enabled; + }); + + let backend_message = match configured_remote_url { + Some(url) => format!( + "remote backend is a local stub; no connection will be made and configured remote URL {url} is ignored" + ), + None => "remote backend is a local stub; commands are simulated locally".to_string(), + }; + ParticipantLogMessage::new("warn", &username, backend_message).write(); + + while let Some(message) = receiver.recv().await { + match message { + ParticipantMessage::Join => { + state_sender.send_modify(|state| { + state.joined = true; + }); + ParticipantLogMessage::new("info", &username, "remote stub join simulated").write(); + } + ParticipantMessage::Leave => { + state_sender.send_modify(|state| { + state.joined = false; + state.screenshare_activated = false; + }); + ParticipantLogMessage::new("info", &username, "remote stub leave simulated").write(); + } + ParticipantMessage::Close => { + state_sender.send_modify(|state| { + state.running = false; + state.joined = false; + state.screenshare_activated = false; + }); + ParticipantLogMessage::new("debug", &username, "remote stub closed").write(); + return Ok(()); + } + ParticipantMessage::ToggleAudio => { + state_sender.send_modify(|state| { + state.muted = !state.muted; + }); + ParticipantLogMessage::new("debug", &username, "remote stub toggled audio").write(); + } + ParticipantMessage::ToggleVideo => { + state_sender.send_modify(|state| { + state.video_activated = !state.video_activated; + }); + ParticipantLogMessage::new("debug", &username, "remote stub toggled video").write(); + } + ParticipantMessage::ToggleScreenshare => { + state_sender.send_modify(|state| { + state.screenshare_activated = !state.screenshare_activated; + }); + ParticipantLogMessage::new("debug", &username, "remote stub toggled screenshare").write(); + } + ParticipantMessage::SetNoiseSuppression(value) => { + state_sender.send_modify(|state| { + state.noise_suppression = value; + }); + ParticipantLogMessage::new("debug", &username, format!("remote stub set noise suppression to {value}")) + .write(); + } + ParticipantMessage::SetWebcamResolutions(value) => { + state_sender.send_modify(|state| { + state.webcam_resolution = value; + }); + ParticipantLogMessage::new("debug", &username, format!("remote stub set camera resolution to {value}")) + .write(); + } + ParticipantMessage::ToggleBackgroundBlur => { + state_sender.send_modify(|state| { + state.background_blur = !state.background_blur; + }); + ParticipantLogMessage::new("debug", &username, "remote stub toggled background blur").write(); + } + } + } + + state_sender.send_modify(|state| { + state.running = false; + state.joined = false; + state.screenshare_activated = false; + }); + ParticipantLogMessage::new("debug", &username, "remote stub channel closed").write(); + + Ok(()) +} diff --git a/browser/src/participant/store.rs b/browser/src/participant/store.rs index 7d53382..466814b 100644 --- a/browser/src/participant/store.rs +++ b/browser/src/participant/store.rs @@ -41,8 +41,8 @@ impl ParticipantStore { Ok(()) } - pub fn spawn_remote(&self, config: &Config) -> Result<()> { - let participant = Participant::spawn_remote(config, self.cookies.clone())?; + pub fn spawn_remote_stub(&self, config: &Config) -> Result<()> { + let participant = Participant::spawn_remote_stub(config, self.cookies.clone())?; self.add(participant); Ok(()) } diff --git a/browser/src/participant/transport_data.rs b/browser/src/participant/transport_data.rs deleted file mode 100644 index 6357e86..0000000 --- a/browser/src/participant/transport_data.rs +++ /dev/null @@ -1,235 +0,0 @@ -use crate::{ - auth::{ - BorrowedCookie, - HyperSessionCookie, - HyperSessionCookieManger, - }, - participant::{ - messages::ParticipantLogMessage, - ParticipantState, - }, -}; -use base64::{ - prelude::BASE64_STANDARD, - Engine, -}; -use client_simulator_config::{ - generate_random_name, - media::{ - FakeMedia, - FakeMediaWithDescription, - }, - Config, - NoiseSuppression, - TransportMode, - WebcamResolution, -}; -use eyre::{ - eyre, - Context as _, - Report, - Result, -}; -use std::fmt; -use strum::Display; -use url::Url; - -#[derive(Debug, Default, Display, Clone, serde::Serialize, serde::Deserialize)] -pub enum FakeMediaQuery { - #[default] - None, - Builtin, - Url(Url), -} - -impl From for FakeMediaQuery { - fn from(source: FakeMedia) -> Self { - Self::from(&source) - } -} - -impl From<&FakeMedia> for FakeMediaQuery { - fn from(source: &FakeMedia) -> Self { - match source { - FakeMedia::None => Self::None, - FakeMedia::Builtin => Self::Builtin, - FakeMedia::FileOrUrl(file_or_url) => { - if file_or_url.starts_with("http") { - Url::parse(file_or_url).map(FakeMediaQuery::Url).unwrap_or_default() - } else { - FakeMediaQuery::Builtin - } - } - } - } -} - -impl From<&FakeMediaWithDescription> for FakeMediaQuery { - fn from(source: &FakeMediaWithDescription) -> Self { - Self::from(source.fake_media()) - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ParticipantConfigQuery { - pub username: String, - pub remote_url: Url, - pub session_url: Url, - pub base_url: String, - pub cookie: Option, - pub fake_media: Option, - pub audio_enabled: bool, - pub video_enabled: bool, - pub headless: bool, - pub screenshare_enabled: bool, - pub noise_suppression: NoiseSuppression, - pub transport: TransportMode, - pub resolution: WebcamResolution, - pub blur: bool, -} - -impl ParticipantConfigQuery { - pub fn new(config: &Config, cookie: Option<&BorrowedCookie>) -> Result { - let username = cookie - .map(|c| c.username().to_string()) - .unwrap_or_else(generate_random_name); - - let fake_media = config - .fake_media_selected - .map(|index| config.fake_media_sources.get(index).map(FakeMediaQuery::from)) - .unwrap_or_default(); - - let session_url = config - .url - .clone() - .ok_or_else(|| eyre!("Session URL is required for remote participant"))?; - - let base_url = session_url.origin().unicode_serialization(); - - let remote_url_index = config - .remote_url - .ok_or_else(|| eyre!("Remote URL is required for remote participant"))?; - - let remote_url = config - .remote_url_options - .get(remote_url_index) - .ok_or_else(|| eyre!("Remote URL is required for remote participant"))? - .url() - .clone(); - - Ok(Self { - username, - remote_url, - session_url, - base_url, - cookie: cookie.map(|c| c.cookie.clone()), - fake_media, - audio_enabled: config.audio_enabled, - video_enabled: config.video_enabled, - headless: config.headless, - screenshare_enabled: config.screenshare_enabled, - noise_suppression: config.noise_suppression, - transport: config.transport, - resolution: config.resolution, - blur: config.blur, - }) - } - - pub async fn ensure_cookie(&mut self, cookie_manager: HyperSessionCookieManger) -> Result> { - if self.cookie.is_none() { - let base_url = Url::parse(&self.base_url).context("Failed to parse base URL")?; - let cookie = cookie_manager.fetch_new_cookie(base_url, &self.username).await?; - self.cookie = Some(cookie.cookie.clone()); - - return Ok(Some(cookie)); - } - - Ok(None) - } - - pub fn into_config_and_cookie( - self, - app_config: &Config, - cookie_manager: HyperSessionCookieManger, - ) -> (Config, Option) { - let borrowed_cookie = self - .cookie - .map(|cookie| BorrowedCookie::new(&self.base_url, cookie, cookie_manager)); - - let mut config = Config { - app_config: app_config.app_config.clone(), - url: Some(self.session_url.clone()), - fake_media_selected: Some(0), // Default to the first fake media source - fake_media_sources: app_config.fake_media_sources.clone(), - audio_enabled: self.audio_enabled, - video_enabled: self.video_enabled, - headless: self.headless, - screenshare_enabled: self.screenshare_enabled, - noise_suppression: self.noise_suppression, - transport: self.transport, - resolution: self.resolution, - blur: self.blur, - remote_url: None, - remote_url_options: vec![], - }; - - config.fake_media_selected = match self.fake_media { - Some(FakeMediaQuery::None) => None, - Some(FakeMediaQuery::Builtin) => Some(1), // Builtin media is the first source - Some(FakeMediaQuery::Url(url)) => config.add_custom_fake_media(url.to_string()), - None => Some(0), // Default to the first source if no fake media is specified - }; - - (config, borrowed_cookie) - } - - pub fn into_url(&self) -> Result { - let json = serde_json::to_string(&self)?; - let base64 = BASE64_STANDARD.encode(json.as_bytes()); - let mut url = self.remote_url.clone(); - url.query_pairs_mut().append_pair("payload", &base64); - Ok(url) - } -} - -impl TryFrom for ParticipantConfigQuery { - type Error = Report; - fn try_from(value: String) -> Result { - let json = BASE64_STANDARD.decode(value)?; - let config = serde_json::from_slice::(json.as_slice())?; - Ok(config) - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ParticipantResponseMessage { - pub state: ParticipantState, - pub log: Option, -} - -impl ParticipantResponseMessage { - pub fn new(state: ParticipantState, log: ParticipantLogMessage) -> Self { - Self { state, log: Some(log) } - } - - pub fn from_state(state: ParticipantState) -> Self { - Self { state, log: None } - } - - pub fn from_log(log: ParticipantLogMessage) -> Self { - Self { - state: ParticipantState::default(), - log: Some(log), - } - } -} - -impl fmt::Display for ParticipantResponseMessage { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "{}", - serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string()) - ) - } -} diff --git a/http/Cargo.toml b/http/Cargo.toml deleted file mode 100644 index eed4a4b..0000000 --- a/http/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "client-simulator-http" -version.workspace = true -authors.workspace = true -edition.workspace = true -repository.workspace = true - -[dependencies] -axum = { workspace = true, features = ["ws"] } -axum-server = { workspace = true, features = ["tls-rustls"] } -clap = { workspace = true, features = ["derive"] } -client-simulator-browser.workspace = true -client-simulator-config.workspace = true -color-eyre.workspace = true -eyre.workspace = true -futures.workspace = true -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true -tokio.workspace = true -tracing.workspace = true -tracing-error.workspace = true -tracing-subscriber.workspace = true - -[lints] -workspace = true diff --git a/http/src/error.rs b/http/src/error.rs deleted file mode 100644 index 8fb5579..0000000 --- a/http/src/error.rs +++ /dev/null @@ -1,32 +0,0 @@ -use axum::{ - extract::ws::Message, - http::StatusCode, - response::{ - IntoResponse, - Response, - }, -}; - -#[derive(thiserror::Error, Debug)] -pub enum AppError { - #[error("The configuration cannot be used to join the participant: {0}")] - ParticipantConfig(eyre::Report), - #[error("Handling the websocket connection failed: {0}")] - Socket(eyre::Report), -} - -impl AppError { - pub fn into_message(self) -> Message { - Message::Text(serde_json::json!({ "error": self.to_string() }).to_string().into()) - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": self.to_string() })), - ) - .into_response() - } -} diff --git a/http/src/lib.rs b/http/src/lib.rs deleted file mode 100644 index e642ab5..0000000 --- a/http/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[macro_use] -extern crate tracing; -pub mod error; -mod participant; -pub mod router; diff --git a/http/src/main.rs b/http/src/main.rs deleted file mode 100644 index 6a19903..0000000 --- a/http/src/main.rs +++ /dev/null @@ -1,87 +0,0 @@ -use axum::serve; -use axum_server::tls_rustls::RustlsConfig; -use clap::Parser; -use client_simulator_browser::auth::HyperSessionCookieStash; -use client_simulator_config::Config; -use client_simulator_http::router::create_router; -use color_eyre::Result; -use std::{ - net::SocketAddr, - path::PathBuf, -}; -use tokio::net::TcpListener; -use tracing_subscriber::{ - fmt, - layer::SubscriberExt, - util::SubscriberInitExt, - EnvFilter, - Layer, -}; - -#[derive(Debug, clap::Args)] -#[group(required = true, multiple = true)] -struct HttpArgs { - /// IP address to listen on. - #[arg(long, env = "CLIENT_SIMULATOR_HTTP_ADDRESS", default_value = "127.0.0.1:8081")] - http_listen_address: SocketAddr, - - /// Should the HTTP server terminate TLS connections? - #[arg(long, action, default_value_t = false, env = "CLIENT_SIMULATOR_HTTP_TLS")] - tls: bool, -} - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - #[clap(flatten)] - http: HttpArgs, - - /// Path to the X.509 public key certificate in DER encoding. - #[arg(long)] - certificate: Option, - - /// Path to the private key for the X.509 certificate in DER encoding. - #[arg(long)] - private_key: Option, -} - -fn init_logging() { - color_eyre::install().expect("color_eyre init"); - - tracing_subscriber::registry() - .with(fmt::layer().with_filter(EnvFilter::from_default_env())) - .with(tracing_error::ErrorLayer::default()) - .init(); -} - -async fn start_server(args: Args) -> Result<()> { - let config = Config::default(); - let cookie_stash = HyperSessionCookieStash::load_from_data_dir(config.data_dir()); - let app = create_router(config, cookie_stash.into()); - - tracing::info!("listening on {}", args.http.http_listen_address); - - if args.http.tls { - let Some(cert_path) = args.certificate else { - eyre::bail!("TLS is enabled but no certificate path was provided"); - }; - let Some(private_key) = args.private_key else { - eyre::bail!("TLS is enabled but no private key path was provided"); - }; - let rustls_config = RustlsConfig::from_pem_file(cert_path, private_key).await?; - axum_server::bind_rustls(args.http.http_listen_address, rustls_config) - .serve(app.into_make_service()) - .await?; - } else { - let listener = TcpListener::bind(args.http.http_listen_address).await?; - serve(listener, app.into_make_service()).await?; - } - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - init_logging(); - start_server(Args::parse()).await -} diff --git a/http/src/participant.rs b/http/src/participant.rs deleted file mode 100644 index 9ea823e..0000000 --- a/http/src/participant.rs +++ /dev/null @@ -1,155 +0,0 @@ -use crate::{ - error::AppError, - router::AppState, -}; -use axum::{ - extract::{ - ws::{ - Message, - WebSocket, - WebSocketUpgrade, - }, - Query, - State, - }, - response::{ - IntoResponse, - Response, - }, -}; -use client_simulator_browser::participant::{ - messages::ParticipantMessage, - transport_data::{ - ParticipantConfigQuery, - ParticipantResponseMessage, - }, - Participant, -}; -use client_simulator_config::ParticipantConfig; -use eyre::{ - eyre, - Result, -}; -use futures::{ - sink::SinkExt, - stream::{ - SplitSink, - SplitStream, - StreamExt, - }, -}; - -#[derive(serde::Deserialize)] -pub struct QueryPayload { - payload: String, -} - -pub async fn handler( - ws: WebSocketUpgrade, - State(state): State, - Query(query): Query, -) -> Response { - let participant_config_query = match ParticipantConfigQuery::try_from(query.payload) { - Ok(v) => v, - Err(e) => return AppError::ParticipantConfig(e).into_response(), - }; - - ws.on_upgrade(move |socket| handle_socket(socket, state, participant_config_query)) -} - -async fn handle_socket(socket: WebSocket, state: AppState, query: ParticipantConfigQuery) { - info!("New WebSocket connection"); - - let (mut sender, receiver) = socket.split(); - - if let Err(e) = handle_socket_inner(&mut sender, receiver, state, query).await { - error!("Error handling WebSocket connection: {}", e); - sender - .send(AppError::Socket(e).into_message()) - .await - .unwrap_or_else(|e| error!("Failed to send error message: {}", e)); - } -} - -async fn handle_socket_inner( - sender: &mut SplitSink, - mut receiver: SplitStream, - state: AppState, - query: ParticipantConfigQuery, -) -> Result<()> { - let (config, borrowed_cookie) = query.into_config_and_cookie(&state.config, state.cookie_manager.clone()); - let borrowed_cookie = borrowed_cookie.ok_or_else(|| eyre!("Cannot connect to session without a cookie"))?; - - let participant_config = ParticipantConfig::new(&config, Some(borrowed_cookie.username().to_string()))?; - debug!("Creating a new participant with config: {:?}", config); - - let (mut participant, mut participant_receiver) = - Participant::with_participant_config(participant_config, Some(borrowed_cookie), state.cookie_manager)?; - - let mut rx = participant.state.clone(); - - loop { - tokio::select! { - biased; - - Some(message) = receiver.next() => { - match message { - Ok(Message::Text(text)) => { - let message = serde_json::from_str::(&text)?; - - if let ParticipantMessage::Close = message { - participant.close().await; - sender.send(Message::Close(None)).await?; - return Ok(()); - } - - participant.send_message(message); - } - Ok(Message::Binary(_)) => { - info!("Received binary message"); - } - Ok(Message::Close(_)) => { - info!("WebSocket connection closed by client"); - participant.close().await; - sender.send(Message::Close(None)).await?; - return Ok(()); - } - Ok(Message::Ping(ping)) => { - info!("Received ping: {:?}", ping); - sender.send(Message::Pong(ping)).await?; - } - Ok(Message::Pong(pong)) => { - info!("Received pong: {:?}", pong); - } - Err(e) => { - sender.send(AppError::Socket(e.into()).into_message()).await?; - participant.close().await; - return Ok(()); - } - } - }, - - message = participant_receiver.recv() => { - match message { - Some(msg) => { - info!("Received participant message: {:?}", msg); - let json = ParticipantResponseMessage::new(participant.state.borrow().clone(), msg).to_string(); - sender.send(Message::Text(json.into())).await?; - } - None => { - info!("Participant channel closed"); - participant.close().await; - return Ok(()); - } - } - }, - - Ok(_) = rx.changed() => { - let state = participant.state.borrow_and_update().clone(); - debug!("Participant state changed: {:?}", &state); - let json = ParticipantResponseMessage::from_state(state).to_string(); - sender.send(Message::Text(json.into())).await?; - }, - }; - } -} diff --git a/http/src/router.rs b/http/src/router.rs deleted file mode 100644 index 73c8ca1..0000000 --- a/http/src/router.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::participant::handler; -use axum::{ - routing::get, - Router, -}; -use client_simulator_browser::auth::HyperSessionCookieManger; -use client_simulator_config::Config; - -#[derive(Clone)] -pub struct AppState { - pub config: Config, - pub cookie_manager: HyperSessionCookieManger, -} - -pub fn create_router(config: Config, cookie_manager: HyperSessionCookieManger) -> Router { - let state = AppState { config, cookie_manager }; - - Router::new() - .route("/healthz", get(healthz)) - .route("/", get(handler)) - .with_state(state) -} - -async fn healthz() -> &'static str { - "Hello!" -} diff --git a/justfile b/justfile index 9f20931..6cabdf7 100644 --- a/justfile +++ b/justfile @@ -14,17 +14,6 @@ run-nix *flags="": # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- -serve *flags="": - cargo run --release --bin client-simulator-http -- {{ flags }} - -serve-dev *flags="": - cargo run --package client-simulator-http -- {{ flags }} - -serve-nix *flags="": - nix run .#client-simulator-http -- {{ flags }} - -# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - stats-gatherer *flags="": cargo run --release --bin client-simulator-stats-gatherer -- {{ flags }} @@ -58,6 +47,5 @@ fetch-cookie username="simulator-user" server-url="http://localhost:8081": cachix-push: nix build --no-link --print-out-paths \ .#client-simulator \ - .#client-simulator-http \ .#client-simulator-stats-gatherer \ | cachix push hyper-video diff --git a/nix/packages.nix b/nix/packages.nix index e4d2ab7..e750975 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -45,14 +45,6 @@ rec { env.LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; }; - client-simulator-http = mkSimulatorPackage { - pname = "client-simulator-http"; - description = "Hyper browser client simulator HTTP server"; - buildInputs = [ openssl clang ]; - cargoBuildFlags = [ "--package" "client-simulator-http" ]; - env.LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; - }; - client-simulator-stats-gatherer = mkSimulatorPackage { pname = "client-simulator-stats-gatherer"; description = "Hyper browser client simulator stats gatherer"; diff --git a/tui/src/tui/components/browser_start.rs b/tui/src/tui/components/browser_start.rs index bbffa69..74cac03 100644 --- a/tui/src/tui/components/browser_start.rs +++ b/tui/src/tui/components/browser_start.rs @@ -643,8 +643,8 @@ impl Component for BrowserStart { } if self.config.remote_url.is_some() { - if let Err(e) = self.participant_store.spawn_remote(&self.config) { - error!(?e, "Failed to spawn remote participant"); + if let Err(e) = self.participant_store.spawn_remote_stub(&self.config) { + error!(?e, "Failed to spawn remote participant stub"); } } else if let Err(e) = self.participant_store.spawn_local(&self.config) { error!(?e, "Failed to spawn local participant"); From 68e102d7a94c402dba82c85d65f2023cc6bc783e Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:15:33 +0100 Subject: [PATCH 5/9] make backend selection explicit --- browser/src/participant/remote_stub.rs | 10 +- config/src/args.rs | 6 -- config/src/client_config.rs | 15 +++ config/src/default-config.yaml | 4 +- config/src/lib.rs | 96 ++++++++--------- config/src/remote_url_option.rs | 24 ----- tui/src/tui/components/browser_start.rs | 135 ++++++++---------------- 7 files changed, 109 insertions(+), 181 deletions(-) delete mode 100644 config/src/remote_url_option.rs diff --git a/browser/src/participant/remote_stub.rs b/browser/src/participant/remote_stub.rs index 9510cb9..2d015e1 100644 --- a/browser/src/participant/remote_stub.rs +++ b/browser/src/participant/remote_stub.rs @@ -18,7 +18,6 @@ pub async fn spawn_remote_stub( participant_config: ParticipantConfig, ) -> Result<()> { let username = participant_config.username.clone(); - let configured_remote_url = participant_config.app_config.remote_url(); state_sender.send_modify(|state| { state.username = username.clone(); @@ -33,13 +32,8 @@ pub async fn spawn_remote_stub( state.screenshare_activated = participant_config.app_config.screenshare_enabled; }); - let backend_message = match configured_remote_url { - Some(url) => format!( - "remote backend is a local stub; no connection will be made and configured remote URL {url} is ignored" - ), - None => "remote backend is a local stub; commands are simulated locally".to_string(), - }; - ParticipantLogMessage::new("warn", &username, backend_message).write(); + ParticipantLogMessage::new("warn", &username, "remote backend is a local stub; commands are simulated locally") + .write(); while let Some(message) = receiver.recv().await { match message { diff --git a/config/src/args.rs b/config/src/args.rs index 85aea3c..a86f88f 100644 --- a/config/src/args.rs +++ b/config/src/args.rs @@ -28,9 +28,6 @@ pub struct TuiArgs { #[clap(long = "headless", action)] pub headless: Option, - /// Optional remote URL for the server running a participant - #[clap(long, value_name = "remote-URL")] - pub remote_url: Option, } mod config_ext { @@ -67,9 +64,6 @@ mod config_ext { if let Some(headless) = &self.headless { cache.insert("headless".to_string(), (*headless).into()); } - if let Some(remote_url) = &self.remote_url { - cache.insert("remote_url".to_string(), remote_url.clone().into()); - } Ok(cache) } } diff --git a/config/src/client_config.rs b/config/src/client_config.rs index d289233..fe46a68 100644 --- a/config/src/client_config.rs +++ b/config/src/client_config.rs @@ -65,3 +65,18 @@ pub enum NoiseSuppression { #[serde(rename = "krisp-medium-with-bvc")] KrispMediumWithBVC, } + +#[derive(Debug, Default, Clone, Copy, Display, EnumIter, EnumString, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum ParticipantBackendKind { + #[default] + Local, + RemoteStub, +} + +impl ParticipantBackendKind { + pub const fn is_local(&self) -> bool { + matches!(self, Self::Local) + } +} diff --git a/config/src/default-config.yaml b/config/src/default-config.yaml index 9bff3ad..b08a1bf 100644 --- a/config/src/default-config.yaml +++ b/config/src/default-config.yaml @@ -24,10 +24,10 @@ fake_media_sources: - description: 'Steve Jobs + British radio (Audio)' fake_media: 'https://audio-samples.hyper.video/steve-jobs-british-radio-bg-50.mp3' headless: false +backend: local audio_enabled: true video_enabled: true -noise_suppresion: none +noise_suppression: none transport: webtransport resolution: auto blur: false -remote_url: diff --git a/config/src/lib.rs b/config/src/lib.rs index 15dde2a..9e6b312 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -7,14 +7,12 @@ mod browser_config; mod client_config; pub mod media; mod participant_config; -pub mod remote_url_option; use crate::{ media::{ FakeMedia, FakeMediaWithDescription, }, - remote_url_option::RemoteUrlOption, }; use app_config::AppConfig; pub use app_config::{ @@ -26,6 +24,7 @@ pub use browser_config::BrowserConfig; pub use client_config::{ NoiseSuppression, NoiseSuppressionIter, + ParticipantBackendKind, TransportMode, TransportModeIter, WebcamResolution, @@ -58,6 +57,8 @@ pub struct Config { pub fake_media_sources: Vec, #[serde(default)] pub headless: bool, + #[serde(default, skip_serializing_if = "ParticipantBackendKind::is_local")] + pub backend: ParticipantBackendKind, #[serde(default)] pub audio_enabled: bool, #[serde(default)] @@ -72,10 +73,6 @@ pub struct Config { pub resolution: WebcamResolution, #[serde(default)] pub blur: bool, - #[serde(default)] - pub remote_url: Option, - #[serde(default)] - pub remote_url_options: Vec, } const DEFAULT_CONFIG: &str = include_str!("default-config.yaml"); @@ -93,13 +90,21 @@ impl config::Source for Config { fn collect(&self) -> Result, config::ConfigError> { let mut cache = HashMap::::new(); + cache.insert("backend".to_string(), self.backend.to_string().into()); if let Some(url) = &self.url { cache.insert("url".to_string(), url.to_string().into()); } - if let Some(url) = &self.remote_url { - cache.insert("remote_url".to_string(), url.to_string().into()); - } cache.insert("headless".to_string(), (self.headless).into()); + cache.insert("audio_enabled".to_string(), self.audio_enabled.into()); + cache.insert("video_enabled".to_string(), self.video_enabled.into()); + cache.insert("screenshare_enabled".to_string(), self.screenshare_enabled.into()); + cache.insert( + "noise_suppression".to_string(), + self.noise_suppression.to_string().into(), + ); + cache.insert("transport".to_string(), self.transport.to_string().into()); + cache.insert("resolution".to_string(), self.resolution.to_string().into()); + cache.insert("blur".to_string(), self.blur.into()); if let Some(value) = self.fake_media_selected { cache.insert("fake_media_selected".to_string(), (value as u64).into()); } @@ -118,24 +123,6 @@ impl config::Source for Config { .into(), ); } - if let Some(remote_url) = &self.remote_url { - cache.insert("remote_url".to_string(), remote_url.to_string().into()); - } - if !self.remote_url_options.is_empty() { - cache.insert( - "remote_url_options".to_string(), - self.remote_url_options - .iter() - .map(|item| { - config::ValueKind::Table(HashMap::from_iter([ - ("description".to_string(), item.description().to_string().into()), - ("url".to_string(), item.url().to_string().into()), - ])) - }) - .collect::>() - .into(), - ); - } Ok(cache) } } @@ -177,17 +164,6 @@ impl Config { self.fake_media_with_description().fake_media().clone() } - pub fn remote_url_option(&self) -> Option { - match (self.remote_url, &self.remote_url_options) { - (Some(selected), sources) => sources.get(selected).cloned(), - _ => None, - } - } - - pub fn remote_url(&self) -> Option { - self.remote_url_option().map(|o| o.url().clone()) - } - pub fn add_custom_fake_media(&mut self, content: String) -> Option { let media = if content.trim().is_empty() { return None; @@ -204,16 +180,6 @@ impl Config { } } - pub fn add_remote_url(&mut self, content: String) -> Option { - let url = match url::Url::parse(&content) { - Ok(url) => url, - Err(_) => return None, - }; - let option = RemoteUrlOption::new(url, None); - self.remote_url_options.push(option); - Some(self.remote_url_options.len() - 1) - } - pub fn data_dir(&self) -> &Path { &self.app_config.data_dir } @@ -232,9 +198,6 @@ impl Config { if self.url == default.url { clone.url = None; } - if self.remote_url == default.remote_url { - clone.remote_url = None; - } std::fs::create_dir_all(&self.app_config.config_dir).context("Failed to create config directory")?; let path = self.app_config.config_dir.join("config.yaml"); @@ -277,3 +240,34 @@ impl Config { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loads_old_config_file_without_remote_url_fields() { + let config: Config = config::Config::builder() + .add_source(Config::default()) + .add_source(config::File::from_str( + r#" +url: https://example.com/space/demo +remote_url: 0 +remote_url_options: + - description: old worker + url: https://remote.example.com +"#, + config::FileFormat::Yaml, + )) + .build() + .expect("failed to build config") + .try_deserialize() + .expect("failed to deserialize config"); + + assert_eq!( + config.url, + Some(url::Url::parse("https://example.com/space/demo").expect("valid url")) + ); + assert_eq!(config.backend, ParticipantBackendKind::Local); + } +} diff --git a/config/src/remote_url_option.rs b/config/src/remote_url_option.rs deleted file mode 100644 index 8f09313..0000000 --- a/config/src/remote_url_option.rs +++ /dev/null @@ -1,24 +0,0 @@ -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct RemoteUrlOption { - url: url::Url, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, -} - -impl RemoteUrlOption { - pub fn new(url: url::Url, description: Option) -> Self { - Self { url, description } - } - - pub fn url(&self) -> &url::Url { - &self.url - } - - pub fn description(&self) -> &str { - if let Some(description) = self.description.as_ref() { - return description.as_str(); - } - - "" - } -} diff --git a/tui/src/tui/components/browser_start.rs b/tui/src/tui/components/browser_start.rs index 74cac03..632d80f 100644 --- a/tui/src/tui/components/browser_start.rs +++ b/tui/src/tui/components/browser_start.rs @@ -17,6 +17,7 @@ use client_simulator_browser::participant::ParticipantStore; use client_simulator_config::{ Config, NoiseSuppression, + ParticipantBackendKind, TransportMode, WebcamResolution, }; @@ -48,8 +49,8 @@ enum SelectedField { Resolution, BackgroundBlur, Headless, + Backend, StartBrowser, - RemoteUrl, } impl SelectedField { @@ -67,8 +68,8 @@ impl SelectedField { SelectedField::Resolution => " Select resolution for video (camera). to select. ", SelectedField::BackgroundBlur => " Enable background blur? to toggle. ", SelectedField::Headless => " Run the browser in headless mode? When disabled, will show a browser window with which you can interact. to toggle. ", + SelectedField::Backend => " Select the participant backend. to select, to reset. ", SelectedField::StartBrowser => " Start a new browser session and join a hyper.video session. to start. ", - SelectedField::RemoteUrl => " URL to a remote server to spawn remote participants. to edit, to clear. ", } } } @@ -82,7 +83,7 @@ pub(crate) enum BrowserStartAction { StartSelectNoiseSuppression, StartSelectTransport, StartSelectResolution, - StartSelectRemoteUrl, + StartSelectBackend, StartBrowser, Toggle, DeleteSelectedField, @@ -94,14 +95,6 @@ enum FakeMediaWithDescriptionItem { Select, } -#[derive(Debug, Clone)] -enum RemoteUrlWithDescriptionItem { - Add, - Select, -} - -// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - #[derive(Debug)] pub struct BrowserStart { focused: bool, @@ -115,7 +108,7 @@ pub struct BrowserStart { noise_suppression_list: Option>, transport_list: Option>, resolution_list: Option>, - remote_url_list: Option>, + backend_list: Option>, participant_store: ParticipantStore, } @@ -132,7 +125,7 @@ impl BrowserStart { noise_suppression_list: None, resolution_list: None, transport_list: None, - remote_url_list: None, + backend_list: None, editing: None, participant_store, } @@ -166,10 +159,6 @@ impl Component for BrowserStart { let content = editing.editor.finish(); match editing.field { SelectedField::Url => self.config.url = url::Url::parse(&content).ok(), - SelectedField::RemoteUrl => { - let index = self.config.add_remote_url(content); - self.config.remote_url = index; - } SelectedField::FakeMedia => { let index = self.config.add_custom_fake_media(content); self.config.fake_media_selected = index; @@ -182,6 +171,7 @@ impl Component for BrowserStart { | SelectedField::Resolution | SelectedField::BackgroundBlur | SelectedField::Headless + | SelectedField::Backend | SelectedField::StartBrowser => {} } // Save config immediately after edit confirmation @@ -329,30 +319,17 @@ impl Component for BrowserStart { } } - if let Some(mut list) = self.remote_url_list.take() { + if let Some(mut list) = self.backend_list.take() { match key.code { - KeyCode::Delete | KeyCode::Backspace => { - if let Some(index) = list.finish().and_then(|(index, _)| (index > 0).then(|| index - 1)) { - self.config.remote_url_options.remove(index); - } - return Ok(Some(Action::BrowserStartAction( - BrowserStartAction::StartSelectRemoteUrl, - ))); - } KeyCode::Enter => { - let content = list.finish(); - if let Some((index, media)) = content { - match media { - RemoteUrlWithDescriptionItem::Add => { - return Ok(Some(Action::BrowserStartAction(BrowserStartAction::StartEditText))); - } - RemoteUrlWithDescriptionItem::Select => { - self.config.remote_url = Some(index - 1); - } + match list.finish() { + Ok(value) => { + self.config.backend = value; } - } else { - self.config.remote_url = None; - }; + Err(err) => { + error!(?err, "Failed to parse"); + } + } if let Err(e) = self.config.save() { error!(?e, "Failed to save config after edit"); } @@ -364,7 +341,7 @@ impl Component for BrowserStart { _ => {} } let handled = list.handle_key_event(key); - self.remote_url_list = Some(list); + self.backend_list = Some(list); if handled { return Ok(None); } @@ -393,14 +370,12 @@ impl Component for BrowserStart { Some(BrowserStartAction::StartSelectResolution) } KeyCode::Enter if self.selected == SelectedField::BackgroundBlur => Some(BrowserStartAction::Toggle), + KeyCode::Enter if self.selected == SelectedField::Backend => Some(BrowserStartAction::StartSelectBackend), KeyCode::Enter if self.selected == SelectedField::FakeMedia => { Some(BrowserStartAction::StartSelectFakeMedia) } KeyCode::Enter if self.selected == SelectedField::Url => Some(BrowserStartAction::StartEditText), - KeyCode::Enter if self.selected == SelectedField::RemoteUrl => { - Some(BrowserStartAction::StartSelectRemoteUrl) - } KeyCode::Esc if self.fake_media_builtin_list.is_some() => { self.fake_media_builtin_list = None; @@ -418,8 +393,8 @@ impl Component for BrowserStart { self.transport_list = None; None } - KeyCode::Esc if self.remote_url_list.is_some() => { - self.remote_url_list = None; + KeyCode::Esc if self.backend_list.is_some() => { + self.backend_list = None; None } @@ -471,8 +446,8 @@ impl Component for BrowserStart { SelectedField::Resolution => SelectedField::Transport, SelectedField::BackgroundBlur => SelectedField::Resolution, SelectedField::Headless => SelectedField::BackgroundBlur, - SelectedField::RemoteUrl => SelectedField::Headless, - SelectedField::StartBrowser => SelectedField::RemoteUrl, + SelectedField::Backend => SelectedField::Headless, + SelectedField::StartBrowser => SelectedField::Backend, }; } @@ -487,8 +462,8 @@ impl Component for BrowserStart { SelectedField::Transport => SelectedField::Resolution, SelectedField::Resolution => SelectedField::BackgroundBlur, SelectedField::BackgroundBlur => SelectedField::Headless, - SelectedField::Headless => SelectedField::RemoteUrl, - SelectedField::RemoteUrl => SelectedField::StartBrowser, + SelectedField::Headless => SelectedField::Backend, + SelectedField::Backend => SelectedField::StartBrowser, SelectedField::StartBrowser => return Ok(Some(Action::Activate(ActivateAction::Participants))), }; } @@ -506,16 +481,6 @@ impl Component for BrowserStart { .unwrap_or_default() .to_string(), ), - SelectedField::RemoteUrl => ( - "Edit remote URL", - "URL to a remote server to spawn remote participants", - self.config - .remote_url - .as_ref() - .map(|url| url.to_string()) - .unwrap_or_default() - .to_string(), - ), SelectedField::FakeMedia => { let content = self.config.fake_media().to_string(); ("Edit Fake Media", "Fake media from file", content) @@ -582,20 +547,11 @@ impl Component for BrowserStart { return Ok(None); } - BrowserStartAction::StartSelectRemoteUrl => { - let items = [("".to_string(), RemoteUrlWithDescriptionItem::Add)] - .into_iter() - .chain( - self.config - .remote_url_options - .clone() - .into_iter() - .map(|url| (url.url().to_string(), RemoteUrlWithDescriptionItem::Select)), - ); - self.remote_url_list = Some(ListInput::new( - "Remote URL options", - items, - self.config.remote_url.map(|index| index + 1), + BrowserStartAction::StartSelectBackend => { + self.backend_list = Some(EnumListInput::new( + "Participant backend", + ParticipantBackendKind::iter(), + self.config.backend, )); return Ok(None); } @@ -606,9 +562,7 @@ impl Component for BrowserStart { SelectedField::FakeMedia => { self.config.fake_media_selected = Some(0); } - SelectedField::RemoteUrl => { - self.config.remote_url = None; - } + SelectedField::Backend => self.config.backend = ParticipantBackendKind::default(), _ => return Ok(None), } save_config = true; @@ -642,12 +596,17 @@ impl Component for BrowserStart { return Ok(None); } - if self.config.remote_url.is_some() { - if let Err(e) = self.participant_store.spawn_remote_stub(&self.config) { - error!(?e, "Failed to spawn remote participant stub"); + match self.config.backend { + ParticipantBackendKind::Local => { + if let Err(e) = self.participant_store.spawn_local(&self.config) { + error!(?e, "Failed to spawn local participant"); + } + } + ParticipantBackendKind::RemoteStub => { + if let Err(e) = self.participant_store.spawn_remote_stub(&self.config) { + error!(?e, "Failed to spawn remote participant stub"); + } } - } else if let Err(e) = self.participant_store.spawn_local(&self.config) { - error!(?e, "Failed to spawn local participant"); } return Ok(Some(Action::ParticipantCountChanged(self.participant_store.len()))); @@ -693,7 +652,7 @@ impl Component for BrowserStart { Constraint::Length(1), // Resolution Constraint::Length(1), // Background blur checkbox Constraint::Length(1), // Headless checkbox - Constraint::Length(1), // Remote server URL + Constraint::Length(1), // Backend Constraint::Length(3), // Start button ]) .split(area); @@ -713,7 +672,7 @@ impl Component for BrowserStart { "Resolution:", "Background blur", "Headless:", - "Remote server URL:", + "Backend:", "Start browser", ]; let max_length = form_labels.iter().map(|s| s.len()).max().unwrap_or(0) + 1; @@ -835,17 +794,13 @@ impl Component for BrowserStart { frame.render_widget(widget, rows[current_row_index]); current_row_index += 1; - // --- Remote URL Checkbox --- - let content = self - .config - .remote_url() - .map(|u| u.to_string()) - .unwrap_or_else(|| "".to_string()); + // --- Backend --- + let content = self.config.backend.to_string(); let widget = widgets::label_and_text( form_labels[current_row_index], content, max_length, - self.focused && self.selected == SelectedField::RemoteUrl, + self.focused && self.selected == SelectedField::Backend, &theme, ); frame.render_widget(widget, rows[current_row_index]); @@ -879,7 +834,7 @@ impl Component for BrowserStart { if let Some(list) = &mut self.transport_list { list.draw(frame, area)?; } - if let Some(list) = &mut self.remote_url_list { + if let Some(list) = &mut self.backend_list { list.draw(frame, area)?; } From ad2788499890ac76141632933991d72207b269b6 Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:20:57 +0100 Subject: [PATCH 6/9] extract shared frontend runtime --- browser/src/participant/frontend.rs | 329 +++++++++++++++ browser/src/participant/inner.rs | 531 ++++++++----------------- browser/src/participant/inner_lite.rs | 472 +++++++--------------- browser/src/participant/mod.rs | 38 +- browser/src/participant/remote_stub.rs | 24 +- config/src/args.rs | 1 - config/src/lib.rs | 8 +- config/src/participant_config.rs | 7 - 8 files changed, 686 insertions(+), 724 deletions(-) create mode 100644 browser/src/participant/frontend.rs diff --git a/browser/src/participant/frontend.rs b/browser/src/participant/frontend.rs new file mode 100644 index 0000000..756b0c7 --- /dev/null +++ b/browser/src/participant/frontend.rs @@ -0,0 +1,329 @@ +use super::{ + messages::{ + ParticipantLogMessage, + ParticipantMessage, + }, + ParticipantState, +}; +use crate::create_browser; +use chromiumoxide::{ + cdp::browser_protocol::target::CreateTargetParams, + Browser, + Element, + Handler, + Page, +}; +use client_simulator_config::{ + BrowserConfig, + NoiseSuppression, + ParticipantConfig, + WebcamResolution, +}; +use eyre::{ + bail, + Context as _, + ContextCompat as _, + Result, +}; +use futures::{ + future::BoxFuture, + StreamExt as _, +}; +use tokio::{ + sync::{ + mpsc::{ + UnboundedReceiver, + UnboundedSender, + }, + watch, + }, + task::JoinHandle, +}; +use url::Url; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResolvedFrontendKind { + HyperCore, + HyperLite, +} + +impl ResolvedFrontendKind { + pub(super) fn from_session_url(session_url: &Url) -> Self { + let path = session_url.path(); + if path == "/m" || path.starts_with("/m/") { + Self::HyperLite + } else { + Self::HyperCore + } + } +} + +#[derive(Debug)] +pub(super) struct DriverContext { + pub(super) participant_config: ParticipantConfig, + pub(super) page: Page, + pub(super) state: watch::Sender, + pub(super) sender: UnboundedSender, +} + +impl DriverContext { + pub(super) fn participant_name(&self) -> &str { + &self.participant_config.username + } + + pub(super) fn send_log_message(&self, level: &str, message: impl ToString) { + if let Err(err) = self + .sender + .send(ParticipantLogMessage::new(level, self.participant_name(), message)) + { + trace!("Failed to send log message: {err}"); + } + } + + pub(super) async fn find_element(&self, selector: &str) -> Result { + self.page + .find_element(selector) + .await + .context(format!("Could not find the {selector} element")) + } +} + +pub(super) trait FrontendDriver { + fn participant_name(&self) -> &str; + fn state(&self) -> &watch::Sender; + fn log_message(&self, level: &str, message: String); + fn join(&mut self) -> BoxFuture<'_, Result<()>>; + fn leave(&self) -> BoxFuture<'_, Result<()>>; + fn close(self, browser: Browser) -> BoxFuture<'static, Result<()>> + where + Self: Sized; + fn toggle_audio(&self) -> BoxFuture<'_, Result<()>>; + fn toggle_video(&self) -> BoxFuture<'_, Result<()>>; + fn toggle_screen_share(&self) -> BoxFuture<'_, Result<()>>; + fn set_webcam_resolutions(&self, value: WebcamResolution) -> BoxFuture<'_, Result<()>>; + fn set_noise_suppression(&self, value: NoiseSuppression) -> BoxFuture<'_, Result<()>>; + fn toggle_background_blur(&self) -> BoxFuture<'_, Result<()>>; + fn update_state(&self) -> BoxFuture<'_, ()>; +} + +pub(super) async fn run_local_participant( + participant_config: ParticipantConfig, + receiver: UnboundedReceiver, + sender: UnboundedSender, + state: watch::Sender, + make_driver: F, +) -> Result<()> +where + D: FrontendDriver, + F: FnOnce(DriverContext) -> D, +{ + let participant_name = participant_config.username.clone(); + let (mut browser, handler) = create_browser(&BrowserConfig::from(&participant_config)).await?; + let browser_event_task_handle = drive_browser_events(&participant_name, handler); + let page = create_page_retry(&participant_config, &mut browser).await?; + + state.send_modify(|state| { + state.username = participant_name.clone(); + }); + + let driver = make_driver(DriverContext { + participant_config, + page, + state: state.clone(), + sender, + }); + + handle_actions(browser, receiver, driver) + .await + .context("failed to handle actions")?; + + browser_event_task_handle.await?; + + Ok(()) +} + +fn drive_browser_events(name: &str, mut handler: Handler) -> JoinHandle<()> { + let name = name.to_string(); + tokio::task::spawn(async move { + while let Some(event) = handler.next().await { + if let Err(err) = event { + if err.to_string().contains("ResetWithoutClosingHandshake") { + error!(participant = %name, "Browser unexpectedly closed"); + break; + } + error!(participant = %name, "error in browser handler: {err:?}"); + } + } + debug!(participant = %name, "Browser event handler stopped"); + }) +} + +async fn handle_actions( + mut browser: Browser, + mut receiver: UnboundedReceiver, + mut driver: D, +) -> Result<()> +where + D: FrontendDriver, +{ + driver.state().send_modify(|state| { + state.running = true; + }); + + if let Err(err) = driver.join().await { + error!( + participant = %driver.participant_name(), + "Failed joining the session when starting the browser {err}" + ); + driver.log_message( + "error", + format!("Failed joining the session when starting the browser {err}"), + ); + kill_browser(&mut browser, &driver).await; + driver.state().send_modify(|state| { + state.running = false; + }); + return Ok(()); + } + + let mut detached_event = browser + .event_listener::() + .await + .expect("failed to create event listener"); + + loop { + let message = tokio::select! { + biased; + + Some(_) = detached_event.next() => { + warn!(participant = %driver.participant_name(), "Browser unexpectedly closed"); + kill_browser(&mut browser, &driver).await; + break; + }, + + Some(message) = receiver.recv() => { + message + } + }; + + let result = match message { + ParticipantMessage::Join => driver.join().await, + ParticipantMessage::Leave => driver.leave().await, + ParticipantMessage::Close => return driver.close(browser).await, + ParticipantMessage::ToggleAudio => driver.toggle_audio().await, + ParticipantMessage::ToggleVideo => driver.toggle_video().await, + ParticipantMessage::ToggleScreenshare => driver.toggle_screen_share().await, + ParticipantMessage::SetWebcamResolutions(value) => driver.set_webcam_resolutions(value).await, + ParticipantMessage::SetNoiseSuppression(value) => driver.set_noise_suppression(value).await, + ParticipantMessage::ToggleBackgroundBlur => driver.toggle_background_blur().await, + }; + + if let Err(err) = result { + error!( + participant = %driver.participant_name(), + "Running action {message} failed with error: {err}." + ); + driver.log_message("error", format!("Running action {message} failed with error: {err}.")); + } + + driver.update_state().await; + } + + driver.state().send_modify(|state| { + state.running = false; + }); + + Ok(()) +} + +async fn kill_browser(browser: &mut Browser, driver: &D) +where + D: FrontendDriver, +{ + match browser.kill().await { + Some(Ok(_)) => { + debug!(participant = %driver.participant_name(), "browser killed"); + driver.log_message("debug", "browser killed".to_string()); + } + Some(Err(err)) => { + error!(participant = %driver.participant_name(), "failed to kill browser: {err}"); + driver.log_message("error", format!("failed to kill browser: {err}")); + } + None => { + debug!(participant = %driver.participant_name(), "browser process not found"); + driver.log_message("debug", "browser process not found".to_string()); + } + } +} + +async fn create_page(config: &ParticipantConfig, browser: &mut Browser) -> Result { + let page = if let Ok(Some(page)) = browser + .pages() + .await + .context("failed to get pages") + .map(|pages| pages.into_iter().next()) + { + page.goto(config.session_url.to_string()) + .await + .context("failed to navigate to session_url")?; + page + } else { + browser + .new_page( + CreateTargetParams::builder() + .url(config.session_url.to_string()) + .build() + .map_err(|e| eyre::eyre!(e))?, + ) + .await + .context("failed to create new page")? + }; + + let navigation = page + .wait_for_navigation_response() + .await + .context("Page could not navigate to session_url")? + .with_context(|| { + format!( + "{}: No request returned when creating a page for {}", + config.username, config.session_url, + ) + })?; + + if let Some(text) = &navigation.failure_text { + bail!( + "{}: When creating a new page request got a failure: {}", + config.username, + text + ); + } + + debug!(participant = %config.username, "Created a new page for {}", config.session_url); + + Ok(page) +} + +async fn create_page_retry(config: &ParticipantConfig, browser: &mut Browser) -> Result { + let mut backoff = maybe_backoff::MaybeBackoff::default(); + let mut attempt = 0; + loop { + backoff.sleep().await; + match create_page(config, browser).await { + Ok(page) => return Ok(page), + Err(_) if attempt < 5 => { + attempt += 1; + backoff.arm(); + warn!(participant = %config.username, ?attempt, "Failed to create a new page, retrying..."); + } + Err(err) => return Err(err), + } + } +} + +pub(super) async fn element_state(el: &Element) -> Option { + el.attribute("data-test-state") + .await + .ok() + .unwrap_or(None) + .map(|value| value == "true") +} diff --git a/browser/src/participant/inner.rs b/browser/src/participant/inner.rs index 6c08007..803ba0e 100644 --- a/browser/src/participant/inner.rs +++ b/browser/src/participant/inner.rs @@ -7,15 +7,19 @@ use super::{ get_outgoing_camera_resolution, set_noise_suppression, }, + frontend::{ + element_state, + run_local_participant, + DriverContext, + FrontendDriver, + }, messages::ParticipantMessage, - ParticipantState, }; use crate::{ auth::{ BorrowedCookie, HyperSessionCookieManger, }, - create_browser, participant::{ commands::{ get_background_blur, @@ -23,20 +27,15 @@ use crate::{ set_force_webrtc, set_outgoing_camera_resolution, }, - messages::ParticipantLogMessage, selectors::classic, }, wait_for_element, }; use chromiumoxide::{ - cdp::browser_protocol::target::CreateTargetParams, Browser, Element, - Handler, - Page, }; use client_simulator_config::{ - BrowserConfig, Config, NoiseSuppression, ParticipantConfig, @@ -44,35 +43,26 @@ use client_simulator_config::{ WebcamResolution, }; use eyre::{ - bail, Context as _, - ContextCompat as _, Result, }; -use futures::StreamExt as _; +use futures::{ + future::BoxFuture, + FutureExt as _, +}; use std::time::Duration; -use tokio::{ - sync::{ - mpsc::{ - UnboundedReceiver, - UnboundedSender, - }, - watch, +use tokio::sync::{ + mpsc::{ + UnboundedReceiver, + UnboundedSender, }, - task::JoinHandle, + watch, }; -/// Async participant "worker" that holds the browser and session. -/// It has the direct command to the browser session that can modify the -/// participant behavior in the space. -/// It holds the message receiver that is used to handle the incoming messages -/// from the sync TUI runtime. +/// Frontend driver for the hyper.video ("hyper core") UI. #[derive(Debug)] pub(super) struct ParticipantInner { - participant_config: ParticipantConfig, - page: Page, - state: watch::Sender, - sender: UnboundedSender, + context: DriverContext, auth: BorrowedCookie, } @@ -83,8 +73,8 @@ impl ParticipantInner { cookie: Option, cookie_manager: HyperSessionCookieManger, receiver: UnboundedReceiver, - sender: UnboundedSender, - state: watch::Sender, + sender: UnboundedSender, + state: watch::Sender, ) -> Result<()> { let auth = if let Some(cookie) = cookie { cookie @@ -94,260 +84,54 @@ impl ParticipantInner { .await? }; - let (mut browser, handler) = create_browser(&BrowserConfig::from(&participant_config)).await?; - - let browser_event_task_handle = Self::drive_browser_events(&participant_config.username, handler); - - let page = Self::create_page_retry(&participant_config, &mut browser).await?; - - state.send_modify(|state| { - state.username = participant_config.username.clone(); - }); - - let participant = Self { - participant_config, - page, - state: state.clone(), - sender, + run_local_participant(participant_config, receiver, sender, state, move |context| Self { + context, auth, - }; - - participant - .handle_actions(browser, receiver) - .await - .context("failed to handle actions")?; - - browser_event_task_handle.await?; - - Ok(()) - } - - fn drive_browser_events(name: impl ToString, mut handler: Handler) -> JoinHandle<()> { - let name = name.to_string(); - tokio::task::spawn(async move { - while let Some(event) = handler.next().await { - if let Err(err) = event { - if err.to_string().contains("ResetWithoutClosingHandshake") { - error!(name, "Browser unexpectedly closed"); - break; - } - error!(name, "error in browser handler: {err:?}"); - } - } - debug!(name, "Browser event handler stopped"); }) + .await } - async fn handle_actions( - mut self, - mut browser: Browser, - mut receiver: UnboundedReceiver, - ) -> Result<()> { - self.state.send_modify(|state| { - state.running = true; - }); - - if let Err(err) = self.join().await { - error!("Failed joining the session when starting the browser {err}"); - self.send_log_message( - "error", - format!("Failed joining the session when starting the browser {err}"), - ); - - match browser.kill().await { - Some(Ok(_)) => { - debug!("browser killed"); - self.send_log_message("debug", "browser killed"); - } - Some(Err(err)) => { - error!("failed to kill browser: {err}"); - self.send_log_message("error", format!("failed to kill browser: {err}")); - } - None => debug!("browser process not found"), - }; - self.state.send_modify(|state| { - state.running = false; - }); - return Ok(()); - } - - let mut detached_event = browser - .event_listener::() - .await - .expect("failed to create event listener"); - - loop { - let message = tokio::select! { - biased; - - // Event is fired when the page is closed by the user - Some(_) = detached_event.next() => { - warn!(self.participant_config.username, "Browser unexpectedly closed"); - match browser.kill().await { - Some(Ok(_)) => { - debug!("browser killed"); - self.send_log_message("debug", "browser killed"); - }, - Some(Err(err)) => { - error!("failed to kill browser: {err}"); - self.send_log_message("error", format!("failed to kill browser: {err}")); - }, - None => { - debug!("browser process not found"); - self.send_log_message("debug", "browser process not found"); - }, - } - break; - }, - - Some(message) = receiver.recv() => { - message - } - }; - - if let Err(e) = match message { - ParticipantMessage::Join => self.join().await, - ParticipantMessage::Leave => self.leave().await, - ParticipantMessage::Close => { - self.close(browser).await?; - return Ok(()); - } - ParticipantMessage::ToggleAudio => self.toggle_audio().await, - ParticipantMessage::ToggleVideo => self.toggle_video().await, - ParticipantMessage::ToggleScreenshare => self.toggle_screen_share().await, - ParticipantMessage::SetWebcamResolutions(value) => self.set_webcam_resolutions(value).await, - ParticipantMessage::SetNoiseSuppression(value) => self.set_noise_suppression(value).await, - ParticipantMessage::ToggleBackgroundBlur => self.toggle_background_blur().await, - } { - error!("Running action {message} failed with error: {e}."); - self.send_log_message("error", format!("Running action {message} failed with error: {e}.")); - } - - self.update_state().await; - } - - self.state.send_modify(|state| { - state.running = false; - }); - - Ok(()) - } - - fn send_log_message(&self, level: &str, message: impl ToString) { - if let Err(err) = self.sender.send(ParticipantLogMessage::new( - level, - &self.participant_config.username, - message, - )) { - trace!("Failed to send log message: {err}"); - } - } - - async fn create_page(config: &ParticipantConfig, browser: &mut Browser) -> Result { - let page = if let Ok(Some(page)) = browser - .pages() - .await - .context("failed to get pages") - .map(|pages| pages.into_iter().next()) - { - page.goto(config.session_url.to_string()) - .await - .context("failed to navigate to session_url")?; - page - } else { - browser - .new_page( - CreateTargetParams::builder() - .url(config.session_url.to_string()) - .build() - .map_err(|e| eyre::eyre!(e))?, - ) - .await - .context("failed to create new page")? - }; - - let navigation = page - .wait_for_navigation_response() - .await - .context("Page could not navigate to session_url")? - .with_context(|| { - format!( - "{}: No request returned when creating a page for {}", - config.username, config.session_url, - ) - })?; - - if let Some(text) = &navigation.failure_text { - bail!( - "{}: When creating a new page request got a failure: {}", - config.username, - text - ); - } - - debug!(config.username, "Created a new page for the {}", config.session_url); - - Ok(page) - } - - async fn create_page_retry(config: &ParticipantConfig, browser: &mut Browser) -> Result { - let mut backoff = maybe_backoff::MaybeBackoff::default(); - let mut attempt = 0; - loop { - backoff.sleep().await; - match Self::create_page(config, browser).await { - Ok(page) => return Ok(page), - Err(_) if attempt < 5 => { - attempt += 1; - backoff.arm(); - warn!(?attempt, "Failed to create a new page, retrying..."); - } - Err(err) => return Err(err), - } - } - } -} - -impl ParticipantInner { async fn set_cookie(&self) -> Result<()> { - let domain = self.participant_config.session_url.host_str().unwrap_or("localhost"); - + let domain = self + .context + .participant_config + .session_url + .host_str() + .unwrap_or("localhost"); let cookie = self.auth.as_browser_cookie_for(domain)?; - self.page + self.context + .page .set_cookies(vec![cookie]) .await .context("failed to set cookie")?; - debug!(self.participant_config.username, "Set cookie"); - self.send_log_message("debug", format!("Set cookie for domain {domain}")); + debug!(participant = %self.participant_name(), "Set cookie"); + self.context + .send_log_message("debug", format!("Set cookie for domain {domain}")); Ok(()) } - async fn join(&mut self) -> Result<()> { - if self.state.borrow().joined { - warn!("Already joined."); - self.send_log_message("warn", "Already joined."); + async fn join_session(&mut self) -> Result<()> { + if self.context.state.borrow().joined { + warn!(participant = %self.participant_name(), "Already joined."); + self.context.send_log_message("warn", "Already joined."); return Ok(()); } - // Create a new page if none exists self.set_cookie().await?; - // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - // Navigate to the session URL - self.page - .goto(self.participant_config.session_url.to_string()) + self.context + .page + .goto(self.context.participant_config.session_url.to_string()) .await .context("failed to wait for navigation response")?; - debug!(self.participant_config.username, "Navigated to page"); - self.send_log_message("debug", "Navigated to page"); + debug!(participant = %self.participant_name(), "Navigated to page"); + self.context.send_log_message("debug", "Navigated to page"); - // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - // Find the input box to enter the name - let input = wait_for_element(&self.page, classic::NAME_INPUT, Duration::from_secs(30)) + let input = wait_for_element(&self.context.page, classic::NAME_INPUT, Duration::from_secs(30)) .await .context("failed to find input name field")?; input @@ -358,48 +142,43 @@ impl ParticipantInner { .await .context("failed to empty current name")?; input - .type_str(&self.participant_config.username) + .type_str(&self.context.participant_config.username) .await .context("failed to insert name")?; - debug!(self.participant_config.username, "Set the name of the participant"); - self.send_log_message( + debug!(participant = %self.participant_name(), "Set the name of the participant"); + self.context.send_log_message( "debug", format!( "Set the name of the participant to {}", - self.participant_config.username + self.context.participant_config.username ), ); - // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - // Find the join button - wait_for_element(&self.page, classic::JOIN_BUTTON, Duration::from_secs(30)).await?; + wait_for_element(&self.context.page, classic::JOIN_BUTTON, Duration::from_secs(30)).await?; if let Err(err) = self.apply_all_settings(true).await { - error!("Failed to apply settings before joining: {err}"); + error!(participant = %self.participant_name(), "Failed to apply settings before joining: {err}"); } - // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - // Click the join button - self.find_element(classic::JOIN_BUTTON) + self.context + .find_element(classic::JOIN_BUTTON) .await? .click() .await .context("failed to click join button")?; - debug!(self.participant_config.username, "Clicked on the join button"); - self.send_log_message("debug", "Clicked on the join button"); + debug!(participant = %self.participant_name(), "Clicked on the join button"); + self.context.send_log_message("debug", "Clicked on the join button"); - // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - // Ensure we have joined the space. - wait_for_element(&self.page, classic::LEAVE_BUTTON, Duration::from_secs(30)) + wait_for_element(&self.context.page, classic::LEAVE_BUTTON, Duration::from_secs(30)) .await .context("We haven't joined the space, cannot find the leave button")?; - info!(self.participant_config.username, "Joined the space"); - self.send_log_message("info", "Joined the space"); + info!(participant = %self.participant_name(), "Joined the space"); + self.context.send_log_message("info", "Joined the space"); - self.update_state().await; + self.update_state_inner().await; Ok(()) } @@ -414,175 +193,165 @@ impl ParticipantInner { video_enabled, screenshare_enabled, .. - } = &self.participant_config.app_config; - set_noise_suppression(&self.page, *noise_suppression) + } = &self.context.participant_config.app_config; + + set_noise_suppression(&self.context.page, *noise_suppression) .await .context("failed to set noise suppression")?; - set_background_blur(&self.page, *blur) + set_background_blur(&self.context.page, *blur) .await .context("failed to set background blur")?; - set_outgoing_camera_resolution(&self.page, resolution) + set_outgoing_camera_resolution(&self.context.page, resolution) .await .context("failed to set outgoing camera resolution")?; - set_force_webrtc(&self.page, transport == &TransportMode::WebRTC) + set_force_webrtc(&self.context.page, transport == &TransportMode::WebRTC) .await .context("failed to set transport mode")?; if !audio_enabled { - self.toggle_audio().await?; + self.toggle_audio_inner().await?; } if !video_enabled { - self.toggle_video().await?; + self.toggle_video_inner().await?; } if !in_lobby && *screenshare_enabled { - self.toggle_screen_share().await?; + self.toggle_screen_share_inner().await?; } Ok(()) } - async fn toggle_screen_share(&self) -> Result<()> { - self.screen_share_button() - .await? - .click() - .await - .context("Could not click on the toggle screen share button") - .map(|_| ()) - } - - async fn leave(&self) -> Result<()> { + async fn leave_session(&self) -> Result<()> { self.leave_button() .await? .click() .await .context("Could not click on the leave space button")?; - info!(self.participant_config.username, "Left the space"); - self.send_log_message("info", "Left the space"); + info!(participant = %self.participant_name(), "Left the space"); + self.context.send_log_message("info", "Left the space"); Ok(()) } - async fn close(self, mut browser: Browser) -> Result<()> { - debug!(self.participant_config.username, "Closing the browser..."); + async fn close_browser(self, mut browser: Browser) -> Result<()> { + debug!(participant = %self.participant_name(), "Closing the browser..."); - if let Err(err) = self.leave().await { + if let Err(err) = self.leave_session().await { error!( - self.participant_config.username, - "Failed leaving space while closing browser: {}", err + participant = %self.participant_name(), + "Failed leaving space while closing browser: {err}" ); - self.send_log_message("error", format!("Failed leaving space while closing browser: {}", err)); + self.context + .send_log_message("error", format!("Failed leaving space while closing browser: {err}")); } - if let Err(err) = self.page.clone().close().await { - error!("Error closing page: {err}"); - self.send_log_message("error", format!("Error closing page: {err}")); + if let Err(err) = self.context.page.clone().close().await { + error!(participant = %self.participant_name(), "Error closing page: {err}"); + self.context + .send_log_message("error", format!("Error closing page: {err}")); } browser.close().await?; browser.wait().await?; - info!(self.participant_config.username, "Closed the browser"); - self.send_log_message("info", "Closed the browser"); + info!(participant = %self.participant_name(), "Closed the browser"); + self.context.send_log_message("info", "Closed the browser"); - self.state.send_modify(|state| { + self.context.state.send_modify(|state| { state.running = false; }); Ok(()) } - async fn toggle_audio(&self) -> Result<()> { + async fn toggle_audio_inner(&self) -> Result<()> { self.mute_button() .await? .click() .await .context("Could not click on the toggle audio button")?; - info!(self.participant_config.username, "Toggled audio"); - self.send_log_message("info", "Toggled audio"); - self.update_state().await; + info!(participant = %self.participant_name(), "Toggled audio"); + self.context.send_log_message("info", "Toggled audio"); Ok(()) } - async fn toggle_video(&self) -> Result<()> { + async fn toggle_video_inner(&self) -> Result<()> { self.camera_button() .await? .click() .await .context("Could not click on the toggle camera button")?; - info!(self.participant_config.username, "Toggled camera"); - self.send_log_message("info", "Toggled camera"); - self.update_state().await; + info!(participant = %self.participant_name(), "Toggled camera"); + self.context.send_log_message("info", "Toggled camera"); Ok(()) } - async fn set_webcam_resolutions(&self, value: WebcamResolution) -> Result<()> { - debug!(self.participant_config.username, "Changing to {value} resolution"); + async fn toggle_screen_share_inner(&self) -> Result<()> { + self.screen_share_button() + .await? + .click() + .await + .context("Could not click on the toggle screen share button") + .map(|_| ()) + } + + async fn set_webcam_resolutions_inner(&self, value: WebcamResolution) -> Result<()> { + debug!(participant = %self.participant_name(), "Changing to {value} resolution"); - set_outgoing_camera_resolution(&self.page, &value) + set_outgoing_camera_resolution(&self.context.page, &value) .await .context("Failed to set outgoing camera resolution")?; - self.update_state().await; + Ok(()) } - async fn set_noise_suppression(&self, value: NoiseSuppression) -> Result<()> { + async fn set_noise_suppression_inner(&self, value: NoiseSuppression) -> Result<()> { info!( - self.participant_config.username, + participant = %self.participant_name(), "Changing noise suppression to {value}" ); - self.send_log_message("info", format!("Changing noise suppression to {}", value)); + self.context + .send_log_message("info", format!("Changing noise suppression to {value}")); - set_noise_suppression(&self.page, value) + set_noise_suppression(&self.context.page, value) .await .context("Failed to set noise suppression level")?; - self.update_state().await; - Ok(()) } - async fn toggle_background_blur(&self) -> Result<()> { - let background_blur = get_background_blur(&self.page).await?; - set_background_blur(&self.page, !background_blur) + async fn toggle_background_blur_inner(&self) -> Result<()> { + let background_blur = get_background_blur(&self.context.page).await?; + set_background_blur(&self.context.page, !background_blur) .await .context("Failed to set background blur")?; - self.update_state().await; Ok(()) } -} -impl ParticipantInner { async fn leave_button(&self) -> Result { - self.find_element(classic::LEAVE_BUTTON).await + self.context.find_element(classic::LEAVE_BUTTON).await } async fn mute_button(&self) -> Result { - self.find_element(classic::MUTE_BUTTON).await + self.context.find_element(classic::MUTE_BUTTON).await } async fn camera_button(&self) -> Result { - self.find_element(classic::VIDEO_BUTTON).await + self.context.find_element(classic::VIDEO_BUTTON).await } async fn screen_share_button(&self) -> Result { - self.find_element(classic::SCREEN_SHARE_BUTTON).await - } - - async fn find_element(&self, selector: &str) -> Result { - self.page - .find_element(selector) - .await - .context(format!("Could not find the {selector} element")) + self.context.find_element(classic::SCREEN_SHARE_BUTTON).await } - async fn update_state(&self) { + async fn update_state_inner(&self) { let joined = self.leave_button().await.is_ok(); let mut muted = false; let mut video_activated = false; @@ -592,7 +361,7 @@ impl ParticipantInner { let mut background_blur = false; let mut screenshare_activated = false; - if let Ok(value) = get_noise_suppression(&self.page).await { + if let Ok(value) = get_noise_suppression(&self.context.page).await { noise_suppression = value; } @@ -614,21 +383,21 @@ impl ParticipantInner { } } - if let Ok(value) = get_force_webrtc(&self.page).await { + if let Ok(value) = get_force_webrtc(&self.context.page).await { if value { transport_mode = TransportMode::WebRTC; } } - if let Ok(value) = get_outgoing_camera_resolution(&self.page).await { - webcam_resolution = value + if let Ok(value) = get_outgoing_camera_resolution(&self.context.page).await { + webcam_resolution = value; } - if let Ok(blur) = get_background_blur(&self.page).await { + if let Ok(blur) = get_background_blur(&self.context.page).await { background_blur = blur; } - self.state.send_modify(|state| { + self.context.state.send_modify(|state| { state.joined = joined; state.muted = muted; state.video_activated = video_activated; @@ -642,10 +411,56 @@ impl ParticipantInner { } } -async fn element_state(el: &Element) -> Option { - el.attribute("data-test-state") - .await - .ok() - .unwrap_or(None) - .map(|v| v == "true") +impl FrontendDriver for ParticipantInner { + fn participant_name(&self) -> &str { + self.context.participant_name() + } + + fn state(&self) -> &watch::Sender { + &self.context.state + } + + fn log_message(&self, level: &str, message: String) { + self.context.send_log_message(level, message); + } + + fn join(&mut self) -> BoxFuture<'_, Result<()>> { + async move { self.join_session().await }.boxed() + } + + fn leave(&self) -> BoxFuture<'_, Result<()>> { + async move { self.leave_session().await }.boxed() + } + + fn close(self, browser: Browser) -> BoxFuture<'static, Result<()>> { + async move { self.close_browser(browser).await }.boxed() + } + + fn toggle_audio(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_audio_inner().await }.boxed() + } + + fn toggle_video(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_video_inner().await }.boxed() + } + + fn toggle_screen_share(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_screen_share_inner().await }.boxed() + } + + fn set_webcam_resolutions(&self, value: WebcamResolution) -> BoxFuture<'_, Result<()>> { + async move { self.set_webcam_resolutions_inner(value).await }.boxed() + } + + fn set_noise_suppression(&self, value: NoiseSuppression) -> BoxFuture<'_, Result<()>> { + async move { self.set_noise_suppression_inner(value).await }.boxed() + } + + fn toggle_background_blur(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_background_blur_inner().await }.boxed() + } + + fn update_state(&self) -> BoxFuture<'_, ()> { + async move { self.update_state_inner().await }.boxed() + } } diff --git a/browser/src/participant/inner_lite.rs b/browser/src/participant/inner_lite.rs index f848404..7f0351e 100644 --- a/browser/src/participant/inner_lite.rs +++ b/browser/src/participant/inner_lite.rs @@ -1,26 +1,20 @@ //! Browser interaction for the hyper-lite ("hyper lite") frontend. -use super::{ - messages::ParticipantMessage, - ParticipantState, +use super::frontend::{ + element_state, + run_local_participant, + DriverContext, + FrontendDriver, }; use crate::{ - create_browser, - participant::{ - messages::ParticipantLogMessage, - selectors::lite, - }, + participant::selectors::lite, wait_for_element, }; use chromiumoxide::{ - cdp::browser_protocol::target::CreateTargetParams, Browser, Element, - Handler, - Page, }; use client_simulator_config::{ - BrowserConfig, Config, NoiseSuppression, ParticipantConfig, @@ -28,466 +22,236 @@ use client_simulator_config::{ WebcamResolution, }; use eyre::{ - bail, Context as _, - ContextCompat as _, Result, }; -use futures::StreamExt as _; +use futures::{ + future::BoxFuture, + FutureExt as _, +}; use std::time::Duration; -use tokio::{ - sync::{ - mpsc::{ - UnboundedReceiver, - UnboundedSender, - }, - watch, +use tokio::sync::{ + mpsc::{ + UnboundedReceiver, + UnboundedSender, }, - task::JoinHandle, + watch, }; +/// Frontend driver for the hyper-lite UI. #[derive(Debug)] pub(super) struct ParticipantInnerLite { - participant_config: ParticipantConfig, - page: Page, - state: watch::Sender, - sender: UnboundedSender, + context: DriverContext, } impl ParticipantInnerLite { #[instrument(level = "debug", skip_all, fields(name = %participant_config.username))] pub(super) async fn run( participant_config: ParticipantConfig, - receiver: UnboundedReceiver, - sender: UnboundedSender, - state: watch::Sender, + receiver: UnboundedReceiver, + sender: UnboundedSender, + state: watch::Sender, ) -> Result<()> { - debug!("Starting participant inner lite..."); - let (mut browser, handler) = create_browser(&BrowserConfig::from(&participant_config)).await?; - let browser_event_task_handle = Self::drive_browser_events(&participant_config.username, handler); - let page = Self::create_page_retry(&participant_config, &mut browser).await?; - - state.send_modify(|state| { - state.username = participant_config.username.clone(); - }); - - let participant = Self { - participant_config, - page, - state: state.clone(), - sender, - }; - - participant - .handle_actions(browser, receiver) - .await - .context("failed to handle actions")?; - - browser_event_task_handle.await?; - Ok(()) - } - - fn drive_browser_events(name: impl ToString, mut handler: Handler) -> JoinHandle<()> { - let name = name.to_string(); - tokio::task::spawn(async move { - while let Some(event) = handler.next().await { - if let Err(err) = event { - if err.to_string().contains("ResetWithoutClosingHandshake") { - error!(name, "Browser unexpectedly closed"); - break; - } - error!(name, "error in browser handler: {err:?}"); - } - } - debug!(name, "Browser event handler stopped"); - }) + debug!(participant = %participant_config.username, "Starting participant inner lite..."); + run_local_participant(participant_config, receiver, sender, state, |context| Self { context }).await } - async fn handle_actions( - mut self, - mut browser: Browser, - mut receiver: UnboundedReceiver, - ) -> Result<()> { - self.state.send_modify(|state| { - state.running = true; - }); - - if let Err(err) = self.join().await { - error!("Failed joining the session when starting the browser {err}"); - self.send_log_message( - "error", - format!("Failed joining the session when starting the browser {err}"), - ); - - match browser.kill().await { - Some(Ok(_)) => { - debug!("browser killed"); - self.send_log_message("debug", "browser killed"); - } - Some(Err(err)) => { - error!("failed to kill browser: {err}"); - self.send_log_message("error", format!("failed to kill browser: {err}")); - } - None => debug!("browser process not found"), - }; - self.state.send_modify(|state| { - state.running = false; - }); + async fn join_session(&mut self) -> Result<()> { + if self.context.state.borrow().joined { + warn!(participant = %self.participant_name(), "Already joined."); + self.context.send_log_message("warn", "Already joined."); return Ok(()); } - let mut detached_event = browser - .event_listener::() - .await - .expect("failed to create event listener"); - - loop { - let message = tokio::select! { - biased; - - Some(_) = detached_event.next() => { - warn!(self.participant_config.username, "Browser unexpectedly closed"); - match browser.kill().await { - Some(Ok(_)) => { - debug!("browser killed"); - self.send_log_message("debug", "browser killed"); - }, - Some(Err(err)) => { - error!("failed to kill browser: {err}"); - self.send_log_message("error", format!("failed to kill browser: {err}")); - }, - None => { - debug!("browser process not found"); - self.send_log_message("debug", "browser process not found"); - }, - } - break; - }, - - Some(message) = receiver.recv() => { message } - }; - - if let Err(e) = match message { - ParticipantMessage::Join => self.join().await, - ParticipantMessage::Leave => self.leave().await, - ParticipantMessage::Close => { - self.close(browser).await?; - return Ok(()); - } - ParticipantMessage::ToggleAudio => self.toggle_audio().await, - ParticipantMessage::ToggleVideo => self.toggle_video().await, - ParticipantMessage::ToggleScreenshare => self.toggle_screen_share().await, - ParticipantMessage::SetWebcamResolutions(value) => self.set_webcam_resolutions(value).await, - ParticipantMessage::SetNoiseSuppression(value) => self.set_noise_suppression(value).await, - ParticipantMessage::ToggleBackgroundBlur => self.toggle_background_blur().await, - } { - error!("Running action {message} failed with error: {e}."); - self.send_log_message("error", format!("Running action {message} failed with error: {e}.")); - } - - self.update_state().await; - } - - self.state.send_modify(|state| { - state.running = false; - }); - - Ok(()) - } - - fn send_log_message(&self, level: &str, message: impl ToString) { - if let Err(err) = self.sender.send(ParticipantLogMessage::new( - level, - &self.participant_config.username, - message, - )) { - trace!("Failed to send log message: {err}"); - } - } - - async fn create_page(config: &ParticipantConfig, browser: &mut Browser) -> Result { - let page = if let Ok(Some(page)) = browser - .pages() - .await - .context("failed to get pages") - .map(|pages| pages.into_iter().next()) - { - page.goto(config.session_url.to_string()) - .await - .context("failed to navigate to session_url")?; - page - } else { - browser - .new_page( - CreateTargetParams::builder() - .url(config.session_url.to_string()) - .build() - .map_err(|e| eyre::eyre!(e))?, - ) - .await - .context("failed to create new page")? - }; - - let navigation = page - .wait_for_navigation_response() - .await - .context("Page could not navigate to session_url")? - .with_context(|| { - format!( - "{}: No request returned when creating a page for {}", - config.username, config.session_url, - ) - })?; - - if let Some(text) = &navigation.failure_text { - bail!( - "{}: When creating a new page request got a failure: {}", - config.username, - text - ); - } - - debug!(config.username, "Created a new page for the {}", config.session_url); - - Ok(page) - } - - async fn create_page_retry(config: &ParticipantConfig, browser: &mut Browser) -> Result { - let mut backoff = maybe_backoff::MaybeBackoff::default(); - let mut attempt = 0; - loop { - backoff.sleep().await; - match Self::create_page(config, browser).await { - Ok(page) => return Ok(page), - Err(_) if attempt < 5 => { - attempt += 1; - backoff.arm(); - warn!(?attempt, "Failed to create a new page, retrying..."); - } - Err(err) => return Err(err), - } - } - } -} - -impl ParticipantInnerLite { - async fn join(&mut self) -> Result<()> { - if self.state.borrow().joined { - warn!("Already joined."); - self.send_log_message("warn", "Already joined."); - return Ok(()); - } - - // Navigate to session URL directly, lite auth is handled by frontend routing - self.page - .goto(self.participant_config.session_url.to_string()) + self.context + .page + .goto(self.context.participant_config.session_url.to_string()) .await .context("failed to wait for navigation response")?; - debug!(self.participant_config.username, "Navigated to page"); - self.send_log_message("debug", "Navigated to page"); + debug!(participant = %self.participant_name(), "Navigated to page"); + self.context.send_log_message("debug", "Navigated to page"); - // Ensure join button exists - wait_for_element(&self.page, lite::JOIN_BUTTON, Duration::from_secs(30)).await?; + wait_for_element(&self.context.page, lite::JOIN_BUTTON, Duration::from_secs(30)).await?; - // Lite frontend doesn't support pre-join settings configuration - - // Click the join button - self.find_element(lite::JOIN_BUTTON) + self.context + .find_element(lite::JOIN_BUTTON) .await? .click() .await .context("failed to click join button")?; - debug!(self.participant_config.username, "Clicked on the join button"); - self.send_log_message("debug", "Clicked on the join button"); + debug!(participant = %self.participant_name(), "Clicked on the join button"); + self.context.send_log_message("debug", "Clicked on the join button"); - // Ensure we have joined the space. - wait_for_element(&self.page, lite::LEAVE_BUTTON, Duration::from_secs(30)) + wait_for_element(&self.context.page, lite::LEAVE_BUTTON, Duration::from_secs(30)) .await .context("We haven't joined the space, cannot find the leave button")?; - info!(self.participant_config.username, "Joined the space"); - self.send_log_message("info", "Joined the space"); + info!(participant = %self.participant_name(), "Joined the space"); + self.context.send_log_message("info", "Joined the space"); - // Apply settings after joining - if let Err(err) = self.apply_all_settings(false).await { - error!("Failed to apply settings after joining: {err}"); - self.send_log_message("error", format!("Failed to apply settings after joining: {err}")); + if let Err(err) = self.apply_all_settings().await { + error!(participant = %self.participant_name(), "Failed to apply settings after joining: {err}"); + self.context + .send_log_message("error", format!("Failed to apply settings after joining: {err}")); } - self.update_state().await; + self.update_state_inner().await; + Ok(()) } - async fn apply_all_settings(&self, _in_lobby: bool) -> Result<()> { + async fn apply_all_settings(&self) -> Result<()> { let Config { audio_enabled, video_enabled, screenshare_enabled, .. - } = &self.participant_config.app_config; + } = &self.context.participant_config.app_config; - // Only apply core settings that are supported in lite frontend if !audio_enabled { - self.toggle_audio().await?; + self.toggle_audio_inner().await?; } if !video_enabled { - self.toggle_video().await?; + self.toggle_video_inner().await?; } if *screenshare_enabled { - self.toggle_screen_share().await?; + self.toggle_screen_share_inner().await?; } Ok(()) } - async fn leave(&self) -> Result<()> { + async fn leave_session(&self) -> Result<()> { self.leave_button() .await? .click() .await .context("Could not click on the leave space button")?; - info!(self.participant_config.username, "Left the space"); - self.send_log_message("info", "Left the space"); + info!(participant = %self.participant_name(), "Left the space"); + self.context.send_log_message("info", "Left the space"); Ok(()) } - async fn close(self, mut browser: Browser) -> Result<()> { - debug!(self.participant_config.username, "Closing the browser..."); - let _ = self.leave().await; - let _ = self.page.clone().close().await; + async fn close_browser(self, mut browser: Browser) -> Result<()> { + debug!(participant = %self.participant_name(), "Closing the browser..."); + let _ = self.leave_session().await; + let _ = self.context.page.clone().close().await; browser.close().await?; browser.wait().await?; - info!(self.participant_config.username, "Closed the browser"); - self.send_log_message("info", "Closed the browser"); - self.state.send_modify(|state| { + info!(participant = %self.participant_name(), "Closed the browser"); + self.context.send_log_message("info", "Closed the browser"); + self.context.state.send_modify(|state| { state.running = false; }); Ok(()) } - async fn toggle_audio(&self) -> Result<()> { + async fn toggle_audio_inner(&self) -> Result<()> { self.mute_button() .await? .click() .await .context("Could not click on the toggle audio button")?; - info!(self.participant_config.username, "Toggled audio"); - self.send_log_message("info", "Toggled audio"); - self.update_state().await; + info!(participant = %self.participant_name(), "Toggled audio"); + self.context.send_log_message("info", "Toggled audio"); Ok(()) } - async fn toggle_video(&self) -> Result<()> { + async fn toggle_video_inner(&self) -> Result<()> { self.camera_button() .await? .click() .await .context("Could not click on the toggle camera button")?; - info!(self.participant_config.username, "Toggled camera"); - self.send_log_message("info", "Toggled camera"); - self.update_state().await; + info!(participant = %self.participant_name(), "Toggled camera"); + self.context.send_log_message("info", "Toggled camera"); Ok(()) } - async fn toggle_screen_share(&self) -> Result<()> { + async fn toggle_screen_share_inner(&self) -> Result<()> { self.screen_share_button() .await? .click() .await .context("Could not click on the toggle screen share button")?; - info!(self.participant_config.username, "Toggled screen share"); - self.send_log_message("info", "Toggled screen share"); - // Give it some time to start share before updating state + info!(participant = %self.participant_name(), "Toggled screen share"); + self.context.send_log_message("info", "Toggled screen share"); tokio::time::sleep(Duration::from_secs(1)).await; - self.update_state().await; Ok(()) } - async fn set_webcam_resolutions(&self, _value: WebcamResolution) -> Result<()> { - // Lite frontend doesn't support webcam resolution changes + async fn set_webcam_resolutions_inner(&self, _value: WebcamResolution) -> Result<()> { debug!( - self.participant_config.username, + participant = %self.participant_name(), "Webcam resolution changes not supported in lite frontend" ); Ok(()) } - async fn set_noise_suppression(&self, _value: NoiseSuppression) -> Result<()> { - // Lite frontend doesn't support noise suppression changes + async fn set_noise_suppression_inner(&self, _value: NoiseSuppression) -> Result<()> { debug!( - self.participant_config.username, + participant = %self.participant_name(), "Noise suppression changes not supported in lite frontend" ); Ok(()) } - async fn toggle_background_blur(&self) -> Result<()> { - // Lite frontend doesn't support background blur changes + async fn toggle_background_blur_inner(&self) -> Result<()> { debug!( - self.participant_config.username, + participant = %self.participant_name(), "Background blur changes not supported in lite frontend" ); Ok(()) } -} -impl ParticipantInnerLite { async fn leave_button(&self) -> Result { - self.find_element(lite::LEAVE_BUTTON).await + self.context.find_element(lite::LEAVE_BUTTON).await } + async fn mute_button(&self) -> Result { - self.find_element(lite::MUTE_BUTTON).await + self.context.find_element(lite::MUTE_BUTTON).await } + async fn camera_button(&self) -> Result { - self.find_element(lite::VIDEO_BUTTON).await + self.context.find_element(lite::VIDEO_BUTTON).await } + async fn screen_share_button(&self) -> Result { - self.find_element(lite::SCREEN_SHARE_BUTTON).await - } - async fn find_element(&self, selector: &str) -> Result { - self.page - .find_element(selector) - .await - .context(format!("Could not find the {selector} element")) + self.context.find_element(lite::SCREEN_SHARE_BUTTON).await } - async fn update_state(&self) { + async fn update_state_inner(&self) { let joined = self.leave_button().await.is_ok(); let mut muted = false; let mut video_activated = false; let mut screenshare_activated = false; - // Only check core operations that are supported in lite frontend if let Ok(mute_button) = self.mute_button().await { if let Some(value) = element_state(&mute_button).await { muted = !value; } } if let Ok(camera_button) = self.camera_button().await { - if let Some(v) = element_state(&camera_button).await { - video_activated = v; + if let Some(value) = element_state(&camera_button).await { + video_activated = value; } } if let Ok(screen_share_button) = self.screen_share_button().await { - debug!( - self.participant_config.username, - "Screen share button: {screen_share_button:?}" - ); - if let Some(v) = element_state(&screen_share_button).await { - screenshare_activated = v; + debug!(participant = %self.participant_name(), "Screen share button: {screen_share_button:?}"); + if let Some(value) = element_state(&screen_share_button).await { + screenshare_activated = value; } } - self.state.send_modify(|state| { + self.context.state.send_modify(|state| { state.joined = joined; state.muted = muted; state.video_activated = video_activated; state.screenshare_activated = screenshare_activated; - // Set defaults for unsupported features state.transport_mode = TransportMode::default(); state.webcam_resolution = WebcamResolution::default(); state.noise_suppression = NoiseSuppression::default(); @@ -497,10 +261,56 @@ impl ParticipantInnerLite { } } -async fn element_state(el: &Element) -> Option { - el.attribute("data-test-state") - .await - .ok() - .unwrap_or(None) - .map(|v| v == "true") +impl FrontendDriver for ParticipantInnerLite { + fn participant_name(&self) -> &str { + self.context.participant_name() + } + + fn state(&self) -> &watch::Sender { + &self.context.state + } + + fn log_message(&self, level: &str, message: String) { + self.context.send_log_message(level, message); + } + + fn join(&mut self) -> BoxFuture<'_, Result<()>> { + async move { self.join_session().await }.boxed() + } + + fn leave(&self) -> BoxFuture<'_, Result<()>> { + async move { self.leave_session().await }.boxed() + } + + fn close(self, browser: Browser) -> BoxFuture<'static, Result<()>> { + async move { self.close_browser(browser).await }.boxed() + } + + fn toggle_audio(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_audio_inner().await }.boxed() + } + + fn toggle_video(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_video_inner().await }.boxed() + } + + fn toggle_screen_share(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_screen_share_inner().await }.boxed() + } + + fn set_webcam_resolutions(&self, value: WebcamResolution) -> BoxFuture<'_, Result<()>> { + async move { self.set_webcam_resolutions_inner(value).await }.boxed() + } + + fn set_noise_suppression(&self, value: NoiseSuppression) -> BoxFuture<'_, Result<()>> { + async move { self.set_noise_suppression_inner(value).await }.boxed() + } + + fn toggle_background_blur(&self) -> BoxFuture<'_, Result<()>> { + async move { self.toggle_background_blur_inner().await }.boxed() + } + + fn update_state(&self) -> BoxFuture<'_, ()> { + async move { self.update_state_inner().await }.boxed() + } } diff --git a/browser/src/participant/mod.rs b/browser/src/participant/mod.rs index a06f928..6682588 100644 --- a/browser/src/participant/mod.rs +++ b/browser/src/participant/mod.rs @@ -4,6 +4,7 @@ use super::auth::{ HyperSessionCookieStash, }; use crate::participant::{ + frontend::ResolvedFrontendKind, messages::ParticipantLogMessage, remote_stub::spawn_remote_stub, }; @@ -34,6 +35,7 @@ use tokio_util::sync::{ }; mod commands; +mod frontend; mod inner; mod inner_lite; pub mod messages; @@ -104,22 +106,26 @@ impl Participant { _ = task_cancellation_token.cancelled() => {}, result = async move { - if participant_config.is_lite_frontend() { - inner_lite::ParticipantInnerLite::run( - participant_config, - receiver_tx, - task_sender_for_worker, - state_sender, - ).await - } else { - ParticipantInner::run( - participant_config, - cookie, - cookie_manager, - receiver_tx, - task_sender_for_worker, - state_sender, - ).await + let frontend = ResolvedFrontendKind::from_session_url(&participant_config.session_url); + match frontend { + ResolvedFrontendKind::HyperLite => { + inner_lite::ParticipantInnerLite::run( + participant_config, + receiver_tx, + task_sender_for_worker, + state_sender, + ).await + } + ResolvedFrontendKind::HyperCore => { + ParticipantInner::run( + participant_config, + cookie, + cookie_manager, + receiver_tx, + task_sender_for_worker, + state_sender, + ).await + } } } => { if let Err(err) = result { diff --git a/browser/src/participant/remote_stub.rs b/browser/src/participant/remote_stub.rs index 2d015e1..bf8596c 100644 --- a/browser/src/participant/remote_stub.rs +++ b/browser/src/participant/remote_stub.rs @@ -32,8 +32,12 @@ pub async fn spawn_remote_stub( state.screenshare_activated = participant_config.app_config.screenshare_enabled; }); - ParticipantLogMessage::new("warn", &username, "remote backend is a local stub; commands are simulated locally") - .write(); + ParticipantLogMessage::new( + "warn", + &username, + "remote backend is a local stub; commands are simulated locally", + ) + .write(); while let Some(message) = receiver.recv().await { match message { @@ -81,15 +85,23 @@ pub async fn spawn_remote_stub( state_sender.send_modify(|state| { state.noise_suppression = value; }); - ParticipantLogMessage::new("debug", &username, format!("remote stub set noise suppression to {value}")) - .write(); + ParticipantLogMessage::new( + "debug", + &username, + format!("remote stub set noise suppression to {value}"), + ) + .write(); } ParticipantMessage::SetWebcamResolutions(value) => { state_sender.send_modify(|state| { state.webcam_resolution = value; }); - ParticipantLogMessage::new("debug", &username, format!("remote stub set camera resolution to {value}")) - .write(); + ParticipantLogMessage::new( + "debug", + &username, + format!("remote stub set camera resolution to {value}"), + ) + .write(); } ParticipantMessage::ToggleBackgroundBlur => { state_sender.send_modify(|state| { diff --git a/config/src/args.rs b/config/src/args.rs index a86f88f..85dc523 100644 --- a/config/src/args.rs +++ b/config/src/args.rs @@ -27,7 +27,6 @@ pub struct TuiArgs { /// - adds `.with_head()` when starting the browser #[clap(long = "headless", action)] pub headless: Option, - } mod config_ext { diff --git a/config/src/lib.rs b/config/src/lib.rs index 9e6b312..be4503c 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -8,11 +8,9 @@ mod client_config; pub mod media; mod participant_config; -use crate::{ - media::{ - FakeMedia, - FakeMediaWithDescription, - }, +use crate::media::{ + FakeMedia, + FakeMediaWithDescription, }; use app_config::AppConfig; pub use app_config::{ diff --git a/config/src/participant_config.rs b/config/src/participant_config.rs index 7300548..7512ccb 100644 --- a/config/src/participant_config.rs +++ b/config/src/participant_config.rs @@ -35,13 +35,6 @@ impl ParticipantConfig { url.set_path("/"); url } - - /// Returns true if the session URL points to the new lite frontend. - /// We consider the lite frontend when the URL path starts with "/m/" or is exactly "/m". - pub fn is_lite_frontend(&self) -> bool { - let path = self.session_url.path(); - path == "/m" || path.starts_with("/m/") - } } pub fn generate_random_name() -> String { From 0d996ce1c3c07cc7e824760e8b5850df97ba6b0a Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:23:02 +0100 Subject: [PATCH 7/9] remove stats gatherer --- CLAUDE.md | 62 +- Cargo.lock | 172 +-- Cargo.toml | 2 - README.md | 10 +- justfile | 12 - nix/packages.nix | 7 - stats-gatherer/Cargo.toml | 29 - stats-gatherer/src/collectors/collector.rs | 28 - stats-gatherer/src/collectors/mod.rs | 35 - stats-gatherer/src/collectors/orchestrator.rs | 174 ---- .../src/collectors/participant_collector.rs | 985 ------------------ .../src/collectors/server_collector.rs | 765 -------------- .../src/collectors/space_collector.rs | 701 ------------- stats-gatherer/src/config.rs | 99 -- stats-gatherer/src/lib.rs | 48 - stats-gatherer/src/main.rs | 150 --- stats-gatherer/src/metrics/mod.rs | 43 - stats-gatherer/src/metrics/server_data.rs | 100 -- stats-gatherer/src/metrics/shared.rs | 110 -- stats-gatherer/src/metrics/space_data.rs | 195 ---- 20 files changed, 38 insertions(+), 3689 deletions(-) delete mode 100644 stats-gatherer/Cargo.toml delete mode 100644 stats-gatherer/src/collectors/collector.rs delete mode 100644 stats-gatherer/src/collectors/mod.rs delete mode 100644 stats-gatherer/src/collectors/orchestrator.rs delete mode 100644 stats-gatherer/src/collectors/participant_collector.rs delete mode 100644 stats-gatherer/src/collectors/server_collector.rs delete mode 100644 stats-gatherer/src/collectors/space_collector.rs delete mode 100644 stats-gatherer/src/config.rs delete mode 100644 stats-gatherer/src/lib.rs delete mode 100644 stats-gatherer/src/main.rs delete mode 100644 stats-gatherer/src/metrics/mod.rs delete mode 100644 stats-gatherer/src/metrics/server_data.rs delete mode 100644 stats-gatherer/src/metrics/shared.rs delete mode 100644 stats-gatherer/src/metrics/space_data.rs diff --git a/CLAUDE.md b/CLAUDE.md index 5003ca0..9ab43ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a **Hyper.Video Browser Client Simulator** - a Rust-based testing framework that simulates multiple browser clients connecting to Hyper.Video sessions. It automates browser interactions using Chromium via the `chromiumoxide` library to test real-time video conferencing functionality at scale. +This is a **Hyper.Video Browser Client Simulator**: a Rust TUI that spawns and controls Chromium-backed browser participants for manual session testing. -The project is a Cargo workspace with multiple binaries for different use cases: -- **client-simulator** (main TUI): Interactive terminal UI for manual testing -- **client-simulator-stats-gatherer**: Analytics collection from ClickHouse +The active workspace has one shipped binary: +- **client-simulator**: main TUI entrypoint + +The core crates are: +- **browser/**: participant automation, shared local runtime, frontend drivers, remote stub +- **config/**: CLI and YAML configuration +- **tui/**: ratatui-based interface ## Build & Development Commands @@ -21,9 +25,6 @@ cargo build --release # Run the main TUI simulator just run # release mode just dev # dev mode (faster compilation) - -# Run stats gatherer -just stats-gatherer --clickhouse-url http://localhost:8123 --space-url https://... ``` ### Testing and Linting @@ -55,7 +56,6 @@ The project includes Nix flake support for reproducible builds: ```bash # Build via Nix nix build .#client-simulator -nix build .#client-simulator-stats-gatherer # Run via Nix just run-nix @@ -78,8 +78,7 @@ just fetch-cookie my-user http://localhost:8081 client-simulator/ # Main binary (TUI) ├── browser/ # Browser automation core ├── config/ # Configuration management -├── tui/ # Terminal UI (ratatui-based) -└── stats-gatherer/ # ClickHouse analytics +└── tui/ # Terminal UI (ratatui-based) ``` ### Core Components @@ -90,16 +89,18 @@ The foundation of all simulation modes. Key responsibilities: - **Browser Lifecycle**: Launches headless/headed Chromium instances using `chromiumoxide` - **Participant**: Central abstraction representing a simulated user - - `ParticipantInner`: Full browser-based participant (uses Chromium DevTools Protocol) - - `ParticipantInnerLite`: Browser-based automation for the lite frontend + - `frontend.rs`: shared local Chromium runtime shell + - `ParticipantInner`: hyper core frontend driver + - `ParticipantInnerLite`: hyper-lite frontend driver - `remote_stub.rs`: In-process placeholder for the future remote backend - **Authentication**: `HyperSessionCookieStash` manages persistent user sessions - **Media Handling**: Supports fake media sources (builtin, custom video/audio files) Key files: - `browser/src/participant/mod.rs`: Participant API and lifecycle -- `browser/src/participant/inner.rs`: Full browser implementation -- `browser/src/participant/inner_lite.rs`: Lite frontend browser implementation +- `browser/src/participant/frontend.rs`: Shared runtime and frontend resolution +- `browser/src/participant/inner.rs`: Hyper core frontend driver +- `browser/src/participant/inner_lite.rs`: Hyper-lite frontend driver - `browser/src/participant/remote_stub.rs`: Endpoint-free remote participant stub - `browser/src/auth.rs`: Cookie/session management @@ -108,6 +109,7 @@ Key files: Unified configuration system supporting CLI args, YAML files, and environment variables: - `Config`: Main config struct with media settings, transport modes, etc. +- `ParticipantBackendKind`: Explicit local vs remote-stub backend selection - `ParticipantConfig`: Per-participant settings (username, audio/video, resolution) - `BrowserConfig`: Browser-specific settings (user data dir, headless mode) @@ -115,18 +117,11 @@ Unified configuration system supporting CLI args, YAML files, and environment va Interactive terminal interface built with `ratatui`: - Spawn/control participants manually +- Pick the participant backend explicitly - Toggle audio/video/screenshare - View logs in real-time - Persist configuration across sessions -#### 5. Stats Gatherer (`stats-gatherer/`) - -Connects directly to ClickHouse to collect analytics: -- Server-level metrics -- Space-level metrics -- Participant audio/video processing stats -- Exports as formatted tables or JSON - ### Participant State Machine Participants follow this lifecycle: @@ -143,14 +138,14 @@ The simulator uses two approaches: 1. **Full Browser** (`ParticipantInner`): - Launches real Chromium via CDP (Chrome DevTools Protocol) - - Executes JavaScript in page context + - Uses the shared local runtime plus the hyper core driver - Supports all features (background blur, noise suppression, etc.) - CSS selectors in `browser/src/participant/selectors.rs` 2. **Lite Mode** (`ParticipantInnerLite`): - - Direct WebSocket connection (no browser) - - Faster, lower resource usage - - Limited features (no video rendering, blur, etc.) + - Uses the same shared runtime with a hyper-lite-specific driver + - Keeps the browser-based participant model + - Supports a smaller command surface than hyper core ### Cookie/Session Management @@ -184,9 +179,10 @@ cargo nextest run -p client-simulator-browser ### Adding a New Participant Command 1. Add variant to `ParticipantMessage` enum in `browser/src/participant/messages.rs` -2. Handle in `ParticipantInner::run()` message loop (`browser/src/participant/inner.rs`) -3. Add public method to `Participant` struct in `browser/src/participant/mod.rs` -4. Expose in the TUI as needed +2. Handle it in the shared runtime/driver boundary (`browser/src/participant/frontend.rs`) +3. Implement frontend-specific behavior in `browser/src/participant/inner.rs` and/or `browser/src/participant/inner_lite.rs` +4. Add the public method to `Participant` in `browser/src/participant/mod.rs` +5. Expose it in the TUI if needed ### Debugging Browser Issues @@ -226,14 +222,14 @@ The project allows specific lints (see `Cargo.toml`): ### Transport Modes Participants can use different WebRTC transport modes: -- **UDP**: Standard WebRTC -- **TCP**: Fallback for restrictive networks +- **webtransport** +- **webrtc** - Configured via `TransportMode` enum ### Noise Suppression & Resolution -- Noise suppression levels: `Off`, `Low`, `Medium`, `High` -- Webcam resolutions: Multiple presets from 180p to 1080p +- Noise suppression is configured via the `NoiseSuppression` enum +- Webcam resolutions are configured via the `WebcamResolution` enum ## Important File Locations diff --git a/Cargo.lock b/Cargo.lock index 41ce2ac..421d176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,15 +201,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bstr" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" -dependencies = [ - "memchr", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -339,12 +330,6 @@ dependencies = [ "windows-link 0.2.0", ] -[[package]] -name = "cityhash-rs" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93a719913643003b84bd13022b4b7e703c09342cd03b679c4641c7d2e50dc34d" - [[package]] name = "clap" version = "4.5.48" @@ -385,43 +370,6 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" -[[package]] -name = "clickhouse" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3093f817c4f81c8bd174ed8dd30eac785821a8a7eef27a7dcb7f8cd0d0f6548" -dependencies = [ - "bstr", - "bytes", - "cityhash-rs", - "clickhouse-derive", - "futures", - "futures-channel", - "http-body-util", - "hyper", - "hyper-util", - "lz4_flex", - "replace_with", - "sealed", - "serde", - "static_assertions", - "thiserror 1.0.69", - "tokio", - "url", -] - -[[package]] -name = "clickhouse-derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d70f3e2893f7d3e017eeacdc9a708fbc29a10488e3ebca21f9df6a5d2b616dbb" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn", -] - [[package]] name = "client-simulator" version = "0.1.0" @@ -487,28 +435,6 @@ dependencies = [ "url", ] -[[package]] -name = "client-simulator-stats-gatherer" -version = "0.1.0" -dependencies = [ - "chrono", - "clap", - "clickhouse", - "color-eyre", - "comfy-table", - "config", - "eyre", - "humantime", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "tokio", - "tracing", - "tracing-subscriber", - "url", -] - [[package]] name = "client-simulator-tui" version = "0.1.0" @@ -518,7 +444,7 @@ dependencies = [ "client-simulator-browser", "client-simulator-config", "color-eyre", - "crossterm 0.28.1", + "crossterm", "derive_more", "directories", "eyre", @@ -573,17 +499,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "comfy-table" -version = "7.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" -dependencies = [ - "crossterm 0.29.0", - "unicode-segmentation", - "unicode-width 0.2.0", -] - [[package]] name = "compact_str" version = "0.8.1" @@ -706,20 +621,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags", - "crossterm_winapi", - "document-features", - "parking_lot", - "rustix 1.1.2", - "winapi", -] - [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -1332,12 +1233,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - [[package]] name = "hyper" version = "1.7.0" @@ -1735,12 +1630,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "lz4_flex" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" - [[package]] name = "matchers" version = "0.2.0" @@ -2186,7 +2075,7 @@ dependencies = [ "bitflags", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools", @@ -2248,12 +2137,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" -[[package]] -name = "replace_with" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" - [[package]] name = "reqwest" version = "0.12.23" @@ -2289,14 +2172,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", ] @@ -2449,18 +2330,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sealed" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a8caec23b7800fb97971a1c6ae365b6239aaeddfb934d6265f8505e795699d" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "security-framework" version = "2.11.1" @@ -2514,17 +2383,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "serde_json" version = "1.0.145" @@ -2538,17 +2396,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "serde_spanned" version = "1.0.2" @@ -3175,7 +3022,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ - "crossterm 0.28.1", + "crossterm", "ratatui", "unicode-width 0.2.0", ] @@ -3432,19 +3279,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "web-sys" version = "0.3.81" diff --git a/Cargo.toml b/Cargo.toml index bb70a78..4734c0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ ".", "browser", "config", - "stats-gatherer", "tui", ] @@ -43,7 +42,6 @@ signal-hook = "0.3.17" strip-ansi-escapes = "0.2.0" strum = { version = "0.27.1", features = ["derive"] } temp-dir = "0.1.16" -thiserror = "2.0.6" tokio = { version = "1.46.1", default-features = false, features = ["macros", "rt-multi-thread", "sync", "signal"] } tokio-util = "0.7.12" tracing = "0.1.41" diff --git a/README.md b/README.md index 5c66a1c..6371ecb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ -This is a **Hyper.Video Browser Client Simulator** - a Rust-based testing framework that simulates multiple browser clients connecting to Hyper.Video sessions. It automates browser interactions using Chromium via the `chromiumoxide` library to test real-time video conferencing functionality at scale. +This is a **Hyper.Video Browser Client Simulator**: a Rust TUI for spawning and controlling Chromium-backed browser participants against Hyper.Video sessions. -The project is a Cargo workspace with multiple binaries for different use cases: -- **client-simulator** (main TUI): Interactive terminal UI for manual testing -- **client-simulator-stats-gatherer**: Analytics collection from ClickHouse +The active workspace is centered on: +- **client-simulator**: the main TUI binary +- **browser/**: participant automation and remote stub support +- **config/**: CLI and YAML configuration +- **tui/**: terminal UI components diff --git a/justfile b/justfile index 6cabdf7..381296b 100644 --- a/justfile +++ b/justfile @@ -14,17 +14,6 @@ run-nix *flags="": # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- -stats-gatherer *flags="": - cargo run --release --bin client-simulator-stats-gatherer -- {{ flags }} - -stats-gatherer-dev *flags="": - cargo run --package client-simulator-stats-gatherer -- {{ flags }} - -stats-gatherer-nix *flags="": - nix run .#client-simulator-stats-gatherer -- {{ flags }} - -# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - clippy: cargo clippy --all-targets --all-features -- -D warnings @@ -47,5 +36,4 @@ fetch-cookie username="simulator-user" server-url="http://localhost:8081": cachix-push: nix build --no-link --print-out-paths \ .#client-simulator \ - .#client-simulator-stats-gatherer \ | cachix push hyper-video diff --git a/nix/packages.nix b/nix/packages.nix index e750975..5b08bcb 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -45,12 +45,5 @@ rec { env.LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; }; - client-simulator-stats-gatherer = mkSimulatorPackage { - pname = "client-simulator-stats-gatherer"; - description = "Hyper browser client simulator stats gatherer"; - buildInputs = [ openssl ]; - cargoBuildFlags = [ "--package" "client-simulator-stats-gatherer" ]; - }; - default = client-simulator; } diff --git a/stats-gatherer/Cargo.toml b/stats-gatherer/Cargo.toml deleted file mode 100644 index 8fed39d..0000000 --- a/stats-gatherer/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "client-simulator-stats-gatherer" -version.workspace = true -authors.workspace = true -edition.workspace = true -repository.workspace = true - -[[bin]] -name = "client-simulator-stats-gatherer" -path = "src/main.rs" - -[dependencies] -# Core dependencies -chrono = { workspace = true, features = ["serde"] } -clap = { workspace = true, features = ["derive", "env"] } -clickhouse = "0.12.0" -color-eyre = "0.6.3" -comfy-table = "7.1.0" -config = { workspace = true, features = ["yaml", "convert-case"] } -eyre = "0.6.12" -humantime = "2.1.0" -reqwest = { workspace = true, features = ["json", "stream"] } -serde = { workspace = true, features = ["derive"] } -serde_json = "1.0.132" -serde_repr = "0.1.20" -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } -tracing = "0.1.41" -tracing-subscriber = { workspace = true, features = ["env-filter"] } -url = { workspace = true } diff --git a/stats-gatherer/src/collectors/collector.rs b/stats-gatherer/src/collectors/collector.rs deleted file mode 100644 index 45ec72b..0000000 --- a/stats-gatherer/src/collectors/collector.rs +++ /dev/null @@ -1,28 +0,0 @@ -use chrono::{ - DateTime, - Utc, -}; -use color_eyre::Result; -use std::{ - future::Future, - pin::Pin, -}; - -/// Trait for collecting and formatting data -pub trait Collector { - /// Collect data for the specified time range - fn collect( - &mut self, - start_time: DateTime, - duration_seconds: i64, - ) -> Pin> + Send + '_>>; - - /// Format data for display - fn format(&self) -> String; - - /// Get data summary as JSON - fn summary(&self) -> serde_json::Value; - - /// Get the name of this collector - fn name(&self) -> &'static str; -} diff --git a/stats-gatherer/src/collectors/mod.rs b/stats-gatherer/src/collectors/mod.rs deleted file mode 100644 index fba91a1..0000000 --- a/stats-gatherer/src/collectors/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! # Collectors Module -//! -//! This module contains the core data collection logic for the stats gatherer. -//! -//! ## Architecture -//! -//! - **`Collector` trait**: Defines the interface for all metric collectors -//! - **`ServerCollector`**: Collects server-level metrics (health, CPU, memory, network) -//! - **`SpaceCollector`**: Collects space-level metrics (participants, CPU, network, latency, audio/video processing) -//! - **`Orchestrator`**: Coordinates all collectors and manages the overall data collection flow -//! -//! ## Data Sources -//! -//! - **ClickHouse**: Primary data source for all metrics -//! - **HTTP Health Checks**: Server health and response time -//! -//! ## Key Features -//! -//! - **Modular design**: Each collector handles specific metric types -//! - **Error handling**: Comprehensive error handling with detailed logging -//! - **Performance optimized**: Efficient queries and data processing -//! - **Extensible**: Easy to add new metric types and collectors - -pub mod collector; -pub mod orchestrator; -pub mod participant_collector; -pub mod server_collector; -pub mod space_collector; - -// Re-export the main types for easy access -pub use collector::Collector; -pub use orchestrator::Orchestrator; -pub use participant_collector::ParticipantCollector; -pub use server_collector::ServerCollector; -pub use space_collector::SpaceCollector; diff --git a/stats-gatherer/src/collectors/orchestrator.rs b/stats-gatherer/src/collectors/orchestrator.rs deleted file mode 100644 index ae13f1b..0000000 --- a/stats-gatherer/src/collectors/orchestrator.rs +++ /dev/null @@ -1,174 +0,0 @@ -use crate::{ - collectors::{ - Collector, - ParticipantCollector, - ServerCollector, - SpaceCollector, - }, - config::Config, - metrics::*, -}; -use chrono::{ - DateTime, - Utc, -}; -use clickhouse::Client; -use eyre::Result; -use reqwest::Client as HttpClient; -use serde_json; -use std::{ - future::Future, - pin::Pin, -}; - -/// Orchestrates all collectors and manages the overall data collection flow -pub struct Orchestrator { - server_collector: ServerCollector, - space_collector: Option, - participant_collector: Option, - metrics: Option, -} - -impl Orchestrator { - /// Create a new orchestrator with all available collectors - pub async fn new(config: Config) -> Result { - // Create shared clients once - let mut clickhouse_client = Client::default() - .with_url(config.clickhouse_url.clone()) - .with_user(config.clickhouse_user.clone()); - - if let Some(password) = &config.clickhouse_password { - clickhouse_client = clickhouse_client.with_password(password.clone()); - } - - let http_client = HttpClient::new(); - - let server_collector = - ServerCollector::new(config.clone(), clickhouse_client.clone(), http_client.clone()).await?; - - let space_collector = if config.space_id.is_some() { - let collector = SpaceCollector::new(config.clone(), clickhouse_client.clone()).await?; - Some(collector) - } else { - None - }; - - let participant_collector = if config.space_id.is_some() { - Some(ParticipantCollector::new(config.clone(), clickhouse_client.clone()).await?) - } else { - None - }; - - Ok(Self { - server_collector, - space_collector, - participant_collector, - metrics: None, - }) - } -} - -impl Collector for Orchestrator { - fn collect( - &mut self, - start_time: DateTime, - duration_seconds: i64, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - let mut collected_data = CollectedData::new(start_time); - collected_data.collection_duration_seconds = duration_seconds as f64; - - // Collect server-level data - self.server_collector.collect(start_time, duration_seconds).await?; - - // Collect space-level data if space ID is available - if let Some(ref mut space_collector) = self.space_collector { - space_collector.collect(start_time, duration_seconds).await?; - } - - // Collect participant-level data if space ID is available - if let Some(ref mut participant_collector) = self.participant_collector { - participant_collector.collect(start_time, duration_seconds).await?; - } - - collected_data.finalize(); - - self.metrics = Some(collected_data); - Ok(()) - }) - } - - fn format(&self) -> String { - let metrics = match &self.metrics { - Some(m) => m, - None => panic!("No metrics collected yet. Call collect() first."), - }; - - let mut report = String::new(); - - report.push_str(&format!("\n{}\n", "=".repeat(80))); - report.push_str(&format!("{:^80}\n", "🚀 HYPER.VIDEO ANALYTICS REPORT")); - report.push_str(&format!("{}\n", "=".repeat(80))); - - // Collection summary - report.push_str(&format!( - "\n📊 Collection Summary:\n\ - • Collection Time: {}\n\ - • Duration: {:.1} seconds\n", - metrics.collection_start.format("%Y-%m-%d %H:%M:%S UTC"), - metrics.collection_duration_seconds - )); - - // Server data report - report.push_str(&self.server_collector.format().to_string()); - - // Space data report - if let Some(ref space_collector) = self.space_collector { - report.push_str(&space_collector.format().to_string()); - } - - // Participant data report - if let Some(ref participant_collector) = self.participant_collector { - report.push_str(&participant_collector.format().to_string()); - } - - report.push_str(&format!("\n{}\n", "=".repeat(80))); - report.push_str(&format!("{:^80}\n", "✅ END OF REPORT")); - report.push_str(&format!("{}\n", "=".repeat(80))); - - report - } - - fn summary(&self) -> serde_json::Value { - let metrics = match &self.metrics { - Some(m) => m, - None => panic!("No metrics collected yet. Call collect() first."), - }; - - let mut json_data = serde_json::json!({ - "collection_info": { - "start_time": metrics.collection_start, - "duration_seconds": metrics.collection_duration_seconds, - } - }); - - // Add server data - json_data["server_data"] = self.server_collector.summary(); - - // Add space data - json_data["space_data"] = self.space_collector.as_ref().map(|s| s.summary()).unwrap_or_default(); - - // Add participant data - json_data["participant_data"] = self - .participant_collector - .as_ref() - .map(|p| p.summary()) - .unwrap_or_default(); - - json_data - } - - fn name(&self) -> &'static str { - "Orchestrator" - } -} diff --git a/stats-gatherer/src/collectors/participant_collector.rs b/stats-gatherer/src/collectors/participant_collector.rs deleted file mode 100644 index 487d3b6..0000000 --- a/stats-gatherer/src/collectors/participant_collector.rs +++ /dev/null @@ -1,985 +0,0 @@ -use crate::{ - collectors::Collector, - config::Config, -}; -use chrono::{ - DateTime, - Utc, -}; -use color_eyre::Result; -use comfy_table::{ - presets, - Attribute, - Cell, - Color, - ContentArrangement, - Table, -}; -use serde_json; -use std::{ - future::Future, - pin::Pin, -}; - -/// Participant-level data collector -pub struct ParticipantCollector { - config: Config, - clickhouse_client: clickhouse::Client, - metrics: Option>, -} - -#[derive(Debug, Clone)] -pub struct ParticipantData { - pub participant_id: u16, - pub space_id: String, - pub server_url: String, - pub timestamp: DateTime, - pub join_time: DateTime, - pub leave_time: Option>, - pub duration_seconds: Option, - pub avg_cpu_usage: f64, - pub max_cpu_usage: f64, - pub total_bytes_sent: u64, - pub total_bytes_received: u64, - pub total_packets_sent: u64, - pub total_packets_received: u64, - pub avg_latency_ms: f64, - pub max_latency_ms: f64, - pub p95_latency_ms: f64, - pub audio_metrics: ParticipantAudioVideoMetrics, - pub video_metrics: ParticipantAudioVideoMetrics, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct ParticipantAudioVideoMetrics { - pub decoder_output_count: u64, - pub decoder_decode_count: u64, - pub scheduler_schedule_count: u64, - pub restore_commit_count: u64, - pub preprocessing_gap_duration_avg: f64, - pub gain_normalization_factor: f64, - pub source_volume: f64, - pub streams_active: u16, - pub datagrams_expected: u64, - pub datagrams_lost: u64, - pub datagrams_received: u64, - pub packet_loss_rate: f64, -} - -impl ParticipantCollector { - pub async fn new(config: Config, clickhouse_client: clickhouse::Client) -> Result { - Ok(Self { - config, - clickhouse_client, - metrics: None, - }) - } - - /// Query participant timeline data (join/leave times) - async fn query_participant_timeline( - &self, - space_id: &str, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - - // Use the same approach as the backend - proper query binding and date formatting - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT participant_id, - min(ts) as first_seen, - max(ts) as last_seen, - count(*) as data_points - FROM hyper_session.cpu_usage - WHERE space_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - GROUP BY participant_id - ORDER BY participant_id - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query participant CPU usage - async fn query_participant_cpu_usage( - &self, - space_id: &str, - participant_id: u16, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - space_id, - participant_id, - ts, - value - FROM hyper_session.cpu_usage - WHERE space_id = ? - AND participant_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY ts - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(participant_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query participant throughput metrics - async fn query_participant_throughput( - &self, - space_id: &str, - participant_id: u16, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - participant_id, - direction, - media_type, - ts, - value - FROM hyper_session.throughput - WHERE space_id = ? - AND participant_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY ts - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(participant_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query participant latency metrics - async fn query_participant_latency( - &self, - space_id: &str, - participant_id: u16, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - receiving_participant_id, - sending_participant_id, - media_type, - stream_id, - ts, - collect, - encode, - send, - sender, - relay, - receiver, - decode, - total - FROM hyper_session.participant_metrics - WHERE space_id = ? - AND (receiving_participant_id = ? OR sending_participant_id = ?) - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY ts - "; - - let rows = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(participant_id) - .bind(participant_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query participant audio/video processing metrics - async fn query_participant_audio_video_metrics( - &self, - space_id: &str, - participant_id: u16, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - // Follow the backend approach - filter by participant_id column, not by labels - let query = " - SELECT - space_id, - ts, - name, - labels, - value - FROM hyper_session.global_metrics - WHERE space_id = ? - AND participant_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - AND ( - name LIKE 'audio_%' OR - name LIKE 'video_%' OR - name LIKE 'datagrams_%' - ) - ORDER BY ts - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(participant_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Process participant timeline data - fn process_participant_timeline(&self, timeline: &[ParticipantTimelineRow]) -> Vec { - let mut participants = Vec::new(); - - for row in timeline { - let join_time = match DateTime::from_timestamp_millis(row.first_seen) { - Some(ts) => ts, - None => continue, - }; - let leave_time = if row.last_seen < Utc::now().timestamp_millis() { - DateTime::from_timestamp_millis(row.last_seen) - } else { - None - }; - - let duration_seconds = leave_time.map(|leave| (leave - join_time).num_seconds()); - - let participant = ParticipantData { - participant_id: row.participant_id, - space_id: self.config.space_id.as_ref().unwrap().clone(), - server_url: self.config.server_url.clone(), - timestamp: Utc::now(), - join_time, - leave_time, - duration_seconds, - avg_cpu_usage: 0.0, - max_cpu_usage: 0.0, - total_bytes_sent: 0, - total_bytes_received: 0, - total_packets_sent: 0, - total_packets_received: 0, - avg_latency_ms: 0.0, - max_latency_ms: 0.0, - p95_latency_ms: 0.0, - audio_metrics: ParticipantAudioVideoMetrics::default(), - video_metrics: ParticipantAudioVideoMetrics::default(), - }; - - participants.push(participant); - } - - participants - } - - /// Process CPU usage data for a participant - fn process_cpu_usage(&self, participant: &mut ParticipantData, cpu_data: &[CpuUsageRow]) { - if cpu_data.is_empty() { - return; - } - - let cpu_percentages: Vec = cpu_data.iter().map(|row| row.value * 100.0).collect(); - - participant.avg_cpu_usage = cpu_percentages.iter().sum::() / cpu_percentages.len() as f64; - participant.max_cpu_usage = cpu_percentages.iter().fold(0.0, |acc, &x| acc.max(x)); - } - - /// Process throughput data for a participant - fn process_throughput(&self, participant: &mut ParticipantData, throughput_data: &[ThroughputRow]) { - for row in throughput_data { - match row.direction { - 0 => { - // TX (sent) - participant.total_bytes_sent += row.value as u64; - participant.total_packets_sent += 1; - } - 1 => { - // RX (received) - participant.total_bytes_received += row.value as u64; - participant.total_packets_received += 1; - } - _ => {} - } - } - } - - /// Process latency data for a participant - fn process_latency(&self, participant: &mut ParticipantData, latency_data: &[LatencyMetricRow]) { - if latency_data.is_empty() { - return; - } - - let values: Vec = latency_data.iter().map(|row| row.total as f64).collect(); - participant.avg_latency_ms = values.iter().sum::() / values.len() as f64; - participant.max_latency_ms = values.iter().fold(0.0, |acc, &x| acc.max(x)); - - // Calculate P95 - let mut sorted_values = values.clone(); - sorted_values.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let p95_index = (sorted_values.len() as f64 * 0.95) as usize; - participant.p95_latency_ms = sorted_values[p95_index.min(sorted_values.len() - 1)]; - } - - /// Process audio/video metrics for a participant - fn process_audio_video_metrics(&self, participant: &mut ParticipantData, metrics_data: &[GlobalMetricRow]) { - for row in metrics_data { - match row.name.as_str() { - "audio_decoder_output" => participant.audio_metrics.decoder_output_count = row.value as u64, - "audio_decoder_decode" => participant.audio_metrics.decoder_decode_count = row.value as u64, - "audio_scheduler_schedule" => participant.audio_metrics.scheduler_schedule_count = row.value as u64, - "audio_preprocessing_gap_duration_avg" => { - participant.audio_metrics.preprocessing_gap_duration_avg = row.value - } - "audio_gain_normalization_factor" => participant.audio_metrics.gain_normalization_factor = row.value, - "audio_source_volume" => participant.audio_metrics.source_volume = row.value, - "video_decoder_output" => participant.video_metrics.decoder_output_count = row.value as u64, - "video_restore_commit" => participant.video_metrics.restore_commit_count = row.value as u64, - "video_streams_active" => participant.video_metrics.streams_active = row.value as u16, - "datagrams_receive_expected" => { - participant.audio_metrics.datagrams_expected = row.value as u64; - participant.video_metrics.datagrams_expected = row.value as u64; - } - "datagrams_receive_lost" => { - participant.audio_metrics.datagrams_lost = row.value as u64; - participant.video_metrics.datagrams_lost = row.value as u64; - } - "datagrams_receive_received" => { - participant.audio_metrics.datagrams_received = row.value as u64; - participant.video_metrics.datagrams_received = row.value as u64; - } - "datagrams_receive_packet_loss_rate" => { - participant.audio_metrics.packet_loss_rate = row.value; - participant.video_metrics.packet_loss_rate = row.value; - } - _ => {} - } - } - } - - /// Get color for CPU usage based on threshold - fn get_cpu_color(&self, cpu_usage: f64) -> Color { - if cpu_usage < 50.0 { - Color::Green - } else if cpu_usage < 80.0 { - Color::Yellow - } else { - Color::Red - } - } - - /// Get color for latency based on threshold - fn get_latency_color(&self, latency_ms: f64) -> Color { - if latency_ms < 100.0 { - Color::Green - } else if latency_ms < 200.0 { - Color::Yellow - } else { - Color::Red - } - } - - /// Format bytes in human readable format - fn format_bytes(&self, bytes: u64) -> String { - if bytes >= 1024 * 1024 * 1024 { - format!("{:.1}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } else if bytes >= 1024 * 1024 { - format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0)) - } else if bytes >= 1024 { - format!("{:.1}KB", bytes as f64 / 1024.0) - } else { - format!("{}B", bytes) - } - } - - /// Format number in human readable format - fn format_number(&self, num: u64) -> String { - if num >= 1_000_000 { - format!("{:.1}M", num as f64 / 1_000_000.0) - } else if num >= 1_000 { - format!("{:.1}K", num as f64 / 1_000.0) - } else { - format!("{}", num) - } - } - - /// Format duration in human readable format - fn format_duration(&self, seconds: Option) -> String { - match seconds { - Some(secs) => { - if secs >= 3600 { - format!("{:.1}h", secs as f64 / 3600.0) - } else if secs >= 60 { - format!("{:.1}m", secs as f64 / 60.0) - } else { - format!("{}s", secs) - } - } - None => "Active".to_string(), - } - } -} - -impl Default for ParticipantAudioVideoMetrics { - fn default() -> Self { - Self { - decoder_output_count: 0, - decoder_decode_count: 0, - scheduler_schedule_count: 0, - restore_commit_count: 0, - preprocessing_gap_duration_avg: 0.0, - gain_normalization_factor: 0.0, - source_volume: 0.0, - streams_active: 0, - datagrams_expected: 0, - datagrams_lost: 0, - datagrams_received: 0, - packet_loss_rate: 0.0, - } - } -} - -impl Collector for ParticipantCollector { - fn collect( - &mut self, - start_time: DateTime, - duration_seconds: i64, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - let space_id = self - .config - .space_id - .as_ref() - .ok_or_else(|| eyre::eyre!("Space ID is required for participant-level metrics"))?; - - // Get participant timeline - let timeline = self - .query_participant_timeline(space_id, start_time, duration_seconds) - .await?; - let mut participants = self.process_participant_timeline(&timeline); - - // For each participant, collect detailed metrics - for participant in &mut participants { - // Collect CPU usage - let cpu_data = self - .query_participant_cpu_usage(space_id, participant.participant_id, start_time, duration_seconds) - .await?; - self.process_cpu_usage(participant, &cpu_data); - - // Collect throughput - let throughput_data = self - .query_participant_throughput(space_id, participant.participant_id, start_time, duration_seconds) - .await?; - self.process_throughput(participant, &throughput_data); - - // Collect latency - let latency_data = self - .query_participant_latency(space_id, participant.participant_id, start_time, duration_seconds) - .await?; - self.process_latency(participant, &latency_data); - - // Collect audio/video metrics - let audio_video_data = self - .query_participant_audio_video_metrics( - space_id, - participant.participant_id, - start_time, - duration_seconds, - ) - .await?; - self.process_audio_video_metrics(participant, &audio_video_data); - } - - // Store metrics internally - self.metrics = Some(participants); - Ok(()) - }) - } - - fn format(&self) -> String { - let participants = match &self.metrics { - Some(p) => p, - None => return "No participant metrics collected yet. Call collect() first.".to_string(), - }; - - if participants.is_empty() { - return "No participants found in the specified time range.".to_string(); - } - - let mut output = String::new(); - - // Main participants overview table - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new(format!("👥 PARTICIPANT METRICS ({} participants)", participants.len())) - .add_attribute(Attribute::Bold) - .fg(Color::Cyan), - Cell::new("Latency").add_attribute(Attribute::Bold), - Cell::new("Bytes Sent").add_attribute(Attribute::Bold), - Cell::new("Bytes Received").add_attribute(Attribute::Bold), - ]); - - // Add participant rows - for participant in participants { - table.add_row(vec![ - Cell::new(format!("Participant {}", participant.participant_id)).add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}ms", participant.avg_latency_ms)) - .fg(self.get_latency_color(participant.avg_latency_ms)), - Cell::new(self.format_bytes(participant.total_bytes_sent)), - Cell::new(self.format_bytes(participant.total_bytes_received)), - ]); - } - - output.push_str(&format!("{}\n", table)); - - // Detailed metrics for each participant - for participant in participants { - output.push_str(&self.format_participant_details(participant)); - } - - output - } - - fn summary(&self) -> serde_json::Value { - let participants = match &self.metrics { - Some(p) => p, - None => return serde_json::json!({"error": "No participant metrics collected yet"}), - }; - - serde_json::json!({ - "participants": participants.iter().map(|p| { - serde_json::json!({ - "participant_id": p.participant_id, - "space_id": p.space_id, - "server_url": p.server_url, - "timestamp": p.timestamp, - "join_time": p.join_time, - "leave_time": p.leave_time, - "duration_seconds": p.duration_seconds, - "status": if p.leave_time.is_some() { "left" } else { "active" }, - "cpu": { - "avg_usage": p.avg_cpu_usage, - "max_usage": p.max_cpu_usage - }, - "network": { - "bytes_sent": p.total_bytes_sent, - "bytes_received": p.total_bytes_received, - "packets_sent": p.total_packets_sent, - "packets_received": p.total_packets_received - }, - "latency": { - "avg_ms": p.avg_latency_ms, - "max_ms": p.max_latency_ms, - "p95_ms": p.p95_latency_ms - }, - "audio_metrics": { - "decoder_output_count": p.audio_metrics.decoder_output_count, - "decoder_decode_count": p.audio_metrics.decoder_decode_count, - "scheduler_schedule_count": p.audio_metrics.scheduler_schedule_count, - "preprocessing_gap_duration_avg": p.audio_metrics.preprocessing_gap_duration_avg, - "gain_normalization_factor": p.audio_metrics.gain_normalization_factor, - "source_volume": p.audio_metrics.source_volume, - "streams_active": p.audio_metrics.streams_active, - "datagrams_expected": p.audio_metrics.datagrams_expected, - "datagrams_lost": p.audio_metrics.datagrams_lost, - "datagrams_received": p.audio_metrics.datagrams_received, - "packet_loss_rate": p.audio_metrics.packet_loss_rate - }, - "video_metrics": { - "decoder_output_count": p.video_metrics.decoder_output_count, - "decoder_decode_count": p.video_metrics.decoder_decode_count, - "scheduler_schedule_count": p.video_metrics.scheduler_schedule_count, - "restore_commit_count": p.video_metrics.restore_commit_count, - "preprocessing_gap_duration_avg": p.video_metrics.preprocessing_gap_duration_avg, - "gain_normalization_factor": p.video_metrics.gain_normalization_factor, - "source_volume": p.video_metrics.source_volume, - "streams_active": p.video_metrics.streams_active, - "datagrams_expected": p.video_metrics.datagrams_expected, - "datagrams_lost": p.video_metrics.datagrams_lost, - "datagrams_received": p.video_metrics.datagrams_received, - "packet_loss_rate": p.video_metrics.packet_loss_rate - } - }) - }).collect::>() - }) - } - - fn name(&self) -> &'static str { - "ParticipantCollector" - } -} - -impl ParticipantCollector { - /// Format detailed metrics for a single participant - fn format_participant_details(&self, participant: &ParticipantData) -> String { - let mut output = String::new(); - - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![Cell::new(format!( - "📊 Participant {} Details", - participant.participant_id - )) - .add_attribute(Attribute::Bold) - .fg(Color::Blue)]); - - // Timeline information - table.add_row(vec![ - Cell::new("Join Time").add_attribute(Attribute::Bold), - Cell::new(participant.join_time.format("%Y-%m-%d %H:%M:%S UTC").to_string()), - ]); - - if let Some(leave_time) = participant.leave_time { - table.add_row(vec![ - Cell::new("Leave Time").add_attribute(Attribute::Bold), - Cell::new(leave_time.format("%Y-%m-%d %H:%M:%S UTC").to_string()), - ]); - } - - table.add_row(vec![ - Cell::new("Duration").add_attribute(Attribute::Bold), - Cell::new(self.format_duration(participant.duration_seconds)), - ]); - - // Performance metrics - table.add_row(vec![ - Cell::new("Avg CPU Usage").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}%", participant.avg_cpu_usage)).fg(self.get_cpu_color(participant.avg_cpu_usage)), - ]); - - table.add_row(vec![ - Cell::new("Max CPU Usage").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}%", participant.max_cpu_usage)).fg(self.get_cpu_color(participant.max_cpu_usage)), - ]); - - // Network metrics - table.add_row(vec![ - Cell::new("Bytes Sent").add_attribute(Attribute::Bold), - Cell::new(self.format_bytes(participant.total_bytes_sent)), - ]); - - table.add_row(vec![ - Cell::new("Bytes Received").add_attribute(Attribute::Bold), - Cell::new(self.format_bytes(participant.total_bytes_received)), - ]); - - table.add_row(vec![ - Cell::new("Packets Sent").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.total_packets_sent)), - ]); - - table.add_row(vec![ - Cell::new("Packets Received").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.total_packets_received)), - ]); - - // Latency metrics - table.add_row(vec![ - Cell::new("Avg Latency").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}ms", participant.avg_latency_ms)) - .fg(self.get_latency_color(participant.avg_latency_ms)), - ]); - - table.add_row(vec![ - Cell::new("Max Latency").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}ms", participant.max_latency_ms)) - .fg(self.get_latency_color(participant.max_latency_ms)), - ]); - - table.add_row(vec![ - Cell::new("P95 Latency").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}ms", participant.p95_latency_ms)) - .fg(self.get_latency_color(participant.p95_latency_ms)), - ]); - - output.push_str(&format!("{}\n", table)); - - // Audio/Video metrics - output.push_str(&self.format_audio_video_metrics(participant)); - - output - } - - /// Format audio/video metrics for a participant - fn format_audio_video_metrics(&self, participant: &ParticipantData) -> String { - let mut output = String::new(); - - // Audio metrics table - let mut audio_table = Table::new(); - audio_table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![Cell::new(format!( - "🎵 Audio Metrics - Participant {}", - participant.participant_id - )) - .add_attribute(Attribute::Bold) - .fg(Color::Magenta)]); - - audio_table.add_row(vec![ - Cell::new("Decoder Output").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.audio_metrics.decoder_output_count)), - ]); - - audio_table.add_row(vec![ - Cell::new("Decoder Decode").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.audio_metrics.decoder_decode_count)), - ]); - - audio_table.add_row(vec![ - Cell::new("Scheduler Schedule").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.audio_metrics.scheduler_schedule_count)), - ]); - - audio_table.add_row(vec![ - Cell::new("Gap Duration Avg").add_attribute(Attribute::Bold), - Cell::new(format!( - "{:.2}ms", - participant.audio_metrics.preprocessing_gap_duration_avg - )), - ]); - - audio_table.add_row(vec![ - Cell::new("Gain Normalization").add_attribute(Attribute::Bold), - Cell::new(format!("{:.3}", participant.audio_metrics.gain_normalization_factor)), - ]); - - audio_table.add_row(vec![ - Cell::new("Source Volume").add_attribute(Attribute::Bold), - Cell::new(format!("{:.3}", participant.audio_metrics.source_volume)), - ]); - - audio_table.add_row(vec![ - Cell::new("Streams Active").add_attribute(Attribute::Bold), - Cell::new(format!("{}", participant.audio_metrics.streams_active)), - ]); - - audio_table.add_row(vec![ - Cell::new("Datagrams Expected").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.audio_metrics.datagrams_expected)), - ]); - - audio_table.add_row(vec![ - Cell::new("Datagrams Lost").add_attribute(Attribute::Bold), - Cell::new(format!("{}", participant.audio_metrics.datagrams_lost)).fg( - if participant.audio_metrics.datagrams_lost == 0 { - Color::Green - } else { - Color::Red - }, - ), - ]); - - audio_table.add_row(vec![ - Cell::new("Packet Loss Rate").add_attribute(Attribute::Bold), - Cell::new(format!("{:.2}%", participant.audio_metrics.packet_loss_rate)).fg( - if participant.audio_metrics.packet_loss_rate < 1.0 { - Color::Green - } else { - Color::Yellow - }, - ), - ]); - - output.push_str(&format!("{}\n", audio_table)); - - // Video metrics table - let mut video_table = Table::new(); - video_table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![Cell::new(format!( - "🎬 Video Metrics - Participant {}", - participant.participant_id - )) - .add_attribute(Attribute::Bold) - .fg(Color::Magenta)]); - - video_table.add_row(vec![ - Cell::new("Decoder Output").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.video_metrics.decoder_output_count)), - ]); - - video_table.add_row(vec![ - Cell::new("Restore Commit").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.video_metrics.restore_commit_count)), - ]); - - video_table.add_row(vec![ - Cell::new("Streams Active").add_attribute(Attribute::Bold), - Cell::new(format!("{}", participant.video_metrics.streams_active)), - ]); - - video_table.add_row(vec![ - Cell::new("Datagrams Expected").add_attribute(Attribute::Bold), - Cell::new(self.format_number(participant.video_metrics.datagrams_expected)), - ]); - - video_table.add_row(vec![ - Cell::new("Datagrams Lost").add_attribute(Attribute::Bold), - Cell::new(format!("{}", participant.video_metrics.datagrams_lost)).fg( - if participant.video_metrics.datagrams_lost == 0 { - Color::Green - } else { - Color::Red - }, - ), - ]); - - video_table.add_row(vec![ - Cell::new("Packet Loss Rate").add_attribute(Attribute::Bold), - Cell::new(format!("{:.2}%", participant.video_metrics.packet_loss_rate)).fg( - if participant.video_metrics.packet_loss_rate < 1.0 { - Color::Green - } else { - Color::Yellow - }, - ), - ]); - - output.push_str(&format!("{}\n", video_table)); - - output - } -} - -// ClickHouse row structs -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct ParticipantTimelineRow { - participant_id: u16, - first_seen: i64, // milliseconds since epoch - last_seen: i64, // milliseconds since epoch - #[allow(dead_code)] - data_points: u64, -} - -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct CpuUsageRow { - #[allow(dead_code)] - space_id: String, - #[allow(dead_code)] - participant_id: u16, - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - value: f64, -} - -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct ThroughputRow { - #[allow(dead_code)] - participant_id: u16, - direction: u8, // 0 = tx, 1 = rx - #[allow(dead_code)] - media_type: u8, // 0 = audio, 1 = video - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - value: f64, -} - -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct LatencyMetricRow { - #[allow(dead_code)] - receiving_participant_id: u16, - #[allow(dead_code)] - sending_participant_id: u16, - #[allow(dead_code)] - media_type: u8, // 0 = audio, 1 = video - #[allow(dead_code)] - stream_id: u8, - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - #[allow(dead_code)] - collect: u16, - #[allow(dead_code)] - encode: u16, - #[allow(dead_code)] - send: u16, - #[allow(dead_code)] - sender: u16, - #[allow(dead_code)] - relay: u16, - #[allow(dead_code)] - receiver: u16, - #[allow(dead_code)] - decode: u16, - total: u16, -} - -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct GlobalMetricRow { - #[allow(dead_code)] - space_id: String, - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - name: String, - #[allow(dead_code)] - labels: Vec<(String, String)>, - value: f64, -} diff --git a/stats-gatherer/src/collectors/server_collector.rs b/stats-gatherer/src/collectors/server_collector.rs deleted file mode 100644 index 5d58968..0000000 --- a/stats-gatherer/src/collectors/server_collector.rs +++ /dev/null @@ -1,765 +0,0 @@ -use crate::{ - collectors::Collector, - config::Config, - metrics::*, -}; -use chrono::{ - DateTime, - Utc, -}; -use clickhouse::Client; -use comfy_table::{ - presets, - Attribute, - Cell, - Color, - ContentArrangement, - Table, -}; -use eyre::Result; -use reqwest::Client as HttpClient; -use serde_json; -use std::{ - collections::{ - BTreeMap, - HashMap, - }, - future::Future, - pin::Pin, - time::Duration, -}; - -/// Collects and formats server-level data (CPU, memory, network, health) -pub struct ServerCollector { - config: Config, - clickhouse_client: Client, - http_client: HttpClient, - metrics: Option, -} - -impl ServerCollector { - pub async fn new(config: Config, clickhouse_client: Client, http_client: HttpClient) -> Result { - Ok(Self { - config, - clickhouse_client, - http_client, - metrics: None, - }) - } - - /// Perform HTTP health check on the server - async fn perform_health_check(&self, server_url: &str) -> Result<(f64, u16)> { - let start = std::time::Instant::now(); - let response = self - .http_client - .get(format!("{}/health", server_url)) - .timeout(Duration::from_secs(10)) - .send() - .await?; - - let response_time = start.elapsed().as_millis() as f64; - let status_code = response.status().as_u16(); - - Ok((response_time, status_code)) - } - - /// Query server metrics from ClickHouse - async fn query_server_metrics( - &self, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - ts, - name, - labels, - value, - metric_type - FROM hyper_session.server_metrics - WHERE ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY ts ASC, name - LIMIT 1000000 - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query participant join events for a given time range - async fn query_participant_joins( - &self, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - // Only query if we have a space ID - let space_id = match &self.config.space_id { - Some(id) => id, - None => return Ok(Vec::new()), // No space ID means no participant data - }; - - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT DISTINCT receiving_participant_id AS participant_id, - min(ts) as first_seen - FROM hyper_session.participant_metrics - WHERE space_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - GROUP BY receiving_participant_id - UNION DISTINCT - SELECT DISTINCT sending_participant_id AS participant_id, - min(ts) as first_seen - FROM hyper_session.participant_metrics - WHERE space_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - GROUP BY sending_participant_id - ORDER BY first_seen - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(&since_str) - .bind(&until_str) - .bind(space_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Process raw server metrics into structured data - fn process_server_metrics(&self, server_metrics: Vec) -> ServerData { - let mut metrics = ServerData::new("".to_string()); - - // Group metrics by name for processing - let mut cpu_usage_values = Vec::new(); - let mut cpu_data_points = Vec::new(); // Store CPU data with timestamps - let mut memory_bytes_values = Vec::new(); // Collect all memory byte values - let mut memory_percent_values = Vec::new(); - let mut load_average_values = Vec::new(); - - // Group network samples by interface + direction for proper time series handling - let mut network_bytes_samples: HashMap> = HashMap::new(); - let mut network_packets_samples: HashMap> = HashMap::new(); - - for data_point in server_metrics { - let name = &data_point.name; - let value = data_point.value; - - match name.as_str() { - "server_system_cpu_usage_percent" => { - cpu_usage_values.push(value); - // Store CPU data point with timestamp for graph - if let Some(timestamp) = DateTime::from_timestamp_millis(data_point.ts) { - cpu_data_points.push(crate::metrics::CpuDataPoint { - timestamp, - cpu_usage_percent: value, - }); - } - } - "server_system_memory_bytes" => { - memory_bytes_values.push(value); - } - "server_system_memory_percent" => { - memory_percent_values.push(value); - } - "server_system_network_bytes" => { - // Extract interface and direction from labels to create stable key - let interface = data_point - .labels - .iter() - .find(|(key, _)| key == "interface") - .map(|(_, value)| value.as_str()) - .unwrap_or("unknown"); - - let direction = data_point - .labels - .iter() - .find(|(key, _)| key == "direction") - .map(|(_, value)| value.as_str()) - .unwrap_or("unknown"); - - let series_key = format!("{}_{}", interface, direction); - network_bytes_samples - .entry(series_key) - .or_insert_with(BTreeMap::new) - .insert(data_point.ts, value); - } - "server_system_network_packets" => { - // Extract interface and direction from labels to create stable key - let interface = data_point - .labels - .iter() - .find(|(key, _)| key == "interface") - .map(|(_, value)| value.as_str()) - .unwrap_or("unknown"); - - let direction = data_point - .labels - .iter() - .find(|(key, _)| key == "direction") - .map(|(_, value)| value.as_str()) - .unwrap_or("unknown"); - - let series_key = format!("{}_{}", interface, direction); - network_packets_samples - .entry(series_key) - .or_insert_with(BTreeMap::new) - .insert(data_point.ts, value); - } - "server_system_load_average" => { - load_average_values.push(value); - } - _ => {} - } - } - - // Calculate averages and totals - if !cpu_usage_values.is_empty() { - metrics.cpu_usage_percent = cpu_usage_values.iter().sum::() / cpu_usage_values.len() as f64; - } else { - metrics.cpu_usage_percent = 0.0; - } - - // Store CPU data points for graph - metrics.cpu_data_points = cpu_data_points; - - // Process memory metrics - if !memory_bytes_values.is_empty() { - // Find the largest value as total memory (appears to be around 2GB) - let total_memory: f64 = memory_bytes_values.iter().fold(0.0, |acc: f64, x: &f64| acc.max(*x)); - metrics.memory_total_bytes = total_memory as u64; - - // Calculate used memory from percentage if available - if !memory_percent_values.is_empty() { - let avg_percent = memory_percent_values.iter().sum::() / memory_percent_values.len() as f64; - metrics.memory_used_bytes = (total_memory * avg_percent / 100.0) as u64; - metrics.memory_usage_percent = avg_percent; - } else { - // Fallback: estimate used memory from the smaller values - let used_memory: f64 = memory_bytes_values - .iter() - .filter(|&&x: &&f64| x < total_memory * 0.9) // Exclude the total memory value - .fold(0.0, |acc: f64, x: &f64| acc.max(*x)); - metrics.memory_used_bytes = used_memory as u64; - metrics.memory_usage_percent = (used_memory / total_memory) * 100.0; - } - } - - if !load_average_values.is_empty() { - metrics.cpu_load_average = load_average_values.iter().sum::() / load_average_values.len() as f64; - } - - // Calculate network deltas per time series to handle counter resets properly - let mut total_bytes_tx = 0u64; - let mut total_bytes_rx = 0u64; - let mut total_packets_tx = 0u64; - let mut total_packets_rx = 0u64; - - // Process network bytes deltas - for (series_key, samples) in &network_bytes_samples { - let delta = self.calculate_series_delta(samples); - if series_key.ends_with("_tx") { - total_bytes_tx += delta; - } else if series_key.ends_with("_rx") { - total_bytes_rx += delta; - } - } - - // Process network packets deltas - for (series_key, samples) in &network_packets_samples { - let delta = self.calculate_series_delta(samples); - if series_key.ends_with("_tx") { - total_packets_tx += delta; - } else if series_key.ends_with("_rx") { - total_packets_rx += delta; - } - } - - metrics.network_bytes_sent = total_bytes_tx; - metrics.network_bytes_received = total_bytes_rx; - metrics.network_packets_sent = total_packets_tx; - metrics.network_packets_received = total_packets_rx; - - // Calculate memory usage percentage - if metrics.memory_total_bytes > 0 { - metrics.memory_usage_percent = - (metrics.memory_used_bytes as f64 / metrics.memory_total_bytes as f64) * 100.0; - } - - metrics - } - - /// Calculate delta for a single time series, handling counter resets - fn calculate_series_delta(&self, samples: &BTreeMap) -> u64 { - if samples.len() < 2 { - return 0; - } - - let mut total_delta = 0u64; - let mut prev_value = None; - - // Iterate through samples in chronological order - for (_, &value) in samples { - if let Some(prev) = prev_value { - let diff = value - prev; - if diff >= 0.0 { - // Normal case: positive difference - total_delta += diff as u64; - } else { - // Counter reset: treat negative difference as the later value - total_delta += value as u64; - } - } - prev_value = Some(value); - } - - total_delta - } -} - -impl Collector for ServerCollector { - fn collect( - &mut self, - start_time: DateTime, - duration_seconds: i64, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - let mut metrics = ServerData::new(self.config.server_url.clone()); - - // Perform HTTP health check - let health_result = self.perform_health_check(&self.config.server_url).await; - if let Ok((response_time, status_code)) = health_result { - metrics.response_time_ms = response_time; - metrics.status_code = Some(status_code); - // Only consider 2xx status codes as healthy - metrics.is_healthy = status_code >= 200 && status_code <= 299; - } else { - // Network error or timeout - mark as unhealthy - metrics.is_healthy = false; - } - - // Query server metrics from ClickHouse - let server_metrics = self.query_server_metrics(start_time, duration_seconds).await?; - let processed_metrics = self.process_server_metrics(server_metrics); - - // Query participant join events - let participant_joins = self.query_participant_joins(start_time, duration_seconds).await?; - - // Merge processed metrics with health data - metrics.cpu_usage_percent = processed_metrics.cpu_usage_percent; - metrics.cpu_load_average = processed_metrics.cpu_load_average; - metrics.cpu_data_points = processed_metrics.cpu_data_points; // Copy CPU data points for graph - metrics.memory_used_bytes = processed_metrics.memory_used_bytes; - metrics.memory_total_bytes = processed_metrics.memory_total_bytes; - metrics.memory_usage_percent = processed_metrics.memory_usage_percent; - metrics.network_bytes_sent = processed_metrics.network_bytes_sent; - metrics.network_bytes_received = processed_metrics.network_bytes_received; - metrics.network_packets_sent = processed_metrics.network_packets_sent; - metrics.network_packets_received = processed_metrics.network_packets_received; - - // Store participant join events - metrics.participant_join_events = participant_joins - .into_iter() - .filter_map(|event| { - DateTime::from_timestamp_millis(event.first_seen).map(|first_seen| { - crate::metrics::ParticipantJoinEvent { - participant_id: event.participant_id, - first_seen, - } - }) - }) - .collect(); - - // Store metrics internally - self.metrics = Some(metrics); - Ok(()) - }) - } - - fn format(&self) -> String { - let metrics = match &self.metrics { - Some(m) => m, - None => return "No metrics collected yet. Call collect() first.".to_string(), - }; - - let mut output = String::new(); - - // Create the beautiful table display - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![Cell::new("🖥️ SERVER METRICS ") - .add_attribute(Attribute::Bold) - .fg(Color::Cyan)]); - - table.add_row(vec![ - Cell::new("Health Status").add_attribute(Attribute::Bold), - Cell::new(format!( - "{} ({:.0}ms)", - if metrics.is_healthy { - "✅ Healthy" - } else { - "❌ Unhealthy" - }, - metrics.response_time_ms - )) - .fg(if metrics.is_healthy { Color::Green } else { Color::Red }), - ]); - - table.add_row(vec![ - Cell::new("CPU Usage").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}%", metrics.cpu_usage_percent)).fg(self.get_cpu_color(metrics.cpu_usage_percent)), - ]); - - table.add_row(vec![ - Cell::new("Memory Usage").add_attribute(Attribute::Bold), - Cell::new(format!( - "{} / {} ({:.1}%)", - self.format_bytes(metrics.memory_used_bytes), - self.format_bytes(metrics.memory_total_bytes), - metrics.memory_usage_percent - )) - .fg(self.get_memory_color(metrics.memory_usage_percent)), - ]); - - table.add_row(vec![ - Cell::new("Network Sent").add_attribute(Attribute::Bold), - Cell::new(self.format_bytes(metrics.network_bytes_sent).to_string()), - ]); - - table.add_row(vec![ - Cell::new("Network Received").add_attribute(Attribute::Bold), - Cell::new(self.format_bytes(metrics.network_bytes_received).to_string()), - ]); - - table.add_row(vec![ - Cell::new("Packets Sent").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.network_packets_sent).to_string()), - ]); - - table.add_row(vec![ - Cell::new("Packets Received").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.network_packets_received).to_string()), - ]); - - output.push_str(&format!("{}\n", table)); - - // Add CPU bar chart - output.push_str(&self.format_cpu_bar_chart(&metrics.cpu_data_points, &metrics.participant_join_events)); - - output - } - - fn summary(&self) -> serde_json::Value { - let metrics = match &self.metrics { - Some(m) => m, - None => return serde_json::json!({"error": "No metrics collected yet"}), - }; - - serde_json::json!({ - "server_url": metrics.server_url, - "timestamp": metrics.timestamp, - "health": { - "is_healthy": metrics.is_healthy, - "response_time_ms": metrics.response_time_ms, - "status_code": metrics.status_code - }, - "cpu": { - "usage_percent": metrics.cpu_usage_percent, - "load_average": metrics.cpu_load_average - }, - "memory": { - "used_bytes": metrics.memory_used_bytes, - "total_bytes": metrics.memory_total_bytes, - "usage_percent": metrics.memory_usage_percent, - "used_gb": metrics.memory_usage_gb(), - "total_gb": metrics.memory_total_gb() - }, - "network": { - "bytes_sent": metrics.network_bytes_sent, - "bytes_received": metrics.network_bytes_received, - "packets_sent": metrics.network_packets_sent, - "packets_received": metrics.network_packets_received, - "errors": metrics.network_errors - } - }) - } - - fn name(&self) -> &'static str { - "ServerCollector" - } -} - -impl ServerCollector { - /// Get color for CPU usage based on threshold - fn get_cpu_color(&self, cpu_usage: f64) -> Color { - if cpu_usage < 50.0 { - Color::Green - } else if cpu_usage < 80.0 { - Color::Yellow - } else { - Color::Red - } - } - - /// Get color for memory usage based on threshold - fn get_memory_color(&self, memory_usage: f64) -> Color { - if memory_usage < 60.0 { - Color::Green - } else if memory_usage < 85.0 { - Color::Yellow - } else { - Color::Red - } - } - - /// Format bytes in human readable format - fn format_bytes(&self, bytes: u64) -> String { - if bytes >= 1024 * 1024 * 1024 { - format!("{:.1}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } else if bytes >= 1024 * 1024 { - format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0)) - } else if bytes >= 1024 { - format!("{:.1}KB", bytes as f64 / 1024.0) - } else { - format!("{}B", bytes) - } - } - - /// Format numbers in human readable format - fn format_number(&self, num: u64) -> String { - if num >= 1_000_000 { - format!("{:.1}M", num as f64 / 1_000_000.0) - } else if num >= 1_000 { - format!("{:.1}K", num as f64 / 1_000.0) - } else { - format!("{}", num) - } - } - - /// Format CPU usage as a table with timestamp, bar, and percentage columns - fn format_cpu_bar_chart( - &self, - cpu_data_points: &[crate::metrics::CpuDataPoint], - participant_joins: &[crate::metrics::ParticipantJoinEvent], - ) -> String { - let mut output = String::new(); - output.push_str("\n📊 CPU USAGE OVER TIME\n"); - - // If no data points, show a message - if cpu_data_points.is_empty() { - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("No data available for the time period").add_attribute(Attribute::Bold) - ]); - output.push_str(&format!("{}\n", table)); - return output; - } - - // Extract CPU values and timestamps - let cpu_values: Vec = cpu_data_points.iter().map(|dp| dp.cpu_usage_percent).collect(); - let timestamps: Vec = cpu_data_points - .iter() - .map(|dp| dp.timestamp.format("%H:%M:%S").to_string()) - .collect(); - - // Calculate statistics from original data - let min_val = cpu_values.iter().fold(f64::INFINITY, |a, &b| a.min(b)); - let max_val = cpu_values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)); - let avg = if cpu_values.len() > 0 { - cpu_values.iter().sum::() / cpu_values.len() as f64 - } else { - 0.0 - }; - - // Group data if we have more than 10 data points - let (display_values, display_timestamps) = if cpu_values.len() > 10 { - let max_groups = 10; - let group_size = (cpu_values.len() as f64 / max_groups as f64).ceil() as usize; - let mut grouped_values = Vec::new(); - let mut grouped_timestamps = Vec::new(); - - for i in (0..cpu_values.len()).step_by(group_size) { - let end_idx = (i + group_size).min(cpu_values.len()); - let group_avg = if end_idx > i { - cpu_values[i..end_idx].iter().sum::() / (end_idx - i) as f64 - } else { - 0.0 - }; - grouped_values.push(group_avg); - - // Create time range for the group - let start_time = ×tamps[i]; - let end_time = if end_idx < timestamps.len() { - ×tamps[end_idx - 1] - } else { - ×tamps[timestamps.len() - 1] - }; - - if start_time == end_time { - grouped_timestamps.push(start_time.clone()); - } else { - grouped_timestamps.push(format!("{}~{}", start_time, end_time)); - } - } - (grouped_values, grouped_timestamps) - } else { - (cpu_values, timestamps) - }; - - // Create table with comfy_table - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("Timestamp").add_attribute(Attribute::Bold), - Cell::new("Joined").add_attribute(Attribute::Bold), - Cell::new("Bar").add_attribute(Attribute::Bold), - Cell::new("%").add_attribute(Attribute::Bold), - ]); - - // Add data rows using grouped data - for (timestamp_index, (value, timestamp)) in display_values.iter().zip(display_timestamps.iter()).enumerate() { - // Calculate bar length (max 50 characters) - let bar_length = if max_val > 0.0 { - ((value / max_val) * 50.0).round() as usize - } else { - 0 - }; - let bar_length = bar_length.max(1); // At least 1 character wide - - // Create the bar with solid block character - let bar = "█".repeat(bar_length); - - // Apply color based on CPU usage - let color = match *value { - v if v < 30.0 => Color::Green, - v if v < 60.0 => Color::Yellow, - v if v < 80.0 => Color::Magenta, - _ => Color::Red, - }; - - // Calculate participants joined in this time interval - let participants_joined = - self.count_participants_in_time_interval(participant_joins, timestamp_index, cpu_data_points); - - table.add_row(vec![ - Cell::new(timestamp), - Cell::new(format!("{}", participants_joined)), - Cell::new(&bar).fg(color), - Cell::new(format!("{:.1}%", value)), - ]); - } - - // Add footer rows with statistics - let start_time = cpu_data_points - .first() - .map(|dp| dp.timestamp.format("%H:%M:%S").to_string()) - .unwrap_or_else(|| "N/A".to_string()); - let end_time = cpu_data_points - .last() - .map(|dp| dp.timestamp.format("%H:%M:%S").to_string()) - .unwrap_or_else(|| "N/A".to_string()); - - let time_range = format!( - "Time Range: {} → {} │ Data Points: {}", - start_time, - end_time, - cpu_data_points.len() - ); - table.add_row(vec!["", "", &time_range, ""]); - - let stats = format!("Min: {:.1}% │ Avg: {:.1}% │ Max: {:.1}%", min_val, avg, max_val); - table.add_row(vec!["", "", &stats, ""]); - - output.push_str(&format!("{}\n", table)); - - output - } - - /// Count participants that joined in a specific time interval - fn count_participants_in_time_interval( - &self, - participant_joins: &[crate::metrics::ParticipantJoinEvent], - timestamp_index: usize, - cpu_data_points: &[crate::metrics::CpuDataPoint], - ) -> usize { - if participant_joins.is_empty() || cpu_data_points.is_empty() { - return 0; - } - - // Determine the time interval for this timestamp - let group_size = if cpu_data_points.len() > 10 { - (cpu_data_points.len() as f64 / 10.0).ceil() as usize - } else { - 1 - }; - - let start_idx = timestamp_index * group_size; - let end_idx = ((timestamp_index + 1) * group_size).min(cpu_data_points.len()); - - let interval_start = if start_idx < cpu_data_points.len() { - cpu_data_points[start_idx].timestamp - } else { - return 0; - }; - - let interval_end = if end_idx > 0 && end_idx - 1 < cpu_data_points.len() { - cpu_data_points[end_idx - 1].timestamp - } else { - return 0; - }; - - // Count participants that joined during this time interval - participant_joins - .iter() - .filter(|join_event| join_event.first_seen >= interval_start && join_event.first_seen <= interval_end) - .count() - } -} - -// ClickHouse row struct for server metrics -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct ServerMetricRow { - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - name: String, - labels: Vec<(String, String)>, - value: f64, - #[allow(dead_code)] - metric_type: u8, -} - -// ClickHouse row struct for participant join events -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct ParticipantJoinEventRow { - participant_id: u16, - first_seen: i64, // milliseconds since epoch -} diff --git a/stats-gatherer/src/collectors/space_collector.rs b/stats-gatherer/src/collectors/space_collector.rs deleted file mode 100644 index 4a97150..0000000 --- a/stats-gatherer/src/collectors/space_collector.rs +++ /dev/null @@ -1,701 +0,0 @@ -use crate::{ - collectors::Collector, - config::Config, - metrics::*, -}; -use chrono::{ - DateTime, - Utc, -}; -use clickhouse::Client; -use comfy_table::{ - presets, - Attribute, - Cell, - Color, - ContentArrangement, - Table, -}; -use eyre::Result; -use serde_json; -use std::{ - collections::HashMap, - future::Future, - pin::Pin, -}; - -/// Collects and formats space-level data (participant data, CPU, latency, throughput) -pub struct SpaceCollector { - config: Config, - clickhouse_client: Client, - metrics: Option, -} - -impl SpaceCollector { - pub async fn new(config: Config, clickhouse_client: Client) -> Result { - Ok(Self { - config, - clickhouse_client, - metrics: None, - }) - } - - /// Query global metrics (CPU, memory) for the space - async fn query_global_metrics( - &self, - space_id: &str, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - space_id, - ts, - name, - labels, - value - FROM hyper_session.global_metrics - WHERE space_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY ts - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query CPU usage metrics for the space - async fn query_cpu_usage( - &self, - space_id: &str, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - space_id, - participant_id, - ts, - value - FROM hyper_session.cpu_usage - WHERE space_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY ts - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query throughput metrics for the space - async fn query_throughput_metrics( - &self, - space_id: &str, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - participant_id, - toUInt8(direction) as direction, - toUInt8(media_type) as media_type, - ts, - value - FROM hyper_session.throughput - WHERE space_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY participant_id, direction, ts - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Query latency metrics for the space - async fn query_latency_metrics( - &self, - space_id: &str, - start_time: DateTime, - duration_seconds: i64, - ) -> Result> { - let end_time = start_time + chrono::Duration::seconds(duration_seconds); - let since_str = start_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - let until_str = end_time.format("%Y-%m-%d %H:%M:%S%.3f").to_string(); - - let query = " - SELECT - receiving_participant_id, - sending_participant_id, - toUInt8(media_type) as media_type, - stream_id, - ts, - collect, - encode, - send, - sender, - relay, - receiver, - decode, - total - FROM hyper_session.participant_metrics - WHERE space_id = ? - AND ts >= toDateTime64(?, 3, 'UTC') - AND ts <= toDateTime64(?, 3, 'UTC') - ORDER BY ts ASC, receiving_participant_id, sending_participant_id - "; - - let rows: Vec = self - .clickhouse_client - .query(query) - .bind(space_id) - .bind(&since_str) - .bind(&until_str) - .fetch_all() - .await?; - - Ok(rows) - } - - /// Process global metrics (audio/video processing) for the space - /// Note: Memory metrics are not available at space level in hyper_session.global_metrics - /// This table contains audio/video processing metrics instead - fn process_global_metrics(&self, space_metrics: &mut SpaceData, global_metrics: Vec) { - let mut audio_gap_durations = Vec::new(); - - for data_point in global_metrics { - let name = &data_point.name; - let value = data_point.value; - - match name.as_str() { - // Audio processing metrics - "audio_decoder_output" => { - space_metrics.audio_processing_metrics.audio_decoder_output_count += 1; - } - "audio_decoder_decode" => { - space_metrics.audio_processing_metrics.audio_decoder_decode_count += 1; - } - "audio_scheduler_schedule" => { - space_metrics.audio_processing_metrics.audio_scheduler_schedule_count += 1; - } - "audio_preprocessing_gap_duration" => { - audio_gap_durations.push(value); - } - "audio_gain_normalization_factor" => { - space_metrics.audio_processing_metrics.audio_gain_normalization_factor = value; - } - "audio_source_volume" => { - space_metrics.audio_processing_metrics.audio_source_volume = value; - } - - // Video processing metrics - "video_decoder_output" => { - space_metrics.audio_processing_metrics.video_decoder_output_count += 1; - } - "video_restore_commit" => { - space_metrics.audio_processing_metrics.video_restore_commit_count += 1; - } - "video_streams_active" => { - space_metrics.audio_processing_metrics.video_streams_active = value as u32; - } - "video_send_key_data_request" => { - space_metrics.audio_processing_metrics.video_send_key_data_request_count += 1; - } - - // Network metrics - "datagrams_receive_expected" => { - space_metrics.audio_processing_metrics.datagrams_receive_expected = value as u64; - } - "datagrams_receive_lost" => { - space_metrics.audio_processing_metrics.datagrams_receive_lost = value as u64; - } - "datagrams_receive_received" => { - space_metrics.audio_processing_metrics.datagrams_receive_received = value as u64; - } - "datagrams_receive_packet_loss_rate" => { - space_metrics - .audio_processing_metrics - .datagrams_receive_packet_loss_rate = value; - } - - _ => {} // Ignore other metrics - } - } - - // Calculate average audio preprocessing gap duration - if !audio_gap_durations.is_empty() { - space_metrics - .audio_processing_metrics - .audio_preprocessing_gap_duration_avg = - audio_gap_durations.iter().sum::() / audio_gap_durations.len() as f64; - } - } - - /// Process CPU usage metrics for the space - fn process_cpu_usage(&self, space_metrics: &mut SpaceData, cpu_usage: Vec) { - // Group CPU usage by participant and calculate average per participant - let mut participant_cpu: HashMap> = HashMap::new(); - - for data_point in &cpu_usage { - participant_cpu - .entry(data_point.participant_id) - .or_default() - .push(data_point.value); - } - - if !participant_cpu.is_empty() { - // Calculate average CPU per participant, then average across all participants - let participant_averages: Vec = participant_cpu - .values() - .map(|values| values.iter().sum::() / values.len() as f64) - .collect(); - - let overall_avg_cpu = participant_averages.iter().sum::() / participant_averages.len() as f64; - - space_metrics.avg_cpu_usage_percent = overall_avg_cpu * 100.0; - space_metrics.participant_count = participant_cpu.len() as u32; - } - } - - /// Process throughput metrics for the space - fn process_throughput_metrics(&self, space_metrics: &mut SpaceData, throughput: Vec) { - let mut total_bytes_sent = 0u64; - let mut total_bytes_received = 0u64; - - for data_point in throughput { - let value = data_point.value as u64; - - // direction: 0 = tx, 1 = rx - // media_type: 0 = audio, 1 = video - // For now, we'll treat all throughput as bytes (assuming bits_per_second) - match data_point.direction { - 0 => { - // tx - total_bytes_sent += value / 8; // Convert bits to bytes - } - 1 => { - // rx - total_bytes_received += value / 8; // Convert bits to bytes - } - _ => {} - } - } - - space_metrics.total_network_bytes_sent = total_bytes_sent; - space_metrics.total_network_bytes_received = total_bytes_received; - } - - /// Process latency metrics for the space - fn process_latency_metrics(&self, space_metrics: &mut SpaceData, latency_metrics: Vec) { - let mut latencies = Vec::new(); - let mut total_latencies = Vec::new(); - - for data_point in latency_metrics { - let ts = DateTime::from_timestamp_millis(data_point.ts).unwrap_or_else(Utc::now); - - // Convert media_type from u8 to String - let media_type = match data_point.media_type { - 0 => "audio".to_string(), - 1 => "video".to_string(), - _ => "unknown".to_string(), - }; - - let mut latency = ParticipantLatencyMetrics::new( - data_point.receiving_participant_id, - data_point.sending_participant_id, - media_type, - data_point.stream_id, - ); - latency.timestamp = ts; - latency.collect_latency = data_point.collect; - latency.encode_latency = data_point.encode; - latency.send_latency = data_point.send; - latency.sender_latency = data_point.sender; - latency.relay_latency = data_point.relay; - latency.receiver_latency = data_point.receiver; - latency.decode_latency = data_point.decode; - latency.total_latency = data_point.total; - - latencies.push(latency); - total_latencies.push(data_point.total as f64); - } - - space_metrics.participant_latencies = latencies; - } -} - -impl SpaceCollector { - /// Get color for CPU usage based on threshold - fn get_cpu_color(&self, cpu_usage: f64) -> Color { - if cpu_usage < 50.0 { - Color::Green - } else if cpu_usage < 80.0 { - Color::Yellow - } else { - Color::Red - } - } - - /// Get color for latency based on threshold - fn get_latency_color(&self, latency_ms: f64) -> Color { - if latency_ms < 100.0 { - Color::Green - } else if latency_ms < 200.0 { - Color::Yellow - } else { - Color::Red - } - } - - /// Format bytes in human readable format - fn format_bytes(&self, bytes: u64) -> String { - if bytes >= 1024 * 1024 * 1024 { - format!("{:.1}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } else if bytes >= 1024 * 1024 { - format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0)) - } else if bytes >= 1024 { - format!("{:.1}KB", bytes as f64 / 1024.0) - } else { - format!("{}B", bytes) - } - } - - /// Format number in human readable format - fn format_number(&self, num: u64) -> String { - if num >= 1_000_000 { - format!("{:.1}M", num as f64 / 1_000_000.0) - } else if num >= 1_000 { - format!("{:.1}K", num as f64 / 1_000.0) - } else { - format!("{}", num) - } - } - - /// Print audio/video processing metrics table - fn print_audio_video_metrics_table(&self, metrics: &AudioVideoProcessingMetrics) -> String { - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![Cell::new("🎵🎬 AUDIO/VIDEO PROCESSING METRICS") - .add_attribute(Attribute::Bold) - .fg(Color::Magenta)]); - - // Audio metrics - table.add_row(vec![ - Cell::new("Audio Decoder Output").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.audio_decoder_output_count)), - ]); - - table.add_row(vec![ - Cell::new("Audio Decoder Decode").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.audio_decoder_decode_count)), - ]); - - table.add_row(vec![ - Cell::new("Audio Scheduler Schedule").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.audio_scheduler_schedule_count)), - ]); - - table.add_row(vec![ - Cell::new("Audio Gap Duration Avg").add_attribute(Attribute::Bold), - Cell::new(format!("{:.2}ms", metrics.audio_preprocessing_gap_duration_avg)), - ]); - - // Video metrics - table.add_row(vec![ - Cell::new("Video Decoder Output").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.video_decoder_output_count)), - ]); - - table.add_row(vec![ - Cell::new("Video Restore Commit").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.video_restore_commit_count)), - ]); - - table.add_row(vec![ - Cell::new("Video Streams Active").add_attribute(Attribute::Bold), - Cell::new(format!("{}", metrics.video_streams_active)), - ]); - - // Network metrics - table.add_row(vec![ - Cell::new("Datagrams Expected").add_attribute(Attribute::Bold), - Cell::new(self.format_number(metrics.datagrams_receive_expected)), - ]); - - table.add_row(vec![ - Cell::new("Datagrams Lost").add_attribute(Attribute::Bold), - Cell::new(format!("{}", metrics.datagrams_receive_lost)).fg(if metrics.datagrams_receive_lost == 0 { - Color::Green - } else { - Color::Red - }), - ]); - - table.add_row(vec![ - Cell::new("Packet Loss Rate").add_attribute(Attribute::Bold), - Cell::new(format!("{:.2}%", metrics.datagrams_receive_packet_loss_rate)).fg( - if metrics.datagrams_receive_packet_loss_rate < 1.0 { - Color::Green - } else { - Color::Yellow - }, - ), - ]); - - format!("{}\n", table) - } -} - -impl Collector for SpaceCollector { - fn collect( - &mut self, - start_time: DateTime, - duration_seconds: i64, - ) -> Pin> + Send + '_>> { - Box::pin(async move { - let space_id = self - .config - .space_id - .as_ref() - .ok_or_else(|| eyre::eyre!("Space ID is required for space-level metrics"))?; - - let mut space_metrics = SpaceData::new(space_id.clone(), self.config.server_url.clone()); - - // Query and process all metrics - let global_metrics = self - .query_global_metrics(space_id, start_time, duration_seconds) - .await?; - self.process_global_metrics(&mut space_metrics, global_metrics); - - let cpu_usage = self.query_cpu_usage(space_id, start_time, duration_seconds).await?; - self.process_cpu_usage(&mut space_metrics, cpu_usage); - - let throughput_metrics = self - .query_throughput_metrics(space_id, start_time, duration_seconds) - .await?; - self.process_throughput_metrics(&mut space_metrics, throughput_metrics); - - let latency_metrics = self - .query_latency_metrics(space_id, start_time, duration_seconds) - .await?; - self.process_latency_metrics(&mut space_metrics, latency_metrics); - - // Store metrics internally - self.metrics = Some(space_metrics); - Ok(()) - }) - } - - fn format(&self) -> String { - let metrics = match &self.metrics { - Some(m) => m, - None => return "No metrics collected yet. Call collect() first.".to_string(), - }; - - let mut output = String::new(); - - // Main space metrics table - let mut table = Table::new(); - table - .load_preset(presets::UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![Cell::new(format!( - "👥 SPACE METRICS (Space: {})", - metrics.space_id - )) - .add_attribute(Attribute::Bold) - .fg(Color::Green)]); - - // Add main metrics rows - table.add_row(vec![ - Cell::new("Participants").add_attribute(Attribute::Bold), - Cell::new(format!("{}", metrics.participant_count)), - ]); - - table.add_row(vec![ - Cell::new("Avg CPU Usage").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}%", metrics.avg_cpu_usage_percent)) - .fg(self.get_cpu_color(metrics.avg_cpu_usage_percent)), - ]); - - table.add_row(vec![ - Cell::new("Network Sent").add_attribute(Attribute::Bold), - Cell::new(self.format_bytes(metrics.total_network_bytes_sent).to_string()), - ]); - - table.add_row(vec![ - Cell::new("Network Received").add_attribute(Attribute::Bold), - Cell::new(self.format_bytes(metrics.total_network_bytes_received).to_string()), - ]); - - table.add_row(vec![ - Cell::new("Avg Latency").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}ms", metrics.avg_latency_ms())) - .fg(self.get_latency_color(metrics.avg_latency_ms())), - ]); - - table.add_row(vec![ - Cell::new("Max Latency").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}ms", metrics.max_latency_ms())) - .fg(self.get_latency_color(metrics.max_latency_ms())), - ]); - - table.add_row(vec![ - Cell::new("P95 Latency").add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}ms", metrics.p95_latency_ms())) - .fg(self.get_latency_color(metrics.p95_latency_ms())), - ]); - - output.push_str(&format!("{}\n", table)); - - // Add audio/video processing metrics table - output.push_str(&self.print_audio_video_metrics_table(&metrics.audio_processing_metrics)); - - output - } - - fn summary(&self) -> serde_json::Value { - let metrics = match &self.metrics { - Some(m) => m, - None => return serde_json::json!({"error": "No metrics collected yet"}), - }; - - serde_json::json!({ - "space_id": metrics.space_id, - "server_url": metrics.server_url, - "timestamp": metrics.timestamp, - "participants": { - "count": metrics.participant_count - }, - "cpu": { - "total_usage_percent": metrics.total_cpu_usage_percent, - "avg_usage_percent": metrics.avg_cpu_usage_percent, - "max_usage_percent": metrics.max_cpu_usage_percent - }, - "network": { - "bytes_sent": metrics.total_network_bytes_sent, - "bytes_received": metrics.total_network_bytes_received, - "errors": metrics.total_network_errors - }, - "latency": { - "avg_ms": metrics.avg_latency_ms(), - "max_ms": metrics.max_latency_ms(), - "p95_ms": metrics.p95_latency_ms(), - "participant_count": metrics.participant_latencies.len() - }, - "audio_video_processing": { - "audio_decoder_output_count": metrics.audio_processing_metrics.audio_decoder_output_count, - "audio_decoder_decode_count": metrics.audio_processing_metrics.audio_decoder_decode_count, - "audio_scheduler_schedule_count": metrics.audio_processing_metrics.audio_scheduler_schedule_count, - "audio_preprocessing_gap_duration_avg": metrics.audio_processing_metrics.audio_preprocessing_gap_duration_avg, - "audio_gain_normalization_factor": metrics.audio_processing_metrics.audio_gain_normalization_factor, - "audio_source_volume": metrics.audio_processing_metrics.audio_source_volume, - "video_decoder_output_count": metrics.audio_processing_metrics.video_decoder_output_count, - "video_restore_commit_count": metrics.audio_processing_metrics.video_restore_commit_count, - "video_streams_active": metrics.audio_processing_metrics.video_streams_active, - "video_send_key_data_request_count": metrics.audio_processing_metrics.video_send_key_data_request_count, - "datagrams_receive_expected": metrics.audio_processing_metrics.datagrams_receive_expected, - "datagrams_receive_lost": metrics.audio_processing_metrics.datagrams_receive_lost, - "datagrams_receive_received": metrics.audio_processing_metrics.datagrams_receive_received, - "datagrams_receive_packet_loss_rate": metrics.audio_processing_metrics.datagrams_receive_packet_loss_rate, - } - }) - } - - fn name(&self) -> &'static str { - "SpaceCollector" - } -} - -// ClickHouse row structs -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct GlobalMetricRow { - #[allow(dead_code)] - space_id: String, - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - name: String, - #[allow(dead_code)] - labels: Vec<(String, String)>, - value: f64, -} - -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct CpuUsageRow { - #[allow(dead_code)] - space_id: String, - participant_id: u16, - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - value: f64, -} - -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct ThroughputRow { - #[allow(dead_code)] - participant_id: u16, - direction: u8, // 0 = tx, 1 = rx - #[allow(dead_code)] - media_type: u8, // 0 = audio, 1 = video - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - value: f64, -} - -#[derive(Debug, Clone, serde::Deserialize, clickhouse::Row)] -struct LatencyMetricRow { - receiving_participant_id: u16, - sending_participant_id: u16, - media_type: u8, // 0 = audio, 1 = video - stream_id: u8, - #[allow(dead_code)] - ts: i64, // milliseconds since epoch - collect: u16, - encode: u16, - send: u16, - sender: u16, - relay: u16, - receiver: u16, - decode: u16, - total: u16, -} diff --git a/stats-gatherer/src/config.rs b/stats-gatherer/src/config.rs deleted file mode 100644 index 7fc9fd7..0000000 --- a/stats-gatherer/src/config.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! # Configuration Module -//! -//! This module handles configuration management and CLI argument parsing for the stats gatherer. -//! -//! ## Key Components -//! -//! - **`Config`**: Main configuration structure containing all settings -//! -//! ## Configuration Fields -//! -//! - **ClickHouse connection**: URL, username, password -//! - **Space URL**: Target space for metrics collection -//! - **Collection settings**: Interval duration -//! - **Output settings**: Optional JSON export file path -//! - **Parsed fields**: Server URL and space ID extracted from space URL -//! -//! ## URL Parsing -//! -//! The module automatically extracts server URL and space ID from the provided space URL. -//! Expected format: `https://server.com/m/SPACE_ID` or `https://server.com/SPACE_ID` - -use eyre::Result; -use serde::{ - Deserialize, - Serialize, -}; -use std::time::Duration; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub clickhouse_url: String, - pub clickhouse_user: String, - pub clickhouse_password: Option, - pub space_url: String, - pub collection_interval: Duration, - pub output_file: Option, - // Parsed from space_url - pub server_url: String, - pub space_id: Option, -} - -impl Config { - pub fn new( - clickhouse_url: String, - clickhouse_user: String, - clickhouse_password: Option, - space_url: String, - collection_interval: Duration, - output_file: Option, - ) -> Result { - let space_id = Self::parse_space_id(&space_url)?; - let server_url = Self::extract_server_url(&space_url)?; - - Ok(Self { - clickhouse_url, - clickhouse_user, - clickhouse_password, - space_url, - collection_interval, - output_file, - server_url, - space_id, - }) - } - - /// Parse space URL to extract space ID - /// Expected format: `https://server.com/m/SPACE_ID` or `https://server.com/SPACE_ID/` - fn parse_space_id(space_url: &str) -> Result> { - let url = url::Url::parse(space_url)?; - - // Extract space ID from path segments - let path_segments: Vec<&str> = url - .path_segments() - .map(|segments| segments.collect()) - .unwrap_or_default(); - - let space_id = match path_segments.as_slice() { - ["m", id, ..] if !id.is_empty() => Some((*id).to_string()), - [id, ..] if !id.is_empty() && *id != "m" => Some((*id).to_string()), - _ => None, - }; - - Ok(space_id) - } - - /// Extract server URL from space URL - /// Expected format: `https://server.com/m/SPACE_ID` or `https://server.com/SPACE_ID/` - fn extract_server_url(space_url: &str) -> Result { - let url = url::Url::parse(space_url)?; - let host = url - .host_str() - .ok_or_else(|| eyre::eyre!("No host found in space URL: {}", space_url))?; - let server_url = match url.port() { - Some(port) => format!("{}://{}:{}", url.scheme(), host, port), - None => format!("{}://{}", url.scheme(), host), - }; - Ok(server_url) - } -} diff --git a/stats-gatherer/src/lib.rs b/stats-gatherer/src/lib.rs deleted file mode 100644 index d2faf1d..0000000 --- a/stats-gatherer/src/lib.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! # Hyper.Video Stats Gatherer -//! -//! A comprehensive analytics tool for collecting and displaying metrics from Hyper.Video sessions. -//! -//! ## Features -//! -//! - **Server-level metrics**: Health status, CPU usage, memory usage, network traffic -//! - **Space-level metrics**: Participant count, CPU usage, network traffic, latency metrics -//! - **Audio/Video processing metrics**: Decoder performance, scheduler metrics, gap analysis -//! - **Network quality metrics**: Packet loss, datagram statistics -//! - **Beautiful terminal display**: Enhanced tables with color-coded performance indicators -//! - **JSON export**: Programmatic access to collected data -//! -//! ## Architecture -//! -//! The tool is built with a modular architecture where each collector is self-contained: -//! -//! - **`config`**: Configuration management and CLI argument parsing -//! - **`metrics`**: Data structures for server and space-level metrics -//! - **`collectors`**: Self-contained data collection and formatting modules -//! - **`ServerCollector`**: Collects server-level metrics and handles display/JSON export -//! - **`SpaceCollector`**: Collects space-level metrics and handles display/JSON export -//! - **`Orchestrator`**: Creates shared clients and coordinates all collectors -//! -//! ## Usage -//! -//! ```bash -//! # Collect metrics for a specific space -//! stats-gatherer --clickhouse-url=http://clickhouse:8123 \ -//! --clickhouse-user=default \ -//! --space-url=https://server.com/SPACE-ID \ -//! --duration=5m -//! -//! # Export to JSON file -//! stats-gatherer --output-file=metrics.json \ -//! --clickhouse-url=http://clickhouse:8123 \ -//! --clickhouse-user=default \ -//! --space-url=https://server.com/SPACE-ID \ -//! --duration=5m -//! ``` - -pub mod collectors; -pub mod config; -pub mod metrics; - -pub use collectors::*; -pub use config::Config; -pub use metrics::*; diff --git a/stats-gatherer/src/main.rs b/stats-gatherer/src/main.rs deleted file mode 100644 index 570a060..0000000 --- a/stats-gatherer/src/main.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! # Hyper.Video Stats Gatherer - Main Entry Point -//! -//! This tool collects and displays analytics data from Hyper.Video sessions by: -//! -//! 1. Connecting directly to ClickHouse database -//! 2. Querying server-level and space-level metrics -//! 3. Processing audio/video processing metrics -//! 4. Displaying results in beautiful terminal tables -//! 5. Optionally exporting data as JSON - -use chrono::Utc; -use clap::Parser; -use client_simulator_stats_gatherer::{ - config::Config, - Collector, - Orchestrator, -}; -use color_eyre::Result; -use std::time::Duration; -use tracing::info; - -#[derive(Parser)] -#[command(name = "stats-gatherer")] -#[command(about = "Hyper.Video Analytics Data Gatherer")] -#[command(version)] -struct Cli { - /// ClickHouse connection URL (e.g., http://clickhouse:8123) - #[arg(long, env = "HYPER_CLICKHOUSE_URL")] - clickhouse_url: String, - - /// ClickHouse username - #[arg(long, env = "HYPER_CLICKHOUSE_USER", default_value = "default")] - clickhouse_user: String, - - /// ClickHouse password (optional) - #[arg(long, env = "HYPER_CLICKHOUSE_PASSWORD")] - clickhouse_password: Option, - - /// Space URL (provide the URL with SPACE_ID for space specific metrics, or without for global metrics) - #[arg(long, env = "HYPER_SPACE_URL")] - space_url: String, - - /// Duration to look back for historical data (e.g., "5m", "1h", "30s") - #[arg(long, default_value = "5m")] - duration: String, - - /// Start time for data collection (ISO 8601 format, e.g., "2025-09-30T16:00:00Z") - /// If not provided, will use current time minus duration - #[arg(long)] - start_time: Option, - - /// Output file path (optional, if provided exports as JSON, otherwise displays formatted output) - #[arg(long)] - output_file: Option, - - /// Enable verbose logging - #[arg(short, long)] - verbose: bool, -} - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - - // Setup logging - let log_level = if cli.verbose { "debug" } else { "info" }; - tracing_subscriber::fmt() - .with_env_filter(format!("stats_gatherer={log_level},clickhouse=warn")) - .init(); - - color_eyre::install()?; - - info!("Starting Hyper.Video Stats Gatherer"); - info!("ClickHouse URL: {}", cli.clickhouse_url); - info!("Space URL: {}", cli.space_url); - info!("Duration: {}", cli.duration); - - // Parse duration - let duration = parse_duration(&cli.duration)?; - info!("Parsed duration: {:?}", duration); - - // Parse start time if provided, otherwise use current time minus duration - let start_time = if let Some(start_time_str) = &cli.start_time { - chrono::DateTime::parse_from_rfc3339(start_time_str) - .map_err(|e| eyre::eyre!("Invalid start time '{}': {}", start_time_str, e))? - .with_timezone(&chrono::Utc) - } else { - Utc::now() - chrono::Duration::seconds(duration.as_secs() as i64) - }; - info!("Collection start time: {}", start_time.format("%Y-%m-%d %H:%M:%S UTC")); - - // Extract server URL from space URL for display purposes - let server_url = extract_server_url_from_space_url(&cli.space_url)?; - info!("Extracted server URL from space URL: {}", server_url); - - // Create configuration - let config = Config::new( - cli.clickhouse_url, - cli.clickhouse_user, - cli.clickhouse_password, - cli.space_url, - Duration::from_secs(1), // Not used for interval collection anymore - cli.output_file, - )?; - - info!("Parsed server URL: {}", config.server_url); - if let Some(space_id) = &config.space_id { - info!("Parsed space ID: {}", space_id); - } else { - info!("No space ID found in URL - will collect global metrics"); - } - - // Extract output_file before moving config - let output_file = config.output_file.clone(); - - // Create orchestrator - let mut orchestrator = Orchestrator::new(config).await?; - - orchestrator.collect(start_time, duration.as_secs() as i64).await?; - - println!("{}", orchestrator.format()); - - // Additionally export to JSON if output file is specified using unified interface - if let Some(output_file) = &output_file { - let json_string = serde_json::to_string_pretty(&orchestrator.summary())?; - tokio::fs::write(output_file, json_string).await?; - info!("Data exported successfully to {}", output_file); - } - - info!("Data collection completed successfully"); - Ok(()) -} - -fn parse_duration(duration_str: &str) -> Result { - use humantime::parse_duration; - parse_duration(duration_str).map_err(|e| eyre::eyre!("Invalid duration '{}': {}", duration_str, e)) -} - -fn extract_server_url_from_space_url(space_url: &str) -> Result { - let url = url::Url::parse(space_url).map_err(|e| eyre::eyre!("Invalid space URL '{}': {}", space_url, e))?; - - let server_url = format!( - "{}://{}", - url.scheme(), - url.host_str() - .ok_or_else(|| eyre::eyre!("No host found in space URL: {}", space_url))? - ); - - Ok(server_url) -} diff --git a/stats-gatherer/src/metrics/mod.rs b/stats-gatherer/src/metrics/mod.rs deleted file mode 100644 index c94501b..0000000 --- a/stats-gatherer/src/metrics/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -pub mod server_data; -pub mod shared; -pub mod space_data; - -// Re-export the main types for easy access -use chrono::{ - DateTime, - Utc, -}; -use serde::{ - Deserialize, - Serialize, -}; -pub use server_data::*; -pub use shared::*; -pub use space_data::*; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CollectedData { - // Current data - pub server_level_data: Option, - pub space_level_data: Option, - pub collection_start: DateTime, - pub collection_end: DateTime, - pub collection_duration_seconds: f64, -} - -impl CollectedData { - pub fn new(collection_start: DateTime) -> Self { - Self { - server_level_data: None, - space_level_data: None, - collection_start, - collection_end: collection_start, - collection_duration_seconds: 0.0, - } - } - - pub fn finalize(&mut self) { - self.collection_end = Utc::now(); - // Don't override collection_duration_seconds as it's set to the actual time window being queried - } -} diff --git a/stats-gatherer/src/metrics/server_data.rs b/stats-gatherer/src/metrics/server_data.rs deleted file mode 100644 index 45ed389..0000000 --- a/stats-gatherer/src/metrics/server_data.rs +++ /dev/null @@ -1,100 +0,0 @@ -use chrono::{ - DateTime, - Utc, -}; -use serde::{ - Deserialize, - Serialize, -}; - -/// CPU data point with timestamp -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CpuDataPoint { - pub timestamp: DateTime, - pub cpu_usage_percent: f64, -} - -/// Participant join event -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ParticipantJoinEvent { - pub participant_id: u16, - pub first_seen: DateTime, -} - -/// Server-level data (CPU, memory, network) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerData { - pub server_url: String, - pub timestamp: DateTime, - - // CPU metrics - pub cpu_usage_percent: f64, - pub cpu_load_average: f64, - pub cpu_data_points: Vec, - - // Participant join events - pub participant_join_events: Vec, - - // Memory metrics - pub memory_used_bytes: u64, - pub memory_total_bytes: u64, - pub memory_usage_percent: f64, - - // Network metrics - pub network_bytes_sent: u64, - pub network_bytes_received: u64, - pub network_packets_sent: u64, - pub network_packets_received: u64, - pub network_errors: u64, - - // Server health - pub is_healthy: bool, - pub response_time_ms: f64, - pub status_code: Option, -} - -impl ServerData { - pub fn new(server_url: String) -> Self { - Self { - server_url, - timestamp: Utc::now(), - cpu_usage_percent: 0.0, - cpu_load_average: 0.0, - cpu_data_points: Vec::new(), - participant_join_events: Vec::new(), - memory_used_bytes: 0, - memory_total_bytes: 0, - memory_usage_percent: 0.0, - network_bytes_sent: 0, - network_bytes_received: 0, - network_packets_sent: 0, - network_packets_received: 0, - network_errors: 0, - is_healthy: false, - response_time_ms: 0.0, - status_code: None, - } - } - - pub fn memory_usage_gb(&self) -> f64 { - self.memory_used_bytes as f64 / (1024.0 * 1024.0 * 1024.0) - } - - pub fn memory_total_gb(&self) -> f64 { - self.memory_total_bytes as f64 / (1024.0 * 1024.0 * 1024.0) - } - - pub fn network_mbps_sent(&self, seconds: f64) -> f64 { - if seconds <= 0.0 { - return 0.0; - } - (self.network_bytes_sent as f64 * 8.0) / (1_000_000.0 * seconds) - } - - pub fn network_mbps_received(&self, seconds: f64) -> f64 { - if seconds <= 0.0 { - return 0.0; - } - (self.network_bytes_received as f64 * 8.0) / (1_000_000.0 * seconds) - } -} diff --git a/stats-gatherer/src/metrics/shared.rs b/stats-gatherer/src/metrics/shared.rs deleted file mode 100644 index 9b0affc..0000000 --- a/stats-gatherer/src/metrics/shared.rs +++ /dev/null @@ -1,110 +0,0 @@ -use serde_repr::{ - Deserialize_repr, - Serialize_repr, -}; - -#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MediaType { - Audio = 0, - Video = 1, -} - -#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum Direction { - Tx = 0, - Rx = 1, -} - -#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum Unit { - Packets = 0, - BitsPerSecond = 1, - BytesPerSecond = 2, - Microseconds = 3, - Percent = 4, - Count = 5, -} - -#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MetricType { - Gauge = 0, - Counter = 1, - Histogram = 2, -} - -#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum StreamType { - Audio = 0, - Video = 1, -} - -#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum ParticipantType { - Sender = 0, - Receiver = 1, -} - -impl MediaType { - pub fn as_str(&self) -> &'static str { - match self { - MediaType::Audio => "audio", - MediaType::Video => "video", - } - } -} - -impl Direction { - pub fn as_str(&self) -> &'static str { - match self { - Direction::Tx => "tx", - Direction::Rx => "rx", - } - } -} - -impl Unit { - pub fn as_str(&self) -> &'static str { - match self { - Unit::Packets => "packets", - Unit::BitsPerSecond => "bps", - Unit::BytesPerSecond => "Bps", - Unit::Microseconds => "μs", - Unit::Percent => "%", - Unit::Count => "count", - } - } -} - -impl StreamType { - pub fn as_str(&self) -> &'static str { - match self { - StreamType::Audio => "audio", - StreamType::Video => "video", - } - } -} - -impl ParticipantType { - pub fn as_str(&self) -> &'static str { - match self { - ParticipantType::Sender => "sender", - ParticipantType::Receiver => "receiver", - } - } -} - -impl MetricType { - pub fn metric_type_str(&self) -> &'static str { - match self { - MetricType::Gauge => "gauge", - MetricType::Counter => "counter", - MetricType::Histogram => "histogram", - } - } -} diff --git a/stats-gatherer/src/metrics/space_data.rs b/stats-gatherer/src/metrics/space_data.rs deleted file mode 100644 index 5d3773f..0000000 --- a/stats-gatherer/src/metrics/space_data.rs +++ /dev/null @@ -1,195 +0,0 @@ -use chrono::{ - DateTime, - Utc, -}; -use serde::{ - Deserialize, - Serialize, -}; - -/// Space-level data (CPU, memory, network, latency per participant) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SpaceData { - pub space_id: String, - pub server_url: String, - pub timestamp: DateTime, - - // Space-wide CPU metrics - pub total_cpu_usage_percent: f64, - pub avg_cpu_usage_percent: f64, - pub max_cpu_usage_percent: f64, - - // Space-wide network metrics - pub total_network_bytes_sent: u64, - pub total_network_bytes_received: u64, - pub total_network_errors: u64, - - // Latency metrics per participant - pub participant_latencies: Vec, - - // Participant count - pub participant_count: u32, - - // Audio/Video processing metrics - pub audio_processing_metrics: AudioVideoProcessingMetrics, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudioVideoProcessingMetrics { - // Audio processing metrics - pub audio_decoder_output_count: u64, - pub audio_decoder_decode_count: u64, - pub audio_scheduler_schedule_count: u64, - pub audio_preprocessing_gap_duration_avg: f64, - pub audio_gain_normalization_factor: f64, - pub audio_source_volume: f64, - - // Video processing metrics - pub video_decoder_output_count: u64, - pub video_restore_commit_count: u64, - pub video_streams_active: u32, - pub video_send_key_data_request_count: u64, - - // Network metrics - pub datagrams_receive_expected: u64, - pub datagrams_receive_lost: u64, - pub datagrams_receive_received: u64, - pub datagrams_receive_packet_loss_rate: f64, -} - -impl Default for AudioVideoProcessingMetrics { - fn default() -> Self { - Self::new() - } -} - -impl AudioVideoProcessingMetrics { - pub fn new() -> Self { - Self { - audio_decoder_output_count: 0, - audio_decoder_decode_count: 0, - audio_scheduler_schedule_count: 0, - audio_preprocessing_gap_duration_avg: 0.0, - audio_gain_normalization_factor: 0.0, - audio_source_volume: 0.0, - video_decoder_output_count: 0, - video_restore_commit_count: 0, - video_streams_active: 0, - video_send_key_data_request_count: 0, - datagrams_receive_expected: 0, - datagrams_receive_lost: 0, - datagrams_receive_received: 0, - datagrams_receive_packet_loss_rate: 0.0, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ParticipantLatencyMetrics { - pub participant_id: u16, - pub sending_participant_id: u16, - pub media_type: String, // "audio" or "video" - pub stream_id: u8, - pub timestamp: DateTime, - - // Latency breakdown (in milliseconds) - pub collect_latency: u16, - pub encode_latency: u16, - pub send_latency: u16, - pub sender_latency: u16, - pub relay_latency: u16, - pub receiver_latency: u16, - pub decode_latency: u16, - pub total_latency: u16, - - // Throughput metrics - pub throughput_bps: f64, - pub packets_per_second: f64, -} - -impl SpaceData { - pub fn new(space_id: String, server_url: String) -> Self { - Self { - space_id, - server_url, - timestamp: Utc::now(), - total_cpu_usage_percent: 0.0, - avg_cpu_usage_percent: 0.0, - max_cpu_usage_percent: 0.0, - total_network_bytes_sent: 0, - total_network_bytes_received: 0, - total_network_errors: 0, - participant_latencies: Vec::new(), - participant_count: 0, - audio_processing_metrics: AudioVideoProcessingMetrics::new(), - } - } - - pub fn total_network_mbps_sent(&self, interval_seconds: f64) -> f64 { - if interval_seconds <= 0.0 { - return 0.0; - } - (self.total_network_bytes_sent as f64 * 8.0) / interval_seconds / 1_000_000.0 - } - - pub fn total_network_mbps_received(&self, interval_seconds: f64) -> f64 { - if interval_seconds <= 0.0 { - return 0.0; - } - (self.total_network_bytes_received as f64 * 8.0) / interval_seconds / 1_000_000.0 - } - - pub fn avg_latency_ms(&self) -> f64 { - if self.participant_latencies.is_empty() { - return 0.0; - } - self.participant_latencies - .iter() - .map(|p| p.total_latency as f64) - .sum::() - / self.participant_latencies.len() as f64 - } - - pub fn p95_latency_ms(&self) -> f64 { - if self.participant_latencies.is_empty() { - return 0.0; - } - let mut latencies: Vec = self - .participant_latencies - .iter() - .map(|p| p.total_latency as f64) - .collect(); - latencies.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let p95_index = (latencies.len() as f64 * 0.95) as usize; - latencies[p95_index.min(latencies.len() - 1)] - } - - pub fn max_latency_ms(&self) -> f64 { - self.participant_latencies - .iter() - .map(|p| p.total_latency as f64) - .fold(0.0, f64::max) - } -} - -impl ParticipantLatencyMetrics { - pub fn new(participant_id: u16, sending_participant_id: u16, media_type: String, stream_id: u8) -> Self { - Self { - participant_id, - sending_participant_id, - media_type, - stream_id, - timestamp: Utc::now(), - collect_latency: 0, - encode_latency: 0, - send_latency: 0, - sender_latency: 0, - relay_latency: 0, - receiver_latency: 0, - decode_latency: 0, - total_latency: 0, - throughput_bps: 0.0, - packets_per_second: 0.0, - } - } -} From b1225e775804df1a0ea86ad4c72048c50c02c4b7 Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:28:37 +0100 Subject: [PATCH 8/9] nix flake update --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 7b4976e..088a973 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1760965567, - "narHash": "sha256-0JDOal5P7xzzAibvD0yTE3ptyvoVOAL0rcELmDdtSKg=", + "lastModified": 1774273680, + "narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "cb82756ecc37fa623f8cf3e88854f9bf7f64af93", + "rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed", "type": "github" }, "original": { From 49a798319f5c1f30b57c8c7fae0672c654b0edcd Mon Sep 17 00:00:00 2001 From: Robert Krahn Date: Fri, 27 Mar 2026 12:48:53 +0100 Subject: [PATCH 9/9] fix devshell openssl runtime path --- flake.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flake.nix b/flake.nix index d8b920e..2af9510 100644 --- a/flake.nix +++ b/flake.nix @@ -8,6 +8,9 @@ flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; + runtimeLibs = with pkgs; [ + openssl + ]; in { packages = pkgs.callPackages ./nix/packages.nix { }; @@ -37,6 +40,7 @@ RUST_BACKTRACE = "1"; RUST_LOG = "debug"; LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; }; } );