dashboard: overall sync status; fix unselected spinner visibility

- New SYNC STATUS card on the dashboard: every device with its last sync
  result (✓ ok / ✗ error / — never) + ages, and an ok/error/never/last summary
- Sidebar: the sync spinner is now bright cyan on unselected (dark) rows and
  dark on the selected (accent) row, so it's visible in both states
This commit is contained in:
Sebastian
2026-08-22 10:23:41 +02:00
parent 4568080453
commit 1ac5d5dd5d
2 changed files with 80 additions and 28 deletions
+7 -4
View File
@@ -189,10 +189,13 @@ fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) {
// status icon // status icon
let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false); let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
if is_this { if is_this {
spans.push(Span::styled( // dark on the accent selection, bright cyan on the dark unselected row
app.spinner().to_string(), let spinner_style = if selected {
Style::default().fg(theme::BG), Style::default().fg(theme::BG).add_modifier(Modifier::BOLD)
)); } else {
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD)
};
spans.push(Span::styled(app.spinner().to_string(), spinner_style));
} else if device.mounted { } else if device.mounted {
spans.push(Span::styled("●", Style::default().fg(if selected { theme::BG } else { theme::GREEN }))); spans.push(Span::styled("●", Style::default().fg(if selected { theme::BG } else { theme::GREEN })));
} else { } else {
+73 -24
View File
@@ -56,10 +56,10 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
draw_device_card(f, left, app, device); draw_device_card(f, left, app, device);
draw_settings_card(f, right, device); draw_settings_card(f, right, device);
let [lib, pipe] = let [lib, status] =
Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).areas(row2); Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).areas(row2);
draw_library_card(f, lib, app); draw_library_card(f, lib, app);
draw_pipeline_card(f, pipe, device); draw_sync_status_card(f, status, app);
draw_chain(f, chain, app, device); draw_chain(f, chain, app, device);
} }
@@ -247,34 +247,83 @@ fn draw_library_card(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
} }
fn draw_pipeline_card(f: &mut Frame, area: Rect, device: &crate::config::Device) { fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) {
let block = card("SYNC PIPELINE"); let block = card("SYNC STATUS");
let inner = block.inner(area); let inner = block.inner(area);
f.render_widget(block, area); f.render_widget(block, area);
let steps: Vec<(&str, &str)> = vec![ let syncing = app.engine.state.lock().map(|s| s.running).unwrap_or(false);
("1", "mirror StorageBox → local"), let sync_device = if syncing {
("2", "NFC-aware diff"), app.engine.state.lock().unwrap().device.clone()
("3", "rsync copy"), } else {
("4", if device.firmware == Firmware::Mlove { None
"eject + fatsort" };
} else { let now = chrono::Local::now();
"eject"
}), let mut ok = 0usize;
]; let mut err = 0usize;
let mut never = 0usize;
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
for (n, label) in steps { for device in &app.config.devices {
lines.push(Line::from(vec![ let is_syncing = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
Span::raw(" "), let status_span: Span = if is_syncing {
Span::styled( Span::styled(
format!(" {n} "), format!("{} syncing…", app.spinner()),
Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD), Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
), )
Span::raw(" "), } else if let Some(dt) = device.last_sync {
Span::styled(label, Style::default().fg(theme::FG)), let ago = relative_ago(dt);
])); if device.last_status == "ok" {
lines.push(Line::raw("")); ok += 1;
Span::styled(
format!("✓ ok · {ago}"),
Style::default().fg(theme::GREEN),
)
} else {
err += 1;
Span::styled(
format!("✗ error · {ago}"),
Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
)
}
} else {
never += 1;
Span::styled("— never", Style::default().fg(theme::DIM))
};
let name = truncate(&device.name, inner.width.saturating_sub(26).max(4) as usize);
let mut spans: Vec<Span> = vec![Span::raw(" "), Span::styled(name, Style::default().fg(theme::FG))];
let used: usize = spans.iter().map(|s| s.width()).sum();
let left = inner.width as usize - 2 - status_span.width();
if used < left {
spans.push(Span::raw(" ".repeat(left - used)));
}
spans.push(status_span);
lines.push(Line::from(spans));
} }
// summary footer
lines.push(Line::raw(""));
let summary = format!(
" {ok} ok · {err} error · {never} never · last {last}",
last = app
.config
.devices
.iter()
.filter_map(|d| d.last_sync)
.min_by_key(|dt| {
let s = now.signed_duration_since(*dt).num_seconds();
s.max(0)
})
.map(|dt| relative_ago(dt))
.unwrap_or_else(|| "—".to_string()),
);
lines.push(Line::from(Span::styled(
truncate(&summary, inner.width as usize),
Style::default().fg(theme::DIM),
)));
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
} }