From bdaa21ae5d3a621f16c9dc681b1b0a1c1d98035b Mon Sep 17 00:00:00 2001 From: Anten Skrabec Date: Tue, 28 Jul 2026 21:51:17 -0600 Subject: [PATCH 1/2] fix: add auto-refresh for headless scaling and convergence operations Adds HeadlessChanged SSE event and broadcasts it when headless client operations complete (scale, converge, create, delete, rebuild, start, stop, restart, graceful-restart). The headless.html template now listens for headlessChanged events and auto-refreshes the client list. Operation forms use HTMX to submit asynchronously, and the operation_result template triggers a client list refresh on successful completion. Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 1 - src/web/handlers/clients.rs | 41 +++++++++++++++--------- src/web/sse.rs | 2 ++ templates/headless.html | 24 +++++++++++--- templates/headless/operation_result.html | 2 +- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/TODO.md b/TODO.md index a62125a5..e885b60e 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,6 @@ - headless client start/stop/restart buttons on `/quma/headless` go past the card length - currency items (USD, EUR) displayed as roubles instead of as currency balances (Stash) - typo in update changelog: versions url should use `/#versions` hash anchor, not `/versions` path segment (current URL 404s on Forge) -- no auto-refresh when scaling/converging clients - typo in update changelog: versions url should be like this `https://forge.sp-tarkov.com/mod/2310/wtt-commonlib/#versions` (`#version` is the important part) ## Convoy diff --git a/src/web/handlers/clients.rs b/src/web/handlers/clients.rs index 1b67d159..35c259b2 100644 --- a/src/web/handlers/clients.rs +++ b/src/web/handlers/clients.rs @@ -19,6 +19,7 @@ use crate::web::auth::{require_auth, require_permission, SessionUser}; use crate::web::error::WebError; use crate::web::flash::{set_flash, take_flash, FlashMessage, FlashType}; use crate::web::nav::NavContext; +use crate::web::sse::ServerEvent; use crate::web::state::AppState; #[allow(unused_imports)] @@ -368,11 +369,14 @@ pub async fn client_restart( .client_lifecycle(index, LifecycleAction::Restart) .await { - Ok(()) => set_flash( - &session, - &format!("Client {index} restarting"), - FlashType::Success, - ), + Ok(()) => { + let _ = state.events.send(ServerEvent::HeadlessChanged); + set_flash( + &session, + &format!("Client {index} restarting"), + FlashType::Success, + ); + } Err(e) => set_flash(&session, &e.to_string(), FlashType::Error), } @@ -414,6 +418,7 @@ pub async fn client_graceful_restart( match service.graceful_restart(index).await { Ok(result) => { + let _ = state.events.send(ServerEvent::HeadlessChanged); use crate::headless::service::GracefulResult; match result { GracefulResult::Exited => { @@ -474,11 +479,14 @@ pub async fn client_stop( }; match service.client_lifecycle(index, LifecycleAction::Stop).await { - Ok(()) => set_flash( - &session, - &format!("Client {index} stopped"), - FlashType::Success, - ), + Ok(()) => { + let _ = state.events.send(ServerEvent::HeadlessChanged); + set_flash( + &session, + &format!("Client {index} stopped"), + FlashType::Success, + ); + } Err(e) => set_flash(&session, &e.to_string(), FlashType::Error), } @@ -522,11 +530,14 @@ pub async fn client_start( .client_lifecycle(index, LifecycleAction::Start) .await { - Ok(()) => set_flash( - &session, - &format!("Client {index} started"), - FlashType::Success, - ), + Ok(()) => { + let _ = state.events.send(ServerEvent::HeadlessChanged); + set_flash( + &session, + &format!("Client {index} started"), + FlashType::Success, + ); + } Err(e) => set_flash(&session, &e.to_string(), FlashType::Error), } diff --git a/src/web/sse.rs b/src/web/sse.rs index 5ed465cf..530d3977 100644 --- a/src/web/sse.rs +++ b/src/web/sse.rs @@ -15,6 +15,7 @@ pub enum ServerEvent { RaidStarted, RaidEnded, IntegrityChanged, + HeadlessChanged, } pub async fn events_stream( @@ -37,6 +38,7 @@ pub async fn events_stream( ServerEvent::RaidStarted => "event: raidStarted\ndata: \n\n", ServerEvent::RaidEnded => "event: raidEnded\ndata: \n\n", ServerEvent::IntegrityChanged => "event: integrityChanged\ndata: \n\n", + ServerEvent::HeadlessChanged => "event: headlessChanged\ndata: \n\n", }; return Some((Ok::<_, actix_web::Error>(web::Bytes::from(msg)), rx)); } diff --git a/templates/headless.html b/templates/headless.html index c90491b3..820f05dc 100644 --- a/templates/headless.html +++ b/templates/headless.html @@ -12,22 +12,26 @@

Headless Clients

Clients

{% if user.can("headless.manage") %}
-
+
-
+
-
+
+ hx-post="/quma/headless/rebuild" hx-target="#operation-status" hx-swap="outerHTML" + hx-confirm="Rebuild all headless clients? This will destroy and recreate all containers and overlays.">
@@ -35,7 +39,8 @@

Clients

{% endif %}
-
+
+
{% if headless_converging %}
@@ -293,4 +298,13 @@

NUMA Scheduling

+ + {% endblock %} diff --git a/templates/headless/operation_result.html b/templates/headless/operation_result.html index 97423296..d126310d 100644 --- a/templates/headless/operation_result.html +++ b/templates/headless/operation_result.html @@ -1,4 +1,4 @@ -
+
{% if success %}
{{ message }}
{% else %} From 1eba29fcdc84a2981238f64cf8692aa1fbae121b Mon Sep 17 00:00:00 2001 From: Anten Skrabec Date: Tue, 28 Jul 2026 22:28:07 -0600 Subject: [PATCH 2/2] fix: broadcast HeadlessChanged from async ops and fix EventSource leak Fix-ups for headless auto-refresh PR: 1. Broadcast HeadlessChanged from async operation completions: - Pass events sender to HeadlessService via new() - Broadcast on ops.complete() and ops.fail() in spawn_convergence() - Broadcast from delete() async task (all error + success paths) - Broadcast from rebuild() async task (all error + success paths) 2. Fix EventSource connection leak in headless.html: - Close existing EventSource before creating new one - Store in window.headlessEventSource for reuse - Prevents accumulation on HTMX soft-reload 3. Remove double-refresh from operation_result.html: - Drop hx-trigger="load" attribute - SSE HeadlessChanged is canonical refresh mechanism Co-Authored-By: Claude Opus 4.6 (1M context) --- src/headless/service.rs | 44 ++++++++++++++++++++---- src/web/mod.rs | 1 + templates/headless.html | 8 +++-- templates/headless/operation_result.html | 2 +- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/headless/service.rs b/src/headless/service.rs index 68ed54bd..43329c10 100644 --- a/src/headless/service.rs +++ b/src/headless/service.rs @@ -6,7 +6,7 @@ use bollard::container::LogOutput; use futures_util::StreamExt; use parking_lot::Mutex; use serde::Serialize; -use tokio::sync::RwLock; +use tokio::sync::{broadcast, RwLock}; use crate::client::{ClientHealth, ClientState}; use crate::config::Config; @@ -17,6 +17,7 @@ use crate::fika::client::FikaClient; use crate::forge::client::ForgeClient; use crate::headless::{HeadlessError, OperationTracker}; use crate::spt::headless::EHeadlessStatus; +use crate::web::sse::ServerEvent; #[derive(Debug, Clone, Copy)] pub enum LifecycleAction { @@ -51,6 +52,7 @@ pub struct HeadlessService { pub(crate) fika_config_lock: Arc>, pub(crate) forge: ForgeClient, pub(crate) operations: OperationTracker, + pub(crate) events: broadcast::Sender, } impl HeadlessService { @@ -67,6 +69,7 @@ impl HeadlessService { fika_client: Option>, fika_config_lock: Arc>, forge: ForgeClient, + events: broadcast::Sender, ) -> Self { Self { container_mgr, @@ -81,6 +84,7 @@ impl HeadlessService { fika_config_lock, forge, operations: OperationTracker::new(), + events, } } @@ -125,6 +129,7 @@ impl HeadlessService { let converging = Arc::clone(&self.converging); let db = Arc::clone(&self.db); let ops = self.operations.clone(); + let events = self.events.clone(); tokio::spawn(async move { match crate::client::converge::converge( @@ -140,8 +145,14 @@ impl HeadlessService { ) .await { - Ok(()) => ops.complete(&op_id), - Err(e) => ops.fail(&op_id, e.to_string()), + Ok(()) => { + ops.complete(&op_id); + let _ = events.send(ServerEvent::HeadlessChanged); + } + Err(e) => { + ops.fail(&op_id, e.to_string()); + let _ = events.send(ServerEvent::HeadlessChanged); + } } }); op_id @@ -547,6 +558,7 @@ impl HeadlessService { let converging = Arc::clone(&self.converging); let db = Arc::clone(&self.db); let ops = self.operations.clone(); + let events = self.events.clone(); let mut updated_config = headless_config; updated_config.clients.remove((index - 1) as usize); @@ -554,6 +566,7 @@ impl HeadlessService { tokio::spawn(async move { if let Err(e) = crate::client::converge::remove_all_managed_containers(&mgr).await { ops.fail(&op_id, format!("Failed to remove containers: {e}")); + let _ = events.send(ServerEvent::HeadlessChanged); return; } @@ -568,12 +581,14 @@ impl HeadlessService { } if let Err(e) = fresh_config.save(&config_path) { ops.fail(&op_id, format!("Failed to save config: {e}")); + let _ = events.send(ServerEvent::HeadlessChanged); return; } *config_handle.write() = fresh_config; } Err(e) => { ops.fail(&op_id, format!("Failed to reload config: {e}")); + let _ = events.send(ServerEvent::HeadlessChanged); return; } } @@ -597,8 +612,14 @@ impl HeadlessService { ) .await { - Ok(()) => ops.complete(&op_id), - Err(e) => ops.fail(&op_id, e.to_string()), + Ok(()) => { + ops.complete(&op_id); + let _ = events.send(ServerEvent::HeadlessChanged); + } + Err(e) => { + ops.fail(&op_id, e.to_string()); + let _ = events.send(ServerEvent::HeadlessChanged); + } } }); @@ -647,6 +668,7 @@ impl HeadlessService { let converging = Arc::clone(&self.converging); let db = Arc::clone(&self.db); let ops = self.operations.clone(); + let events = self.events.clone(); tokio::spawn(async move { if converging @@ -659,12 +681,14 @@ impl HeadlessService { .is_err() { ops.fail(&op_id, "Convergence already in progress".into()); + let _ = events.send(ServerEvent::HeadlessChanged); return; } if let Err(e) = crate::client::converge::remove_all_managed_containers(&mgr).await { converging.store(false, std::sync::atomic::Ordering::Release); ops.fail(&op_id, format!("Failed to remove containers: {e}")); + let _ = events.send(ServerEvent::HeadlessChanged); return; } @@ -688,8 +712,14 @@ impl HeadlessService { ) .await { - Ok(()) => ops.complete(&op_id), - Err(e) => ops.fail(&op_id, e.to_string()), + Ok(()) => { + ops.complete(&op_id); + let _ = events.send(ServerEvent::HeadlessChanged); + } + Err(e) => { + ops.fail(&op_id, e.to_string()); + let _ = events.send(ServerEvent::HeadlessChanged); + } } }); diff --git a/src/web/mod.rs b/src/web/mod.rs index ea336746..6874a026 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1064,6 +1064,7 @@ pub async fn start_server(ctx: ServerContext, api_token: String) -> Result<()> { fika_client.clone(), Arc::clone(&fika_config_lock), forge.clone(), + events_tx.clone(), )) } else { None diff --git a/templates/headless.html b/templates/headless.html index 820f05dc..d46f8d8f 100644 --- a/templates/headless.html +++ b/templates/headless.html @@ -300,11 +300,15 @@

NUMA Scheduling

{% endblock %} diff --git a/templates/headless/operation_result.html b/templates/headless/operation_result.html index d126310d..97423296 100644 --- a/templates/headless/operation_result.html +++ b/templates/headless/operation_result.html @@ -1,4 +1,4 @@ -
+
{% if success %}
{{ message }}
{% else %}