diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 856e18e..2cd4ecd 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -85,10 +85,24 @@ pub async fn add_torrent_handler( tracing::error!("rTorrent returned fault: {}", response); return StatusCode::INTERNAL_SERVER_ERROR; } + let _ = + state + .event_bus + .send(shared::AppEvent::Notification(shared::SystemNotification { + level: shared::NotificationLevel::Success, + message: "Torrent added successfully".to_string(), + })); StatusCode::OK } Err(e) => { tracing::error!("Failed to add torrent: {}", e); + let _ = + state + .event_bus + .send(shared::AppEvent::Notification(shared::SystemNotification { + level: shared::NotificationLevel::Error, + message: format!("Failed to add torrent: {}", e), + })); StatusCode::INTERNAL_SERVER_ERROR } } @@ -121,7 +135,15 @@ pub async fn handle_torrent_action( // Special handling for delete_with_data if payload.action == "delete_with_data" { return match delete_torrent_with_data(&client, &payload.hash).await { - Ok(msg) => (StatusCode::OK, msg).into_response(), + Ok(msg) => { + let _ = state.event_bus.send(shared::AppEvent::Notification( + shared::SystemNotification { + level: shared::NotificationLevel::Success, + message: format!("Torrent deleted with data: {}", payload.hash), + }, + )); + (StatusCode::OK, msg).into_response() + } Err((status, msg)) => (status, msg).into_response(), }; } @@ -136,7 +158,16 @@ pub async fn handle_torrent_action( let params = vec![RpcParam::from(payload.hash.as_str())]; match client.call(method, ¶ms).await { - Ok(_) => (StatusCode::OK, "Action executed").into_response(), + Ok(_) => { + let _ = + state + .event_bus + .send(shared::AppEvent::Notification(shared::SystemNotification { + level: shared::NotificationLevel::Info, + message: format!("Action '{}' executed on torrent", payload.action), + })); + (StatusCode::OK, "Action executed").into_response() + } Err(e) => { tracing::error!("RPC error: {}", e); ( diff --git a/backend/src/main.rs b/backend/src/main.rs index 08a4840..853f5ac 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -147,6 +147,8 @@ async fn main() { tokio::spawn(async move { let client = xmlrpc::RtorrentClient::new(&socket_path); let mut previous_torrents: Vec = Vec::new(); + let mut consecutive_errors = 0; + let mut backoff_duration = Duration::from_secs(1); loop { // 1. Fetch Torrents @@ -158,6 +160,21 @@ async fn main() { // Handle Torrents match torrents_result { Ok(new_torrents) => { + // Check if we recovered from an error state + if consecutive_errors > 0 { + tracing::info!( + "Reconnected to rTorrent after {} failures.", + consecutive_errors + ); + let _ = + event_bus_tx.send(AppEvent::Notification(shared::SystemNotification { + level: shared::NotificationLevel::Success, + message: "Reconnected to rTorrent".to_string(), + })); + consecutive_errors = 0; + backoff_duration = Duration::from_secs(1); + } + // Update latest state let _ = tx_clone.send(new_torrents.clone()); @@ -186,6 +203,23 @@ async fn main() { } Err(e) => { tracing::error!("Error fetching torrents in background: {}", e); + consecutive_errors += 1; + + // If this is the first error after success (or startup), notify clients + if consecutive_errors == 1 { + let _ = + event_bus_tx.send(AppEvent::Notification(shared::SystemNotification { + level: shared::NotificationLevel::Error, + message: format!("Lost connection to rTorrent: {}", e), + })); + } + + // Exponential backoff with a cap of 30 seconds + backoff_duration = std::cmp::min(backoff_duration * 2, Duration::from_secs(30)); + tracing::warn!( + "Backoff: Sleeping for {:?} due to rTorrent error.", + backoff_duration + ); } } @@ -199,7 +233,7 @@ async fn main() { } } - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::time::sleep(backoff_duration).await; } }); diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 06011d9..b3abc23 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,9 +1,10 @@ +use crate::components::layout::sidebar::Sidebar; +use crate::components::layout::statusbar::StatusBar; +use crate::components::layout::toolbar::Toolbar; +use crate::components::toast::ToastContainer; +use crate::components::torrent::table::TorrentTable; use leptos::*; use leptos_router::*; -use crate::components::layout::sidebar::Sidebar; -use crate::components::layout::toolbar::Toolbar; -use crate::components::layout::statusbar::StatusBar; -use crate::components::torrent::table::TorrentTable; #[component] pub fn App() -> impl IntoView { @@ -12,7 +13,7 @@ pub fn App() -> impl IntoView { view! {
- +
// Toolbar at the top @@ -25,7 +26,7 @@ pub fn App() -> impl IntoView { - + // Status Bar at the bottom
@@ -36,6 +37,8 @@ pub fn App() -> impl IntoView {
+ + } } diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs index a98eab0..f86eef4 100644 --- a/frontend/src/components/mod.rs +++ b/frontend/src/components/mod.rs @@ -1,4 +1,5 @@ -pub mod modal; pub mod context_menu; pub mod layout; +pub mod modal; +pub mod toast; pub mod torrent; diff --git a/frontend/src/components/toast.rs b/frontend/src/components/toast.rs new file mode 100644 index 0000000..ee8e906 --- /dev/null +++ b/frontend/src/components/toast.rs @@ -0,0 +1,35 @@ +use leptos::*; +use shared::NotificationLevel; + +#[component] +pub fn ToastContainer() -> impl IntoView { + let store = use_context::().expect("store not provided"); + let notifications = store.notifications; + + view! { +
+ {move || notifications.get().into_iter().map(|item| { + let alert_class = match item.notification.level { + NotificationLevel::Info => "alert-info", + NotificationLevel::Success => "alert-success", + NotificationLevel::Warning => "alert-warning", + NotificationLevel::Error => "alert-error", + }; + + let icon = match item.notification.level { + NotificationLevel::Info => view! { }, + NotificationLevel::Success => view! { }, + NotificationLevel::Warning => view! { }, + NotificationLevel::Error => view! { }, + }; + + view! { +
+ {icon} + {item.notification.message} +
+ } + }).collect::>()} +
+ } +} diff --git a/frontend/src/store.rs b/frontend/src/store.rs index 31621b1..a51abcf 100644 --- a/frontend/src/store.rs +++ b/frontend/src/store.rs @@ -1,7 +1,13 @@ use futures::StreamExt; use gloo_net::eventsource::futures::EventSource; use leptos::*; -use shared::{AppEvent, GlobalStats, Torrent}; +use shared::{AppEvent, GlobalStats, SystemNotification, Torrent}; + +#[derive(Clone, Debug, PartialEq)] +pub struct NotificationItem { + pub id: u64, + pub notification: SystemNotification, +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FilterStatus { @@ -36,6 +42,7 @@ pub struct TorrentStore { pub filter: RwSignal, pub search_query: RwSignal, pub global_stats: RwSignal, + pub notifications: RwSignal>, } pub fn provide_torrent_store() { @@ -43,12 +50,14 @@ pub fn provide_torrent_store() { let filter = create_rw_signal(FilterStatus::All); let search_query = create_rw_signal(String::new()); let global_stats = create_rw_signal(GlobalStats::default()); + let notifications = create_rw_signal(Vec::::new()); let store = TorrentStore { torrents, filter, search_query, global_stats, + notifications, }; provide_context(store); @@ -105,6 +114,25 @@ pub fn provide_torrent_store() { AppEvent::Stats(stats) => { global_stats.set(stats); } + AppEvent::Notification(n) => { + let id = js_sys::Date::now() as u64; + let item = NotificationItem { + id, + notification: n, + }; + notifications.update(|list| list.push(item)); + + // Auto-remove after 5 seconds + let notifications = notifications; + let _ = set_timeout( + move || { + notifications.update(|list| { + list.retain(|i| i.id != id); + }); + }, + std::time::Duration::from_secs(5), + ); + } } } } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 5fb50d3..522f093 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -36,6 +36,21 @@ pub enum AppEvent { }, Update(TorrentUpdate), Stats(GlobalStats), + Notification(SystemNotification), +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct SystemNotification { + pub level: NotificationLevel, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Eq)] +pub enum NotificationLevel { + Info, + Success, + Warning, + Error, } #[derive(Debug, Serialize, Deserialize, Clone, ToSchema, Default)]