feat: add global stats broadcasting and statusbar integration
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>>> {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user