feat: add global stats broadcasting and statusbar integration

This commit is contained in:
spinline
2026-02-03 23:12:51 +03:00
parent 6a5ce421b9
commit 563ffad3ab
5 changed files with 105 additions and 12 deletions

View File

@@ -148,12 +148,19 @@ async fn main() {
let mut previous_torrents: Vec<Torrent> = Vec::new(); let mut previous_torrents: Vec<Torrent> = Vec::new();
loop { loop {
match sse::fetch_torrents(&client).await { // 1. Fetch Torrents
let torrents_result = sse::fetch_torrents(&client).await;
// 2. Fetch Global Stats
let stats_result = sse::fetch_global_stats(&client).await;
// Handle Torrents
match torrents_result {
Ok(new_torrents) => { Ok(new_torrents) => {
// 1. Update latest state (always) // Update latest state
let _ = tx_clone.send(new_torrents.clone()); let _ = tx_clone.send(new_torrents.clone());
// 2. Calculate Diff and Broadcasting // Calculate Diff and Broadcasting
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
@@ -180,6 +187,17 @@ async fn main() {
tracing::error!("Error fetching torrents in background: {}", e); tracing::error!("Error fetching torrents in background: {}", e);
} }
} }
// Handle Stats
match stats_result {
Ok(stats) => {
let _ = event_bus_tx.send(AppEvent::Stats(stats));
}
Err(e) => {
tracing::warn!("Error fetching global stats: {}", e);
}
}
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
}); });

View File

@@ -3,7 +3,7 @@ use crate::AppState;
use axum::extract::State; use axum::extract::State;
use axum::response::sse::{Event, Sse}; use axum::response::sse::{Event, Sse};
use futures::stream::{self, Stream}; use futures::stream::{self, Stream};
use shared::{AppEvent, Torrent, TorrentStatus}; use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
use std::convert::Infallible; use std::convert::Infallible;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
@@ -52,7 +52,6 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
let is_hashing = parse_long(row.get(11)); let is_hashing = parse_long(row.get(11));
let label_raw = parse_string(row.get(12)); let label_raw = parse_string(row.get(12));
// Treat empty label as None
let label = if label_raw.is_empty() { let label = if label_raw.is_empty() {
None None
} else { } else {
@@ -115,6 +114,31 @@ pub async fn fetch_torrents(client: &RtorrentClient) -> Result<Vec<Torrent>, Xml
Ok(torrents) Ok(torrents)
} }
pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats, XmlRpcError> {
// Parallel calls would be better but let's keep it simple sequential for now.
// NOTE: This adds 4 roundtrips per second. If this is too slow, we should use multicall via system.multicall (if supported)
// or just accept the overhead. Unix socket overhead is very low.
// We ignore errors on individual stats to not break the whole loop, using defaults.
// But connection errors should propagate.
let down_rate_str = client.call("throttle.global_down.rate", &[]).await?;
let up_rate_str = client.call("throttle.global_up.rate", &[]).await?;
let down_limit_str = client.call("throttle.global_down.max_rate", &[]).await?;
let up_limit_str = client.call("throttle.global_up.max_rate", &[]).await?;
// Optionally get free space. "directory.default" then "d.free_space_path"?? No "get_directory_free_space"
// Let's skip free space for high frequency updates.
Ok(GlobalStats {
down_rate: down_rate_str.parse().unwrap_or(0),
up_rate: up_rate_str.parse().unwrap_or(0),
down_limit: down_limit_str.parse().ok(),
up_limit: up_limit_str.parse().ok(),
free_space: None,
})
}
pub async fn sse_handler( pub async fn sse_handler(
State(state): State<AppState>, State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {

View File

@@ -1,22 +1,54 @@
use leptos::*; use leptos::*;
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 {
return format!("{} B", bytes);
}
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!(
"{:.1} {}",
(bytes as f64) / 1024_f64.powi(i as i32),
UNITS[i]
)
}
fn format_speed(bytes_per_sec: i64) -> String {
if bytes_per_sec == 0 {
return "0 B/s".to_string();
}
format!("{}/s", format_bytes(bytes_per_sec))
}
#[component] #[component]
pub fn StatusBar() -> impl IntoView { pub fn StatusBar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let stats = store.global_stats;
let (theme_open, set_theme_open) = create_signal(false); let (theme_open, set_theme_open) = create_signal(false);
view! { view! {
<div class="h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70"> <div class="h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70">
<div class="flex items-center gap-2 cursor-pointer hover:text-primary"> <div class="flex items-center gap-2 cursor-pointer hover:text-primary" title="Global Download Speed">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span class="font-mono">"0 KB/s"</span> <span class="font-mono">{move || format_speed(stats.get().down_rate)}</span>
<Show when=move || stats.get().down_limit.unwrap_or(0) > 0 fallback=|| ()>
<span class="text-[10px] opacity-60">
{move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))}
</span>
</Show>
</div> </div>
<div class="flex items-center gap-2 cursor-pointer hover:text-primary"> <div class="flex items-center gap-2 cursor-pointer hover:text-primary" title="Global Upload Speed">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 11.25l-3-3m0 0l-3 3m3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 11.25l-3-3m0 0l-3 3m3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span class="font-mono">"0 KB/s"</span> <span class="font-mono">{move || format_speed(stats.get().up_rate)}</span>
<Show when=move || stats.get().up_limit.unwrap_or(0) > 0 fallback=|| ()>
<span class="text-[10px] opacity-60">
{move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))}
</span>
</Show>
</div> </div>
<div class="ml-auto flex items-center gap-4"> <div class="ml-auto flex items-center gap-4">

View File

@@ -1,7 +1,7 @@
use futures::StreamExt; use futures::StreamExt;
use gloo_net::eventsource::futures::EventSource; use gloo_net::eventsource::futures::EventSource;
use leptos::*; use leptos::*;
use shared::{AppEvent, Torrent}; use shared::{AppEvent, GlobalStats, Torrent};
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FilterStatus { pub enum FilterStatus {
@@ -33,17 +33,20 @@ pub struct TorrentStore {
pub torrents: RwSignal<Vec<Torrent>>, pub torrents: RwSignal<Vec<Torrent>>,
pub filter: RwSignal<FilterStatus>, pub filter: RwSignal<FilterStatus>,
pub search_query: RwSignal<String>, pub search_query: RwSignal<String>,
pub global_stats: RwSignal<GlobalStats>,
} }
pub fn provide_torrent_store() { pub fn provide_torrent_store() {
let torrents = create_rw_signal(Vec::<Torrent>::new()); let torrents = create_rw_signal(Vec::<Torrent>::new());
let filter = create_rw_signal(FilterStatus::All); let filter = create_rw_signal(FilterStatus::All);
let search_query = create_rw_signal(String::new()); let search_query = create_rw_signal(String::new());
let global_stats = create_rw_signal(GlobalStats::default());
let store = TorrentStore { let store = TorrentStore {
torrents, torrents,
filter, filter,
search_query, search_query,
global_stats,
}; };
provide_context(store); provide_context(store);
@@ -91,9 +94,15 @@ pub fn provide_torrent_store() {
if let Some(error_message) = update.error_message { if let Some(error_message) = update.error_message {
t.error_message = error_message; t.error_message = error_message;
} }
if let Some(label) = update.label {
t.label = label;
}
} }
}); });
} }
AppEvent::Stats(stats) => {
global_stats.set(stats);
}
} }
} }
} }

View File

@@ -14,7 +14,7 @@ pub struct Torrent {
pub status: TorrentStatus, pub status: TorrentStatus,
pub error_message: String, pub error_message: String,
pub added_date: i64, pub added_date: i64,
pub label: Option<String>, // Added Label support pub label: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
@@ -35,6 +35,16 @@ pub enum AppEvent {
timestamp: u64, timestamp: u64,
}, },
Update(TorrentUpdate), Update(TorrentUpdate),
Stats(GlobalStats),
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, Default)]
pub struct GlobalStats {
pub down_rate: i64,
pub up_rate: i64,
pub down_limit: Option<i64>,
pub up_limit: Option<i64>,
pub free_space: Option<i64>,
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
@@ -49,7 +59,7 @@ pub struct TorrentUpdate {
pub eta: Option<i64>, pub eta: Option<i64>,
pub status: Option<TorrentStatus>, pub status: Option<TorrentStatus>,
pub error_message: Option<String>, pub error_message: Option<String>,
pub label: Option<String>, // Added Label update support pub label: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]