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::); let pipeline_was_active = use_state(|| false); let pipeline_complete = use_state(|| false); // 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"); // Skip callbacks for scheduler let on_skip_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::skip_scheduled_pipeline().await { Ok(_) => { message.set(Some("Next pipeline run skipped".into())); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let on_skip_monitor = { 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::skip_scheduled_monitor().await { Ok(_) => { message.set(Some("Next monitor check skipped".into())); fetch.emit(()); } Err(e) => error.set(Some(e.0)), } }); }) }; let pipeline_progress_html = if let Some(ref wq) = s.work_queue { let active = wq.download.pending + wq.download.running + wq.index.pending + wq.index.running + wq.tag.pending + wq.tag.running + wq.organize.pending + wq.organize.running + wq.enrich.pending + wq.enrich.running; // Track pipeline active→inactive transition if active > 0 { if !*pipeline_was_active { pipeline_was_active.set(true); pipeline_complete.set(false); } } else if *pipeline_was_active { pipeline_was_active.set(false); pipeline_complete.set(true); } if active > 0 { html! {

{ "Pipeline Progress" }

{ for [("Download", &wq.download), ("Index", &wq.index), ("Tag", &wq.tag), ("Organize", &wq.organize), ("Enrich", &wq.enrich)].iter().map(|(name, c)| { html! { } })}
{ "Step" }{ "Pending" }{ "Running" }{ "Done" }{ "Failed" }
{ name } { c.pending } { if c.running > 0 { html! { { c.running } } } else { html! { { "0" } } } } { c.completed } { if c.failed > 0 { html! { { c.failed } } } else { html! { { "0" } } } }
} } else if *pipeline_complete { html! {

{ "Pipeline run complete!" }

} } else { html! {} } } else if *pipeline_complete { html! {

{ "Pipeline run complete!" }

} } else { html! {} }; let scheduled_jobs_html = { let next_pipeline = s .scheduled .as_ref() .and_then(|sc| sc.next_pipeline.as_ref()); let next_monitor = s.scheduled.as_ref().and_then(|sc| sc.next_monitor.as_ref()); let pipeline_next_str = next_pipeline .map(|n| format_next_run(n)) .unwrap_or_default(); let monitor_next_str = next_monitor.map(|n| format_next_run(n)).unwrap_or_default(); let pipeline_last = s .scheduler .as_ref() .and_then(|sc| sc.get("pipeline")) .and_then(|j| j.get("last_result")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let monitor_last = s .scheduler .as_ref() .and_then(|sc| sc.get("monitor")) .and_then(|j| j.get("last_result")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); html! {

{ "Scheduled Jobs" }

{ "Job" }{ "Status" }{ "Next Run" }{ "Last Result" }
{ "Auto Pipeline" } { if next_pipeline.is_some() { html! { { "Scheduled" } } } else { html! { { "Idle" } } }} { pipeline_next_str } { pipeline_last } { if next_pipeline.is_some() { html! { } } else { html! {} }}
{ "Monitor Check" } { if next_monitor.is_some() { html! { { "Scheduled" } } } else { html! { { "Idle" } } }} { monitor_next_str } { monitor_last } { if next_monitor.is_some() { html! { } } else { html! {} }}
} }; 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" }

// Work Queue Progress { pipeline_progress_html } // Scheduled Jobs (always visible) { scheduled_jobs_html } // Background Tasks (one-off tasks like MB import) if !s.tasks.is_empty() {

{ "Background Tasks" }

{ 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().take(10).map(|item| html! { })}
{ "Query" }{ "Status" }{ "Error" }
{ &item.query } { item.error_message.as_deref().unwrap_or("") }
if s.queue.items.len() > 10 {

{ format!("and {} more...", s.queue.items.len() - 10) }

}
} 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().take(10).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" } }
if tagging.items.len() > 10 {

{ format!("and {} more...", tagging.items.len() - 10) }

} }
} }
} }