use gloo_timers::callback::Interval; use yew::prelude::*; use crate::api; use crate::components::status_badge::StatusBadge; use crate::types::Status; /// Format a UTC datetime string as a relative time like "in 2h 15m". fn format_next_run(datetime_str: &str) -> String { let now_ms = js_sys::Date::new_0().get_time(); let parsed = js_sys::Date::parse(datetime_str); // NaN means parse failed if parsed.is_nan() { return datetime_str.to_string(); } let delta_ms = parsed - now_ms; if delta_ms <= 0.0 { return "soon".to_string(); } let total_mins = (delta_ms / 60_000.0) as u64; let hours = total_mins / 60; let mins = total_mins % 60; if hours > 0 { format!("in {hours}h {mins}m") } else { format!("in {mins}m") } } #[function_component(DashboardPage)] pub fn dashboard() -> Html { let status = use_state(|| None::); let error = use_state(|| None::); let message = use_state(|| None::); // Fetch status function let fetch_status = { let status = status.clone(); let error = error.clone(); Callback::from(move |_: ()| { let status = status.clone(); let error = error.clone(); wasm_bindgen_futures::spawn_local(async move { match api::get_status().await { Ok(s) => { error.set(None); status.set(Some(s)); } Err(e) => error.set(Some(e.0)), } }); }) }; // Initial fetch { let fetch = fetch_status.clone(); use_effect_with((), move |_| { fetch.emit(()); }); } // Auto-refresh every 5 seconds { let fetch = fetch_status.clone(); use_effect_with((), move |_| { let interval = Interval::new(1_000, move || { fetch.emit(()); }); // Keep the interval alive for the component's lifetime move || drop(interval) }); } // Action handlers let on_sync = { let message = message.clone(); let error = error.clone(); let fetch = fetch_status.clone(); Callback::from(move |_: MouseEvent| { let message = message.clone(); let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { match api::sync_downloads().await { Ok(s) => { message.set(Some(format!( "Synced: {} enqueued, {} skipped", s.enqueued, s.skipped ))); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let on_process = { let message = message.clone(); let error = error.clone(); let fetch = fetch_status.clone(); Callback::from(move |_: MouseEvent| { let message = message.clone(); let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { match api::process_downloads().await { Ok(t) => { message.set(Some(format!( "Downloads started (task: {})", &t.task_id[..8] ))); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let on_index = { let message = message.clone(); let error = error.clone(); let fetch = fetch_status.clone(); Callback::from(move |_: MouseEvent| { let message = message.clone(); let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { match api::trigger_index().await { Ok(t) => { message.set(Some(format!("Indexing started ({})", &t.task_id[..8]))); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let on_tag = { let message = message.clone(); let error = error.clone(); let fetch = fetch_status.clone(); Callback::from(move |_: MouseEvent| { let message = message.clone(); let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { match api::trigger_tag().await { Ok(t) => { message.set(Some(format!("Tagging started ({})", &t.task_id[..8]))); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let on_organize = { let message = message.clone(); let error = error.clone(); let fetch = fetch_status.clone(); Callback::from(move |_: MouseEvent| { let message = message.clone(); let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { match api::trigger_organize().await { Ok(t) => { message.set(Some(format!("Organizing started ({})", &t.task_id[..8]))); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let on_monitor_check = { let message = message.clone(); let error = error.clone(); let fetch = fetch_status.clone(); Callback::from(move |_: MouseEvent| { let message = message.clone(); let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { match api::trigger_monitor_check().await { Ok(t) => { message.set(Some(format!( "Monitor check started (task: {})", &t.task_id[..8] ))); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let on_pipeline = { let message = message.clone(); let error = error.clone(); let fetch = fetch_status.clone(); Callback::from(move |_: MouseEvent| { let message = message.clone(); let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { match api::trigger_pipeline().await { Ok(p) => { message.set(Some(format!( "Pipeline started — {} tasks queued", p.task_ids.len() ))); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; if let Some(ref err) = *error { return html! {
{ format!("Error: {err}") }
}; } let Some(ref s) = *status else { return html! {

{ "Loading..." }

}; }; let pipeline_active = s .tasks .iter() .any(|t| t.status == "Pending" || t.status == "Running"); // Pre-compute scheduled task rows let scheduled_rows = { let mut rows = Vec::new(); if let Some(ref sched) = s.scheduled { if let Some(ref next) = sched.next_pipeline { rows.push(html! { { "Auto Pipeline" } { "Scheduled" } { format!("Next run: {}", format_next_run(next)) } }); } if let Some(ref next) = sched.next_monitor { rows.push(html! { { "Monitor Check" } { "Scheduled" } { format!("Next run: {}", format_next_run(next)) } }); } } rows }; let has_scheduled = !scheduled_rows.is_empty(); html! {
if let Some(ref msg) = *message {

{ msg }

} // Stats grid
{ s.library.total_items }
{ "Total Items" }
{ s.library.wanted }
{ "Wanted" }
{ s.library.downloaded }
{ "Downloaded" }
{ s.library.owned }
{ "Owned" }
// Pipeline

{ "Pipeline" }

{ "Sync \u{2192} Download \u{2192} Index \u{2192} Tag \u{2192} Organize \u{2192} Enrich" }

// Individual Actions

{ "Individual Actions" }

// Background Tasks (always show if there are tasks or scheduled items) if !s.tasks.is_empty() || has_scheduled {

{ "Background Tasks" }

{ for scheduled_rows.into_iter() } { for s.tasks.iter().map(|t| { let progress_html = if let Some(ref p) = t.progress { if p.total > 0 { let pct = (p.current as f64 / p.total as f64 * 100.0) as u32; html! {
{ &p.message }{ format!(" ({}/{})", p.current, p.total) }
} } else { html! { { &p.message } } } } else { html! {} }; html! { } })}
{ "Type" }{ "Status" }{ "Progress" }{ "Result" }
{ &t.task_type } { progress_html } { t.result.as_deref().unwrap_or("") }
} // Download Queue Items if !s.queue.items.is_empty() {

{ format!("Download Queue ({} pending, {} downloading, {} failed)", s.queue.pending, s.queue.downloading, s.queue.failed) }

{ for s.queue.items.iter().map(|item| html! { })}
{ "Query" }{ "Status" }{ "Error" }
{ &item.query } { item.error_message.as_deref().unwrap_or("") }
} else if s.queue.pending > 0 || s.queue.downloading > 0 {

{ "Download Queue" }

{ format!("{} pending, {} downloading", s.queue.pending, s.queue.downloading) }

} // Tagging Queue if let Some(ref tagging) = s.tagging { if tagging.needs_tagging > 0 {

{ format!("Tagging Queue ({} tracks need metadata)", tagging.needs_tagging) }

if !tagging.items.is_empty() { { for tagging.items.iter().map(|t| html! { })}
{ "Title" }{ "Artist" }{ "Album" }{ "MBID" }
{ t.title.as_deref().unwrap_or("Unknown") } { t.artist.as_deref().unwrap_or("") } { t.album.as_deref().unwrap_or("—") } if t.album.is_some() { { "needs album link" } } else { { "needs metadata" } }
}
} }
} }