feat(frontend): add SSE auto-reconnect with toast notifications

- Automatic reconnection with exponential backoff (1s to 30s max)
- Toast when connection is lost: 'Sunucu bağlantısı kesildi'
- Toast when reconnected: 'Sunucu bağlantısı yeniden kuruldu'
- Backend rTorrent notifications still work via SSE
This commit is contained in:
spinline
2026-02-05 21:46:20 +03:00
parent 3bc4b0f364
commit ce6c3e01aa
3 changed files with 122 additions and 65 deletions

13
Cargo.lock generated
View File

@@ -695,6 +695,7 @@ dependencies = [
"console_log",
"futures",
"gloo-net 0.5.0",
"gloo-timers",
"js-sys",
"leptos",
"leptos_router",
@@ -874,6 +875,18 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"

View File

@@ -16,6 +16,7 @@ log = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
gloo-net = "0.5"
gloo-timers = { version = "0.3", features = ["futures"] }
wasm-bindgen = "0.2"
uuid = { version = "1", features = ["v4", "js"] }
futures = "0.3"

View File

@@ -138,12 +138,33 @@ pub fn provide_torrent_store() {
};
provide_context(store);
// Initialize SSE connection
// Initialize SSE connection with auto-reconnect
create_effect(move |_| {
spawn_local(async move {
let mut es = EventSource::new("/api/events").unwrap();
let mut stream = es.subscribe("message").unwrap();
let mut backoff_ms: u32 = 1000; // Start with 1 second
let max_backoff_ms: u32 = 30000; // Max 30 seconds
let mut was_connected = false;
loop {
let es_result = EventSource::new("/api/events");
match es_result {
Ok(mut es) => {
match es.subscribe("message") {
Ok(mut stream) => {
// Connection established
if was_connected {
// We were previously connected and lost connection, now reconnected
show_toast_with_signal(
notifications,
NotificationLevel::Success,
"Sunucu bağlantısı yeniden kuruldu",
);
}
was_connected = true;
backoff_ms = 1000; // Reset backoff on successful connection
// Process messages
while let Some(Ok((_, msg))) = stream.next().await {
if let Some(data_str) = msg.data().as_string() {
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
@@ -192,27 +213,49 @@ pub fn provide_torrent_store() {
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));
show_toast_with_signal(notifications, n.level, n.message);
}
}
}
}
}
// 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),
// Stream ended - connection lost
if was_connected {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
}
}
Err(_) => {
// Failed to subscribe
if was_connected {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
}
}
}
}
Err(_) => {
// Failed to create EventSource
if was_connected {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
}
}
}
// Wait before reconnecting (exponential backoff)
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, max_backoff_ms);
}
});
});