Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI. This enables features like automatic page reloads when case data changes in the background, improving the user experience by keeping the UI synchronized with the backend. Key components: - `events` module: Contains the `CaseEventKind` enum, `CaseEvent` struct, and `EventSender` type for managing the broadcast channel. - SSE endpoint (`/web/events`): Streams events to connected browsers. - Client-side JavaScript: Listens for events and triggers debounced page reloads. - Integration points: Workers and route handlers now emit events when relevant state changes occur.
This commit is contained in:
@@ -12,6 +12,7 @@ use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FI
|
||||
use crate::auth::AuthenticatedWebUser;
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::paths::{DeleteMarker, write_delete_marker};
|
||||
use crate::routes::case_actions::{
|
||||
build_analysis_input, reset_case_artefacts, write_input_create_new,
|
||||
@@ -34,21 +35,30 @@ pub async fn handle_bulk_action(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
State(events_tx): State<EventSender>,
|
||||
Form(form): Form<BulkForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
|
||||
match form.action.as_str() {
|
||||
"analyze" => {
|
||||
bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await
|
||||
bulk_analyze(
|
||||
&config,
|
||||
&analyze_tx,
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
&user_root,
|
||||
&form.case_ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"delete" => bulk_delete(&user.slug, &user_root, &form.case_ids).await,
|
||||
"delete" => bulk_delete(&events_tx, &user.slug, &user_root, &form.case_ids).await,
|
||||
"reset" => {
|
||||
if !user.is_admin() {
|
||||
warn!(slug = %user.slug, "bulk-reset: not admin");
|
||||
return Err(AppError::Forbidden("Nur für Admins".into()));
|
||||
}
|
||||
bulk_reset(&user.slug, &user_root, &form.case_ids).await
|
||||
bulk_reset(&events_tx, &user.slug, &user_root, &form.case_ids).await
|
||||
}
|
||||
other => {
|
||||
warn!(slug = %user.slug, action = %other, "bulk: unknown action");
|
||||
@@ -62,6 +72,7 @@ pub async fn handle_bulk_action(
|
||||
async fn bulk_analyze(
|
||||
config: &Config,
|
||||
analyze_tx: &AnalyzeSender,
|
||||
events_tx: &EventSender,
|
||||
slug: &str,
|
||||
user_root: &std::path::Path,
|
||||
case_ids: &[String],
|
||||
@@ -117,12 +128,18 @@ async fn bulk_analyze(
|
||||
{
|
||||
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: send failed (worker gone)");
|
||||
}
|
||||
events::emit(events_tx, slug, case_id, CaseEventKind::AnalysisQueued);
|
||||
ok += 1;
|
||||
}
|
||||
info!(slug = %slug, ok, skipped, "bulk-analyze completed");
|
||||
}
|
||||
|
||||
async fn bulk_delete(slug: &str, user_root: &std::path::Path, case_ids: &[String]) {
|
||||
async fn bulk_delete(
|
||||
events_tx: &EventSender,
|
||||
slug: &str,
|
||||
user_root: &std::path::Path,
|
||||
case_ids: &[String],
|
||||
) {
|
||||
// One batch UUID + one timestamp shared across the entire bulk action,
|
||||
// so undo can restore exactly this group.
|
||||
let batch = uuid::Uuid::new_v4();
|
||||
@@ -155,12 +172,18 @@ async fn bulk_delete(slug: &str, user_root: &std::path::Path, case_ids: &[String
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
events::emit(events_tx, slug, case_id, CaseEventKind::CaseDeleted);
|
||||
ok += 1;
|
||||
}
|
||||
info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed");
|
||||
}
|
||||
|
||||
async fn bulk_reset(slug: &str, user_root: &std::path::Path, case_ids: &[String]) {
|
||||
async fn bulk_reset(
|
||||
events_tx: &EventSender,
|
||||
slug: &str,
|
||||
user_root: &std::path::Path,
|
||||
case_ids: &[String],
|
||||
) {
|
||||
let mut ok = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for case_id in case_ids {
|
||||
@@ -179,6 +202,7 @@ async fn bulk_reset(slug: &str, user_root: &std::path::Path, case_ids: &[String]
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
events::emit(events_tx, slug, case_id, CaseEventKind::CaseReset);
|
||||
ok += 1;
|
||||
}
|
||||
info!(slug = %slug, ok, skipped, "bulk-reset completed");
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::auth::AuthenticatedWebUser;
|
||||
use crate::case_id::CaseIdPath;
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker};
|
||||
use crate::routes::user_web::locate_case_or_404;
|
||||
|
||||
@@ -32,6 +33,7 @@ pub async fn handle_analyze_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, AppError> {
|
||||
if !config.llm_configured() {
|
||||
@@ -76,6 +78,13 @@ pub async fn handle_analyze_case(
|
||||
"analyze requested; analysis enqueued"
|
||||
);
|
||||
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
case_id.to_string(),
|
||||
CaseEventKind::AnalysisQueued,
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
}
|
||||
|
||||
@@ -242,6 +251,7 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()>
|
||||
pub async fn handle_delete_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
@@ -260,6 +270,13 @@ pub async fn handle_delete_case(
|
||||
"case soft-deleted"
|
||||
);
|
||||
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
case_id.to_string(),
|
||||
CaseEventKind::CaseDeleted,
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
}
|
||||
|
||||
@@ -273,6 +290,7 @@ pub async fn handle_delete_case(
|
||||
pub async fn handle_reset_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, AppError> {
|
||||
if !user.is_admin() {
|
||||
@@ -287,6 +305,12 @@ pub async fn handle_reset_case(
|
||||
.map_err(|e| AppError::Internal(format!("reset: {e}")))?;
|
||||
|
||||
info!(slug = %user.slug, case_id = %case_id, "case reset (admin)");
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
case_id.to_string(),
|
||||
CaseEventKind::CaseReset,
|
||||
);
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
}
|
||||
|
||||
@@ -299,6 +323,7 @@ pub async fn handle_reset_case(
|
||||
pub async fn handle_undo_delete(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
|
||||
@@ -308,7 +333,7 @@ pub async fn handle_undo_delete(
|
||||
return Ok(Redirect::to("/web/cases"));
|
||||
};
|
||||
|
||||
let restored = restore_batch(&user_root, batch).await;
|
||||
let restored = restore_batch(&user_root, batch, &events_tx, &user.slug).await;
|
||||
info!(slug = %user.slug, batch = %batch, restored, "undo-delete completed");
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
@@ -363,8 +388,14 @@ async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> {
|
||||
}
|
||||
|
||||
/// Remove `.deleted` markers from every case whose marker carries `batch`.
|
||||
/// Returns the number of restored cases.
|
||||
async fn restore_batch(user_root: &Path, batch: uuid::Uuid) -> usize {
|
||||
/// Returns the number of restored cases. Emits a `CaseRestored` event per
|
||||
/// case so subscribed browsers can update their listings.
|
||||
async fn restore_batch(
|
||||
user_root: &Path,
|
||||
batch: uuid::Uuid,
|
||||
events_tx: &EventSender,
|
||||
user_slug: &str,
|
||||
) -> usize {
|
||||
let mut count = 0;
|
||||
let mut entries = match tokio::fs::read_dir(user_root).await {
|
||||
Ok(r) => r,
|
||||
@@ -385,6 +416,12 @@ async fn restore_batch(user_root: &Path, batch: uuid::Uuid) -> usize {
|
||||
warn!(case_dir = ?case_path, error = %e, "failed to remove .deleted marker");
|
||||
continue;
|
||||
}
|
||||
events::emit(
|
||||
events_tx,
|
||||
user_slug,
|
||||
events::case_id_of(&case_path),
|
||||
CaseEventKind::CaseRestored,
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! SSE endpoint streaming case events to the web UI.
|
||||
//!
|
||||
//! One long-lived connection per browser tab. Non-admins only see events
|
||||
//! for their own user_slug; admins see events across all users
|
||||
//! (useful for support/monitoring). A 15s keep-alive comment keeps idle
|
||||
//! connections alive through reverse proxies / NAT.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::stream::Stream;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use crate::auth::AuthenticatedWebUser;
|
||||
use crate::events::EventSender;
|
||||
|
||||
/// `GET /web/events` — `text/event-stream`, one connection per tab.
|
||||
pub async fn handle_events(
|
||||
user: AuthenticatedWebUser,
|
||||
State(events_tx): State<EventSender>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
let rx = events_tx.subscribe();
|
||||
let is_admin = user.is_admin();
|
||||
let slug = user.slug.clone();
|
||||
|
||||
let stream = BroadcastStream::new(rx).filter_map(move |res| match res {
|
||||
Ok(evt) => {
|
||||
// Non-admins only see events for their own user_slug.
|
||||
if !is_admin && evt.user_slug != slug {
|
||||
return None;
|
||||
}
|
||||
// Serialization of `CaseEvent` is infallible in practice (plain
|
||||
// struct with String + enum fields); on the unexpected error
|
||||
// path we drop the event rather than tear down the stream.
|
||||
let json = serde_json::to_string(&evt).ok()?;
|
||||
Some(Ok(Event::default().event("case").data(json)))
|
||||
}
|
||||
// Lagged subscribers: skip silently. The next real event or the
|
||||
// user's next navigation will resync state from the filesystem.
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => {
|
||||
tracing::debug!(skipped = n, "sse subscriber lagged");
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keepalive"),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod bulk;
|
||||
mod case_actions;
|
||||
mod debug;
|
||||
mod events;
|
||||
mod health;
|
||||
mod login;
|
||||
mod magic;
|
||||
@@ -54,4 +55,5 @@ pub fn api_router() -> Router<AppState> {
|
||||
"/web/audio/{user}/{case_id}/{filename}",
|
||||
get(web::handle_audio),
|
||||
)
|
||||
.route("/web/events", get(events::handle_events))
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::models::{AckResponse, AckStatus};
|
||||
use crate::transcribe::{TranscribeJob, TranscribeSender};
|
||||
|
||||
pub async fn handle_upload(
|
||||
user: AuthenticatedUser,
|
||||
State(transcribe_tx): State<TranscribeSender>,
|
||||
State(events_tx): State<EventSender>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<AckResponse>, AppError> {
|
||||
let mut case_id: Option<String> = None;
|
||||
@@ -85,6 +87,12 @@ pub async fn handle_upload(
|
||||
case_id = %case_id,
|
||||
"Reopened soft-deleted case via new upload"
|
||||
);
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
&case_id,
|
||||
CaseEventKind::CaseRestored,
|
||||
);
|
||||
}
|
||||
|
||||
// Build a filesystem-safe filename from the timestamp (colons → hyphens).
|
||||
@@ -100,6 +108,13 @@ pub async fn handle_upload(
|
||||
"Recording received"
|
||||
);
|
||||
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
&case_id,
|
||||
CaseEventKind::RecordingUploaded,
|
||||
);
|
||||
|
||||
// Enqueue transcription. Failure here is non-fatal: on restart the recovery
|
||||
// scan will re-enqueue any .m4a without a .transcript.txt sibling.
|
||||
match transcribe_tx.try_send(TranscribeJob {
|
||||
|
||||
Reference in New Issue
Block a user