598 lines
24 KiB
Rust
598 lines
24 KiB
Rust
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::<Status>);
|
|
let error = use_state(|| None::<String>);
|
|
let message = use_state(|| None::<String>);
|
|
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! { <div class="error">{ format!("Error: {err}") }</div> };
|
|
}
|
|
|
|
let Some(ref s) = *status else {
|
|
return html! { <p class="loading">{ "Loading..." }</p> };
|
|
};
|
|
|
|
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! {
|
|
<div class="card">
|
|
<h3>{ "Pipeline Progress" }</h3>
|
|
<table>
|
|
<thead>
|
|
<tr><th>{ "Step" }</th><th>{ "Pending" }</th><th>{ "Running" }</th><th>{ "Done" }</th><th>{ "Failed" }</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{ for [("Download", &wq.download), ("Index", &wq.index), ("Tag", &wq.tag), ("Organize", &wq.organize), ("Enrich", &wq.enrich)].iter().map(|(name, c)| {
|
|
html! {
|
|
<tr>
|
|
<td>{ name }</td>
|
|
<td>{ c.pending }</td>
|
|
<td>{ if c.running > 0 { html! { <span class="badge badge-accent">{ c.running }</span> } } else { html! { { "0" } } } }</td>
|
|
<td>{ c.completed }</td>
|
|
<td>{ if c.failed > 0 { html! { <span class="badge badge-danger">{ c.failed }</span> } } else { html! { { "0" } } } }</td>
|
|
</tr>
|
|
}
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
} else if *pipeline_complete {
|
|
html! {
|
|
<div class="card" style="border-color: var(--success);">
|
|
<p style="color: var(--success);">{ "Pipeline run complete!" }</p>
|
|
</div>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
} else if *pipeline_complete {
|
|
html! {
|
|
<div class="card" style="border-color: var(--success);">
|
|
<p style="color: var(--success);">{ "Pipeline run complete!" }</p>
|
|
</div>
|
|
}
|
|
} 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! {
|
|
<div class="card">
|
|
<h3>{ "Scheduled Jobs" }</h3>
|
|
<table>
|
|
<thead>
|
|
<tr><th>{ "Job" }</th><th>{ "Status" }</th><th>{ "Next Run" }</th><th>{ "Last Result" }</th><th></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>{ "Auto Pipeline" }</td>
|
|
<td>{ if next_pipeline.is_some() {
|
|
html! { <span class="badge badge-pending">{ "Scheduled" }</span> }
|
|
} else {
|
|
html! { <span class="text-muted text-sm">{ "Idle" }</span> }
|
|
}}</td>
|
|
<td class="text-sm text-muted">{ pipeline_next_str }</td>
|
|
<td class="text-sm text-muted">{ pipeline_last }</td>
|
|
<td>{ if next_pipeline.is_some() {
|
|
html! { <button class="btn btn-sm btn-danger" onclick={on_skip_pipeline}>{ "Skip" }</button> }
|
|
} else {
|
|
html! {}
|
|
}}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{ "Monitor Check" }</td>
|
|
<td>{ if next_monitor.is_some() {
|
|
html! { <span class="badge badge-pending">{ "Scheduled" }</span> }
|
|
} else {
|
|
html! { <span class="text-muted text-sm">{ "Idle" }</span> }
|
|
}}</td>
|
|
<td class="text-sm text-muted">{ monitor_next_str }</td>
|
|
<td class="text-sm text-muted">{ monitor_last }</td>
|
|
<td>{ if next_monitor.is_some() {
|
|
html! { <button class="btn btn-sm btn-danger" onclick={on_skip_monitor}>{ "Skip" }</button> }
|
|
} else {
|
|
html! {}
|
|
}}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
};
|
|
|
|
html! {
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>{ "Dashboard" }</h2>
|
|
</div>
|
|
|
|
if let Some(ref msg) = *message {
|
|
<div class="card" style="border-color: var(--success);">
|
|
<p>{ msg }</p>
|
|
</div>
|
|
}
|
|
|
|
// Stats grid
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="value">{ s.library.total_items }</div>
|
|
<div class="label">{ "Total Items" }</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="value">{ s.library.wanted }</div>
|
|
<div class="label">{ "Wanted" }</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="value">{ s.library.downloaded }</div>
|
|
<div class="label">{ "Downloaded" }</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="value">{ s.library.owned }</div>
|
|
<div class="label">{ "Owned" }</div>
|
|
</div>
|
|
</div>
|
|
|
|
// Pipeline
|
|
<div class="card">
|
|
<div class="flex items-center justify-between">
|
|
<h3>{ "Pipeline" }</h3>
|
|
<button class="btn btn-primary" onclick={on_pipeline} disabled={pipeline_active}>
|
|
{ "Set Sail" }
|
|
</button>
|
|
</div>
|
|
<p class="text-sm text-muted mt-1">
|
|
{ "Sync \u{2192} Download \u{2192} Index \u{2192} Tag \u{2192} Organize \u{2192} Enrich" }
|
|
</p>
|
|
</div>
|
|
|
|
// Individual Actions
|
|
<div class="card">
|
|
<h3>{ "Individual Actions" }</h3>
|
|
<div class="actions mt-1">
|
|
<button class="btn btn-secondary" onclick={on_sync}>{ "Sync Watchlist" }</button>
|
|
<button class="btn btn-secondary" onclick={on_process}>{ "Process Downloads" }</button>
|
|
<button class="btn btn-secondary" onclick={on_index}>{ "Re-index" }</button>
|
|
<button class="btn btn-secondary" onclick={on_tag}>{ "Auto-tag" }</button>
|
|
<button class="btn btn-secondary" onclick={on_organize}>{ "Organize" }</button>
|
|
<button class="btn btn-secondary" onclick={on_monitor_check}>{ "Check Monitored Artists" }</button>
|
|
</div>
|
|
</div>
|
|
|
|
// 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() {
|
|
<div class="card">
|
|
<h3>{ "Background Tasks" }</h3>
|
|
<table class="tasks-table">
|
|
<thead>
|
|
<tr><th>{ "Type" }</th><th>{ "Status" }</th><th>{ "Progress" }</th><th>{ "Result" }</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{ 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! {
|
|
<div>
|
|
<div class="text-sm">{ &p.message }{ format!(" ({}/{})", p.current, p.total) }</div>
|
|
<div style="background: var(--bg-card); border-radius: 4px; height: 8px; margin-top: 4px;">
|
|
<div style={format!("background: var(--accent); border-radius: 4px; height: 100%; width: {pct}%;")}></div>
|
|
</div>
|
|
</div>
|
|
}
|
|
} else {
|
|
html! { <span class="text-sm text-muted">{ &p.message }</span> }
|
|
}
|
|
} else {
|
|
html! {}
|
|
};
|
|
html! {
|
|
<tr>
|
|
<td>{ &t.task_type }</td>
|
|
<td><StatusBadge status={t.status.clone()} /></td>
|
|
<td>{ progress_html }</td>
|
|
<td class="text-sm text-muted">{ t.result.as_deref().unwrap_or("") }</td>
|
|
</tr>
|
|
}
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
|
|
// Download Queue Items
|
|
if !s.queue.items.is_empty() {
|
|
<div class="card">
|
|
<h3>{ format!("Download Queue ({} pending, {} downloading, {} failed)",
|
|
s.queue.pending, s.queue.downloading, s.queue.failed) }</h3>
|
|
<table>
|
|
<thead>
|
|
<tr><th>{ "Query" }</th><th>{ "Status" }</th><th>{ "Error" }</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{ for s.queue.items.iter().take(10).map(|item| html! {
|
|
<tr>
|
|
<td>{ &item.query }</td>
|
|
<td><StatusBadge status={item.status.clone()} /></td>
|
|
<td class="text-sm text-muted">{ item.error_message.as_deref().unwrap_or("") }</td>
|
|
</tr>
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
if s.queue.items.len() > 10 {
|
|
<p class="text-sm text-muted mt-1">
|
|
{ format!("and {} more...", s.queue.items.len() - 10) }
|
|
</p>
|
|
}
|
|
</div>
|
|
} else if s.queue.pending > 0 || s.queue.downloading > 0 {
|
|
<div class="card">
|
|
<h3>{ "Download Queue" }</h3>
|
|
<p>{ format!("{} pending, {} downloading", s.queue.pending, s.queue.downloading) }</p>
|
|
</div>
|
|
}
|
|
|
|
// Tagging Queue
|
|
if let Some(ref tagging) = s.tagging {
|
|
if tagging.needs_tagging > 0 {
|
|
<div class="card">
|
|
<h3>{ format!("Tagging Queue ({} tracks need metadata)", tagging.needs_tagging) }</h3>
|
|
if !tagging.items.is_empty() {
|
|
<table>
|
|
<thead>
|
|
<tr><th>{ "Title" }</th><th>{ "Artist" }</th><th>{ "Album" }</th><th>{ "MBID" }</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{ for tagging.items.iter().take(10).map(|t| html! {
|
|
<tr>
|
|
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
|
<td>{ t.artist.as_deref().unwrap_or("") }</td>
|
|
<td class="text-muted">{ t.album.as_deref().unwrap_or("—") }</td>
|
|
<td class="text-sm text-muted">
|
|
if t.album.is_some() {
|
|
{ "needs album link" }
|
|
} else {
|
|
{ "needs metadata" }
|
|
}
|
|
</td>
|
|
</tr>
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
if tagging.items.len() > 10 {
|
|
<p class="text-sm text-muted mt-1">
|
|
{ format!("and {} more...", tagging.items.len() - 10) }
|
|
</p>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
}
|