Refactor upload and polling logic
This commit consolidates the upload and polling functionalities into a single `ServerSync` worker. The `oneliner_poller` and `uploader` modules have been removed, and their responsibilities are now handled by `server_sync`. The `ServerSync` worker manages both uploading pending recordings and polling the `/api/oneliners` endpoint. Uploads take precedence, and polling only occurs when the pending upload directory is empty. The worker now uses a unified HTTP client and a single `tokio::select!` loop for managing these tasks. Key changes include: - Removal of `oneliner_poller.rs` and `uploader.rs`. - Introduction of `server_sync.rs` and `upload.rs` in `doctate-client-core`. - `ServerSync` now handles upload events (`UploadEvent`) and provides a `WorkerSnapshot` for UI feedback. - Upload logic has been refactored to handle transient and terminal errors more robustly, including file deletion for terminal failures. - Polling logic is integrated into the `ServerSync` loop, utilizing the existing `SnapshotCache` and `CaseStore`. - The `App` component in `client-desktop` has been updated to use `ServerSync` instead of the separate poller and uploader. - Documentation comments have been updated to reflect the new structure.
This commit is contained in:
+158
-15
@@ -1,8 +1,14 @@
|
||||
//! UI-visible application state. Transitions are driven by user actions
|
||||
//! (buttons) and async events from the recorder + uploader channels.
|
||||
//! UI-visible application state and the footer status derivation.
|
||||
//!
|
||||
//! The state machine here only reflects **user intent** — "ready to
|
||||
//! record", "recording in progress", "config error, please fix".
|
||||
//! Background activity (upload queue, poll cycle, finalize flush) lives
|
||||
//! in a separate observable layer (`FooterStatus`) derived via a pure
|
||||
//! function so it can be table-tested without egui.
|
||||
|
||||
use std::time::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_client_core::server_sync::WorkerState;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -15,16 +21,7 @@ pub enum AppState {
|
||||
Recording {
|
||||
case_id: Uuid,
|
||||
recorded_at: String,
|
||||
started_at: Instant,
|
||||
},
|
||||
/// User pressed Stop; waiting for ffmpeg to finalize the MOOV atom.
|
||||
FinalizingRecording {
|
||||
case_id: Uuid,
|
||||
recorded_at: String,
|
||||
},
|
||||
/// File handed to uploader; waiting for server ACK.
|
||||
Uploading {
|
||||
case_id: Uuid,
|
||||
started_at: std::time::Instant,
|
||||
},
|
||||
/// Terminal error; user must dismiss or fix config.
|
||||
Error {
|
||||
@@ -38,9 +35,155 @@ impl AppState {
|
||||
Self::NotConfigured => "Konfiguration fehlt",
|
||||
Self::Idle => "Bereit",
|
||||
Self::Recording { .. } => "Aufnahme läuft",
|
||||
Self::FinalizingRecording { .. } => "Speichere…",
|
||||
Self::Uploading { .. } => "Upload läuft",
|
||||
Self::Error { .. } => "Fehler",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived observable rendered in the footer. Priority order encoded
|
||||
/// in `pick_footer_status` — active work beats connectivity indicators.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FooterStatus {
|
||||
/// ffmpeg is flushing the MOOV atom. Spinner.
|
||||
Finalizing,
|
||||
/// Worker is uploading. Spinner. `count` = pending-dir depth.
|
||||
Uploading { count: usize },
|
||||
/// Worker is polling `/api/oneliners`. Spinner.
|
||||
Synchronizing,
|
||||
/// Never got a successful request yet — cold start or bad config.
|
||||
NeverConnected,
|
||||
/// Last success older than threshold. Red text.
|
||||
Offline,
|
||||
/// Last success fresh. Green text. The default quiet state.
|
||||
Online,
|
||||
}
|
||||
|
||||
/// Decide which footer status to show. Pure function — no side effects,
|
||||
/// fully table-testable. Inputs chosen to match what `DoctateApp` can
|
||||
/// observe cheaply per egui frame.
|
||||
pub fn pick_footer_status(
|
||||
finalizing: bool,
|
||||
worker_state: WorkerState,
|
||||
queue_len: usize,
|
||||
last_success_age: Option<Duration>,
|
||||
offline_threshold: Duration,
|
||||
) -> FooterStatus {
|
||||
if finalizing {
|
||||
return FooterStatus::Finalizing;
|
||||
}
|
||||
if queue_len > 0 || worker_state == WorkerState::Uploading {
|
||||
return FooterStatus::Uploading {
|
||||
count: queue_len.max(1),
|
||||
};
|
||||
}
|
||||
if worker_state == WorkerState::Polling {
|
||||
return FooterStatus::Synchronizing;
|
||||
}
|
||||
match last_success_age {
|
||||
None => FooterStatus::NeverConnected,
|
||||
Some(age) if age > offline_threshold => FooterStatus::Offline,
|
||||
Some(_) => FooterStatus::Online,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const THRESHOLD: Duration = Duration::from_secs(30);
|
||||
|
||||
#[test]
|
||||
fn finalizing_wins_over_everything() {
|
||||
let s = pick_footer_status(
|
||||
true,
|
||||
WorkerState::Uploading,
|
||||
5,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Finalizing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploading_when_queue_non_empty() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Uploading,
|
||||
3,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Uploading { count: 3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploading_reports_at_least_one_even_if_scan_raced() {
|
||||
// Edge case: WorkerState says Uploading, but scan returned 0
|
||||
// (just finished a file and the next scan hasn't happened yet).
|
||||
// We still want to show "Uploading" to avoid a flicker back to
|
||||
// Online right before the next file starts.
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Uploading,
|
||||
0,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Uploading { count: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_when_polling_and_no_queue() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Polling,
|
||||
0,
|
||||
Some(Duration::from_millis(1)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Synchronizing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_connected_without_last_success() {
|
||||
let s = pick_footer_status(false, WorkerState::Idle, 0, None, THRESHOLD);
|
||||
assert_eq!(s, FooterStatus::NeverConnected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_when_last_success_is_stale() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(Duration::from_secs(120)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Offline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_when_last_success_is_fresh() {
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(Duration::from_secs(5)),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_threshold_is_inclusive_of_equal_age_as_online() {
|
||||
// Exactly at threshold: still online (strict ">" in logic).
|
||||
let s = pick_footer_status(
|
||||
false,
|
||||
WorkerState::Idle,
|
||||
0,
|
||||
Some(THRESHOLD),
|
||||
THRESHOLD,
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Online);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user