diff --git a/changelog/v0.1.0-beta4.txt b/changelog/v0.1.0-beta4.txt index 75d102f7..90c54c5e 100644 --- a/changelog/v0.1.0-beta4.txt +++ b/changelog/v0.1.0-beta4.txt @@ -22,6 +22,7 @@ SYNC - Play on: picking a device now works as on Android. This computer takes playback back from the device you control, picking another device moves that device's session there, and a device that stops answering stops showing as playing. - Linked devices: choose which profiles sync. A profile starts its own synced profile or joins one from your other devices, and Profiles under Linked devices adds one that only your other devices have. - A synced profile that isn't open keeps what arrives and catches up when you switch to it. Stopping a profile on every device keeps its data on each one. +- Linked devices has a Dashboard: where the group lives, how much of a Flow server's storage it uses, your devices, each synced profile's state and the latest sync errors. FIXES - Restarting Flow after an update no longer opens the link Flow was first started with a second time. diff --git a/src-tauri/src/commands/linked.rs b/src-tauri/src/commands/linked.rs index db17b743..32f9f336 100644 --- a/src-tauri/src/commands/linked.rs +++ b/src-tauri/src/commands/linked.rs @@ -5,6 +5,7 @@ use tauri::AppHandle; use crate::errors::ErrorResponse; use crate::sync2::actions::{self, LinkedDevice, LinkedStatus}; +use crate::sync2::dashboard::{self, Dashboard}; use crate::sync2::engine::EngineError; use crate::sync2::join_requests::JoinRequest; use crate::sync2::link_task::{self, LinkPhase}; @@ -242,3 +243,8 @@ pub async fn linked_choose_profile( .await .map_err(|e| err(&e)) } + +#[tauri::command] +pub async fn linked_dashboard(app: AppHandle) -> Result { + dashboard::dashboard(&app).await.map_err(|e| err(&e)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 151f68db..b247183a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,11 +63,11 @@ use commands::files::{write_backup_file, write_lyrics_file}; use commands::linked::{ linked_accept_join, linked_add_device, linked_answer, linked_ask_to_join, linked_cancel, linked_catch_up, linked_choose_profile, linked_create, linked_create_lan, linked_create_webdav, - linked_decline_join, linked_delete_profile, linked_devices, linked_enter_code, linked_forget, - linked_join_requests, linked_leave, linked_link_phase, linked_profiles, linked_recovery_saved, - linked_remove, linked_rename, linked_restore, linked_retry_keychain, linked_show_code, - linked_status, linked_stop_profile, linked_sync_added_profile, linked_sync_now, - linked_sync_profile, linked_update_login, + linked_dashboard, linked_decline_join, linked_delete_profile, linked_devices, + linked_enter_code, linked_forget, linked_join_requests, linked_leave, linked_link_phase, + linked_profiles, linked_recovery_saved, linked_remove, linked_rename, linked_restore, + linked_retry_keychain, linked_show_code, linked_status, linked_stop_profile, + linked_sync_added_profile, linked_sync_now, linked_sync_profile, linked_update_login, }; use commands::live::{live_consents, live_now, live_send, live_set_consent, live_status}; use commands::lyrics::{ @@ -826,6 +826,7 @@ pub fn run() { linked_stop_profile, linked_delete_profile, linked_choose_profile, + linked_dashboard, linked_link_phase, linked_add_device, linked_show_code, diff --git a/src-tauri/src/sync2/actions.rs b/src-tauri/src/sync2/actions.rs index aaba42dd..79586cce 100644 --- a/src-tauri/src/sync2/actions.rs +++ b/src-tauri/src/sync2/actions.rs @@ -500,6 +500,7 @@ async fn forget_with_keys(pool: &SqlitePool) -> Result<()> { /// Forgets the group on a database, keeping this device's keys. async fn forget_here(pool: &SqlitePool) -> Result<()> { forget_group(pool).await?; + super::error_log::clear(pool).await?; set_setting(pool, STOPPED, "").await?; set_login_refused(pool, false).await?; set_recovery_saved(pool, true).await diff --git a/src-tauri/src/sync2/dashboard.rs b/src-tauri/src/sync2/dashboard.rs new file mode 100644 index 00000000..5c1a2643 --- /dev/null +++ b/src-tauri/src/sync2/dashboard.rs @@ -0,0 +1,104 @@ +//! What the Sync page's dashboard shows about the group: where it lives, the store's usage, +//! recent failures, and each synced profile's state on this device. Devices come from +//! [`super::actions::devices`]. + +use serde::Serialize; +use sqlx::SqlitePool; +use tauri::AppHandle; + +use super::actions::local; +use super::engine::{EngineError, Result}; +use super::error_log::{LoggedError, recent}; +use super::kept::kept_count; +use super::profile_links::linked; +use super::server::{ServerInfo, ServerStore}; +use super::snapshot::own_snapshot_ms; +use super::state::{GroupRecord, StoreKind, load_group}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Storage { + pub used: u64, + pub quota: u64, + pub objects: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileState { + pub profile: String, + pub link: String, + /// Batches waiting for the profile to run. + pub kept: i64, + /// When this device last wrote a snapshot of the link. + pub own_snapshot_ms: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Dashboard { + pub kind: StoreKind, + /// The server or folder; `None` for a group on the LAN. + pub store_url: Option, + /// What a Flow server speaks, when it answered. + pub server: Option, + /// A Flow server's usage, when it answered. Other stores aren't listed to measure it. + pub storage: Option, + pub errors: Vec, + pub profiles: Vec, +} + +pub async fn dashboard(app: &AppHandle) -> Result { + let local = local(app).await?; + let record = load_group(&local.pool) + .await? + .ok_or(EngineError::NotLinked)?; + let (server, storage) = match record.kind { + StoreKind::Server => server_state(&record, &local.device).await, + StoreKind::WebDav | StoreKind::Lan => (None, None), + }; + Ok(Dashboard { + kind: record.kind, + store_url: (record.kind != StoreKind::Lan).then(|| record.store_url.clone()), + server, + storage, + errors: recent(&local.pool).await?, + profiles: profile_states(&local.pool, &hex::encode(local.device.did)).await?, + }) +} + +/// Best effort: the rest of the dashboard shows when the server can't be asked. +async fn server_state( + record: &GroupRecord, + device: &super::keys::MemberSecrets, +) -> (Option, Option) { + let Ok(store) = ServerStore::connect( + &record.store_url, + device.clone(), + record.instance.clone(), + Some(record.group_id.clone()), + ) + .await + else { + return (None, None); + }; + let storage = store.status().await.ok().map(|status| Storage { + used: status.used, + quota: status.quota, + objects: status.objects, + }); + (store.info().await.ok(), storage) +} + +async fn profile_states(pool: &SqlitePool, me: &str) -> Result> { + let mut states = Vec::new(); + for (profile, link) in linked(pool).await? { + states.push(ProfileState { + kept: kept_count(pool, &link).await?, + own_snapshot_ms: own_snapshot_ms(pool, me, &link).await?, + profile, + link, + }); + } + Ok(states) +} diff --git a/src-tauri/src/sync2/error_log.rs b/src-tauri/src/sync2/error_log.rs new file mode 100644 index 00000000..a10384e3 --- /dev/null +++ b/src-tauri/src/sync2/error_log.rs @@ -0,0 +1,44 @@ +//! Recent sync failures for the dashboard: the error kind and when, nothing else, newest first. +//! Kept in Default's settings with the rest of the group's state. + +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use super::engine::Result; +use crate::db::settings::{get_setting, set_setting}; + +const ERRORS_KEY: &str = "sync2_errors"; +const KEPT: usize = 20; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoggedError { + pub kind: String, + pub at_ms: i64, +} + +pub async fn recent(pool: &SqlitePool) -> Result> { + Ok(get_setting(pool, ERRORS_KEY) + .await? + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default()) +} + +/// Keeps `kind` as the newest failure, dropping the oldest beyond a few. +pub async fn note(pool: &SqlitePool, kind: &str, at_ms: i64) -> Result<()> { + let mut errors = recent(pool).await?; + errors.insert( + 0, + LoggedError { + kind: kind.to_owned(), + at_ms, + }, + ); + errors.truncate(KEPT); + let json = serde_json::to_string(&errors).map_err(crate::sync::error::SyncError::from)?; + Ok(set_setting(pool, ERRORS_KEY, &json).await?) +} + +pub async fn clear(pool: &SqlitePool) -> Result<()> { + Ok(set_setting(pool, ERRORS_KEY, "[]").await?) +} diff --git a/src-tauri/src/sync2/kept.rs b/src-tauri/src/sync2/kept.rs index 91e7f1f4..4d1505b9 100644 --- a/src-tauri/src/sync2/kept.rs +++ b/src-tauri/src/sync2/kept.rs @@ -78,13 +78,12 @@ pub async fn applied(pool: &SqlitePool, ids: &[i64]) -> Result<(), SyncError> { Ok(()) } -/// Whether batches of `link` wait to be applied. -pub async fn has_kept(pool: &SqlitePool, link: &str) -> Result { +/// How many batches of `link` wait to be applied. +pub async fn kept_count(pool: &SqlitePool, link: &str) -> Result { Ok( - sqlx::query_scalar::<_, i64>("SELECT 1 FROM sync2_kept WHERE link = ? LIMIT 1") + sqlx::query_scalar("SELECT COUNT(*) FROM sync2_kept WHERE link = ?") .bind(link) - .fetch_optional(pool) - .await? - .is_some(), + .fetch_one(pool) + .await?, ) } diff --git a/src-tauri/src/sync2/mod.rs b/src-tauri/src/sync2/mod.rs index ae50373d..0aadc678 100644 --- a/src-tauri/src/sync2/mod.rs +++ b/src-tauri/src/sync2/mod.rs @@ -9,12 +9,14 @@ pub mod blob; pub mod bootstrap; pub mod codes; pub mod curve; +pub mod dashboard; pub mod device; pub mod engine; pub mod entries; pub mod envelope; pub mod epoch; pub mod error; +pub mod error_log; pub mod gc; pub mod group_store; pub mod held; diff --git a/src-tauri/src/sync2/profile_links.rs b/src-tauri/src/sync2/profile_links.rs index 58cfa628..69c1fa9f 100644 --- a/src-tauri/src/sync2/profile_links.rs +++ b/src-tauri/src/sync2/profile_links.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use sqlx::SqlitePool; -use super::kept::has_kept; +use super::kept::kept_count; use super::state::links_to_read; use crate::sync::error::SyncError; @@ -41,7 +41,7 @@ impl Reading { /// Whether `link` still waits here on data other devices sent: kept batches, or its snapshot. pub async fn catching_up(pool: &SqlitePool, link: &str) -> Result { - Ok(has_kept(pool, link).await? || links_to_read(pool).await?.iter().any(|l| l == link)) + Ok(kept_count(pool, link).await? > 0 || links_to_read(pool).await?.iter().any(|l| l == link)) } /// Links `profile` to `link`. diff --git a/src-tauri/src/sync2/server.rs b/src-tauri/src/sync2/server.rs index edada65a..edfd7bfb 100644 --- a/src-tauri/src/sync2/server.rs +++ b/src-tauri/src/sync2/server.rs @@ -233,14 +233,44 @@ impl ServerStore { unreachable!("the loop returns on its second pass") } - /// When the server last heard from each device, by `did` (`GET /v1/status`). - pub async fn last_seen(&self) -> Result> { + /// The group's storage and when the server last heard from each device (`GET /v1/status`). + pub async fn status(&self) -> Result { let status = decode(self.authorized(Method::GET, "v1/status", None).await?).await?; let devices = status["devices"].as_object().ok_or(StoreError::Malformed)?; - Ok(devices - .iter() - .filter_map(|(did, device)| Some((did.clone(), device["seen"].as_i64()?))) - .collect()) + let number = |name: &str| status[name].as_u64().ok_or(StoreError::Malformed); + Ok(ServerStatus { + used: number("used")?, + quota: number("quota")?, + objects: number("objects")?, + seen: devices + .iter() + .filter_map(|(did, device)| Some((did.clone(), device["seen"].as_i64()?))) + .collect(), + }) + } + + /// When the server last heard from each device, by `did`. + pub async fn last_seen(&self) -> Result> { + Ok(self.status().await?.seen) + } + + /// What the server speaks (`GET /v1/info`, no token needed). + pub async fn info(&self) -> Result { + let response = self + .client + .get(self.url("v1/info")?) + .send() + .await + .map_err(StoreError::Network)?; + let info = decode(response).await?; + let protocols = info["protocol"].as_array().ok_or(StoreError::Malformed)?; + Ok(ServerInfo { + protocols: protocols + .iter() + .filter_map(|p| p.as_str().map(str::to_owned)) + .collect(), + live: info["live"].as_bool().unwrap_or(false), + }) } /// A request with the operator's admin token instead of a device token. @@ -465,6 +495,22 @@ async fn check(response: reqwest::Response) -> Result { }) } +/// The group's storage on a Flow server, and when it last heard from each device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerStatus { + pub used: u64, + pub quota: u64, + pub objects: u64, + pub seen: BTreeMap, +} + +/// The protocols a Flow server speaks, and whether it relays live messages. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ServerInfo { + pub protocols: Vec, + pub live: bool, +} + pub(super) async fn decode(response: reqwest::Response) -> Result { check(response) .await? diff --git a/src-tauri/src/sync2/service.rs b/src-tauri/src/sync2/service.rs index 6edf2961..245685f4 100644 --- a/src-tauri/src/sync2/service.rs +++ b/src-tauri/src/sync2/service.rs @@ -16,8 +16,9 @@ use super::actions::{ }; use super::batches::{IncomingBatch, apply_batches}; use super::engine::{EngineError, Result, Session, now_ms}; +use super::error_log; use super::group_store::GroupStore; -use super::kept::{applied as applied_kept, has_kept, keep, kept}; +use super::kept::{applied as applied_kept, keep, kept, kept_count}; use super::live_hub::forget_keys; use super::names::device_names; use super::profile_actions::name_linked; @@ -104,6 +105,11 @@ pub async fn sync_now(app: &AppHandle) -> Result> { at_ms: now_ms(), error: result.as_ref().err().map(|e| e.kind().to_owned()), }; + if let Some(kind) = &last.error + && let Err(failed) = error_log::note(&pool, kind, last.at_ms).await + { + tracing::warn!(target: "flow::sync2", kind = failed.kind(), "sync error not logged"); + } if let Ok(mut slot) = state.last.lock() { *slot = Some(last.clone()); } @@ -183,7 +189,7 @@ async fn exchange( let received = applied.received(session).await; if let Some(link) = &reading.active && snapshot_due(pool, link).await? - && !has_kept(pool, link).await? + && kept_count(pool, link).await? == 0 && !links_to_read(pool).await?.contains(link) { let exported = export_collections(data, &local.meta.app, &Collection::ALL).await?; diff --git a/src-tauri/src/sync2/snapshot.rs b/src-tauri/src/sync2/snapshot.rs index 7d8cbead..e46acf1a 100644 --- a/src-tauri/src/sync2/snapshot.rs +++ b/src-tauri/src/sync2/snapshot.rs @@ -11,7 +11,7 @@ use super::blob::seal_blob; use super::engine::{Result, Session, now_ms}; use super::envelope::ObjectType; use super::epoch::purpose_key; -use super::gc::{Logged, record_manifest}; +use super::gc::{Logged, manifests, record_manifest}; use super::kdf::sha256; use super::paths::blob_path; use super::payload::encode_payload; @@ -81,6 +81,25 @@ pub async fn snapshot_due(pool: &SqlitePool, link: &str) -> Result { .is_some_and(|(written, merged)| merged > MERGED_LIMIT || now_ms() - written >= MAX_AGE_MS)) } +/// When this device wrote the snapshot of `link` it still keeps, if it wrote one. +pub async fn own_snapshot_ms(pool: &SqlitePool, me: &str, link: &str) -> Result> { + let own = manifests(pool) + .await? + .iter() + .any(|logged| logged.did == me && logged.manifest.link.as_deref() == Some(link)); + if !own { + return Ok(None); + } + let written: Option = + sqlx::query_scalar("SELECT written_ms FROM sync2_snapshots WHERE link = ?") + .bind(link) + .fetch_optional(pool) + .await + .map_err(SyncError::from)?; + // Zero asks for a snapshot soon, after a removal; it says nothing about the last one. + Ok(written.filter(|ms| *ms > 0)) +} + /// After this device removed another, its next sync writes a snapshot under the new epoch. pub async fn snapshot_soon(pool: &SqlitePool) -> Result<()> { sqlx::query("UPDATE sync2_snapshots SET written_ms = 0") @@ -91,12 +110,15 @@ pub async fn snapshot_soon(pool: &SqlitePool) -> Result<()> { } async fn snapshot_written(pool: &SqlitePool, link: &str) -> Result<()> { - sqlx::query("UPDATE sync2_snapshots SET written_ms = ?, merged_bytes = 0 WHERE link = ?") - .bind(now_ms()) - .bind(link) - .execute(pool) - .await - .map_err(SyncError::from)?; + sqlx::query( + "INSERT INTO sync2_snapshots (link, written_ms, merged_bytes) VALUES (?, ?, 0) + ON CONFLICT (link) DO UPDATE SET written_ms = excluded.written_ms, merged_bytes = 0", + ) + .bind(link) + .bind(now_ms()) + .execute(pool) + .await + .map_err(SyncError::from)?; Ok(()) } diff --git a/src-tauri/tests/profiles/store_inventory.tsv b/src-tauri/tests/profiles/store_inventory.tsv index c34e4bd8..e7f858eb 100644 --- a/src-tauri/tests/profiles/store_inventory.tsv +++ b/src-tauri/tests/profiles/store_inventory.tsv @@ -85,6 +85,7 @@ setting sponsorblock_server PROFILE settings SponsorBlock preference setting sync_neuro_merged DEFAULT_ONLY sync FlowNeuro CRDT state setting sync_music_merged DEFAULT_ONLY sync MusicBrain CRDT state setting sync_neuro_baseline DEFAULT_ONLY sync FlowNeuro CRDT baseline +setting sync2_errors DEFAULT_ONLY sync Recent linked-devices sync error kinds and times, kept with the group in Default's database setting sync_music_baseline DEFAULT_ONLY sync MusicBrain CRDT baseline setting sync_last_backup DEFAULT_ONLY sync Pre-apply snapshot of the last sync setting sync_clock_hwm DEFAULT_ONLY sync Newest stamp applied; local edits are stamped after it diff --git a/src-tauri/tests/sync2_dashboard.rs b/src-tauri/tests/sync2_dashboard.rs new file mode 100644 index 00000000..cac87cac --- /dev/null +++ b/src-tauri/tests/sync2_dashboard.rs @@ -0,0 +1,44 @@ +//! What the Sync page's dashboard reads: the recent error log, and each synced profile's kept +//! batches and own snapshot. + +mod sync2_common; + +use flow_desktop_lib::sync2::engine::Session; +use flow_desktop_lib::sync2::error_log::{clear, note, recent}; +use flow_desktop_lib::sync2::kept::kept_count; +use flow_desktop_lib::sync2::snapshot::own_snapshot_ms; +use sync2_common::{LINK, device_pool, empty_pool, exported, group}; + +#[tokio::test] +async fn the_error_log_keeps_only_the_newest_kinds_and_times() { + let pool = empty_pool().await; + for at in 0..25 { + note(&pool, "linkedUnreachable", at).await.unwrap(); + } + note(&pool, "linkedRefused", 99).await.unwrap(); + let errors = recent(&pool).await.unwrap(); + assert_eq!(errors.len(), 20); + assert_eq!( + (errors[0].kind.as_str(), errors[0].at_ms), + ("linkedRefused", 99) + ); + assert_eq!(errors[19].at_ms, 6); + clear(&pool).await.unwrap(); + assert!(recent(&pool).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn a_profile_shows_its_own_snapshot_once_this_device_wrote_one() { + let g = group().await; + let pool = device_pool(&g).await; + let me = hex::encode(g.a.did); + assert_eq!(own_snapshot_ms(&pool, &me, LINK).await.unwrap(), None); + assert_eq!(kept_count(&pool, LINK).await.unwrap(), 0); + + let mut session = Session::open(&pool, &g.store, &g.a).await.unwrap(); + session + .write_snapshot(&pool, exported(&pool, "app-a").await) + .await + .unwrap(); + assert!(own_snapshot_ms(&pool, &me, LINK).await.unwrap().is_some()); +} diff --git a/src-tauri/tests/sync2_server.rs b/src-tauri/tests/sync2_server.rs index a52ee396..9f363af8 100644 --- a/src-tauri/tests/sync2_server.rs +++ b/src-tauri/tests/sync2_server.rs @@ -362,6 +362,33 @@ async fn the_server_says_when_it_last_heard_from_each_device() { assert_eq!(seen.get("bb"), Some(&2000)); } +#[tokio::test] +async fn the_server_reports_its_storage_and_what_it_speaks() { + let (server, _) = server(INSTANCE).await; + Mock::given(method("GET")) + .and(path("/v1/status")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "used": 2048, "quota": 4096, "objects": 7, "devices": {} + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/info")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "api": ["v1"], "protocol": ["FLOW-SYNC/2"], "claimed": true, "live": true, "limits": {} + }))) + .mount(&server) + .await; + let store = ServerStore::connect(&format!("{}/", server.uri()), member(), None, None) + .await + .unwrap(); + let status = store.status().await.unwrap(); + assert_eq!((status.used, status.quota, status.objects), (2048, 4096, 7)); + let info = store.info().await.unwrap(); + assert_eq!(info.protocols, ["FLOW-SYNC/2"]); + assert!(info.live); +} + #[tokio::test] async fn a_server_reset_to_empty_says_the_group_is_gone() { use flow_desktop_lib::sync2::engine::EngineError; diff --git a/src/components/sync/linked/Dashboard.tsx b/src/components/sync/linked/Dashboard.tsx new file mode 100644 index 00000000..78bb3621 --- /dev/null +++ b/src/components/sync/linked/Dashboard.tsx @@ -0,0 +1,153 @@ +import { useEffect } from "react"; + +import type { Dashboard as DashboardData, SyncedProfileState } from "../../../lib/api/linked"; +import { onSynced } from "../../../lib/api/linked"; +import { isTauriEnv } from "../../../lib/api/env"; +import { formatBytes } from "../../../lib/downloadOptions"; +import { getString } from "../../../lib/i18n/index"; +import { profileDisplayName } from "../../../lib/profiles/display"; +import { formatYouTubeRelativeTime } from "../../../lib/publishedDate"; +import { syncErrorMessage } from "../../../lib/syncErrors"; +import { useDashboardStore } from "../../../store/useDashboardStore"; +import { useLinkedDevicesStore } from "../../../store/useLinkedDevicesStore"; +import { useProfilesStore } from "../../../store/useProfilesStore"; +import { ProfileAvatar } from "../../ui/ProfileAvatar"; +import { Spinner } from "../SyncParts"; +import { Card } from "./Card"; +import { DeviceList } from "./DeviceList"; +import { STORE_TITLES } from "./LinkedOverview"; + +const bytes = (value: number) => formatBytes(value) ?? "0 MB"; + +function StoreCard({ dashboard }: { dashboard: DashboardData }) { + return ( + +

{getString(STORE_TITLES[dashboard.kind])}

+ {dashboard.storeUrl &&

{dashboard.storeUrl}

} + {dashboard.server && ( + <> +

+ {getString("linked_dashboard_protocols", dashboard.server.protocols.join(", "))} +

+

+ {getString(dashboard.server.live ? "linked_dashboard_live_on" : "linked_dashboard_live_off")} +

+ + )} +
+ ); +} + +function StorageCard({ storage }: { storage: DashboardData["storage"] }) { + const share = storage && storage.quota > 0 ? Math.min(1, storage.used / storage.quota) : 0; + return ( + + {storage ? ( + <> +

+ {getString("linked_dashboard_used", bytes(storage.used), bytes(storage.quota))} +

+
+
+
+

{getString("linked_dashboard_objects", storage.objects)}

+ + ) : ( +

{getString("linked_dashboard_storage_unknown")}

+ )} + + ); +} + +function ProfileStateRow({ state }: { state: SyncedProfileState }) { + const view = useProfilesStore((s) => s.view); + const profile = view?.profiles.find((p) => p.id === state.profile); + if (!profile) return null; + const name = profileDisplayName(profile); + return ( +
+ +
+
{name}
+
+ {state.kept > 0 ? getString("linked_dashboard_kept", state.kept) : getString("linked_dashboard_up_to_date")} +
+
+ {state.ownSnapshotMs + ? getString("linked_dashboard_snapshot", formatYouTubeRelativeTime(state.ownSnapshotMs)) + : getString("linked_dashboard_no_snapshot")} +
+
+
+ ); +} + +function ErrorsCard({ errors }: { errors: DashboardData["errors"] }) { + return ( + + {errors.length === 0 ? ( +

{getString("linked_dashboard_no_errors")}

+ ) : ( +
    + {errors.map((error) => ( +
  • + {syncErrorMessage(error.kind, null)} + {formatYouTubeRelativeTime(error.atMs)} +
  • + ))} +
+ )} +
+ ); +} + +/** The group at a glance: where it lives, its storage, devices, synced profiles and recent errors. */ +export function Dashboard() { + const { dashboard, error, load } = useDashboardStore(); + const devices = useLinkedDevicesStore((state) => state.devices); + + useEffect(() => { + let stop: (() => void) | undefined; + let cancelled = false; + void load(); + void isTauriEnv().then(async (tauri) => { + if (!tauri) return; + const unlisten = await onSynced(() => void load()); + if (cancelled) unlisten(); + else stop = unlisten; + }); + return () => { + cancelled = true; + stop?.(); + }; + }, [load]); + + if (!dashboard) { + return error ? ( +

{getString("linked_dashboard_error", error)}

+ ) : ( + + ); + } + return ( +
+ + + + {devices ? : } + + + + {dashboard.profiles.map((state) => ( + + ))} + +
+ ); +} diff --git a/src/components/sync/linked/LinkedDevicesSection.tsx b/src/components/sync/linked/LinkedDevicesSection.tsx index fe5a5f52..c86e25f4 100644 --- a/src/components/sync/linked/LinkedDevicesSection.tsx +++ b/src/components/sync/linked/LinkedDevicesSection.tsx @@ -1,9 +1,12 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Link2 } from "lucide-react"; import { getString } from "../../../lib/i18n/index"; import { useLinkedDevicesStore } from "../../../store/useLinkedDevicesStore"; +import type { LinkedStatus } from "../../../lib/api/linked"; +import { SegmentedControl } from "../../ui/SegmentedControl"; import { IconChip, Spinner } from "../SyncParts"; +import { Dashboard } from "./Dashboard"; import { KeychainBlocked } from "./KeychainBlocked"; import { LinkPanel } from "./LinkPanel"; import { LinkedOverview } from "./LinkedOverview"; @@ -21,7 +24,30 @@ function Body() { if (status.keychainBlocked) return ; if (!status.kind) return ; if (status.loginRefused) return ; - return ; + return ; +} + +type Tab = "devices" | "dashboard"; + +/** A device in a group: its devices and profiles, or the dashboard. */ +function LinkedTabs({ status }: { status: LinkedStatus }) { + const [tab, setTab] = useState("devices"); + return ( +
+
+ + label={getString("linked_tabs_label")} + value={tab} + onChange={setTab} + options={[ + { value: "devices", label: getString("linked_tab_devices") }, + { value: "dashboard", label: getString("linked_tab_dashboard") }, + ]} + /> +
+ {tab === "devices" ? : } +
+ ); } /** FLOW-SYNC/2: devices that stay in sync through a Flow server. */ diff --git a/src/components/sync/linked/LinkedOverview.tsx b/src/components/sync/linked/LinkedOverview.tsx index ac290fb4..ec2b7220 100644 --- a/src/components/sync/linked/LinkedOverview.tsx +++ b/src/components/sync/linked/LinkedOverview.tsx @@ -22,7 +22,7 @@ function lastSyncText(status: LinkedStatus, syncing: boolean): string { return getString("linked_last_sync", formatYouTubeRelativeTime(last.atMs)); } -const STORE_TITLES = { server: "linked_server", webdav: "linked_webdav", lan: "linked_on_lan" } as const; +export const STORE_TITLES = { server: "linked_server", webdav: "linked_webdav", lan: "linked_on_lan" } as const; /** For a device in a group: where it lives, the last sync, and the devices it syncs with. */ export function LinkedOverview({ status }: { status: LinkedStatus }) { diff --git a/src/lib/api/linked.ts b/src/lib/api/linked.ts index b3f7e4f5..ad2e63c8 100644 --- a/src/lib/api/linked.ts +++ b/src/lib/api/linked.ts @@ -79,7 +79,29 @@ export interface LinkedProfiles { chooseForDefault: boolean; } +/** The Sync page's dashboard (`src-tauri/src/sync2/dashboard.rs`). */ +export interface Dashboard { + kind: "server" | "webdav" | "lan"; + storeUrl?: string | null; + /** What a Flow server speaks, when it answered. */ + server?: { protocols: string[]; live: boolean } | null; + /** A Flow server's usage in bytes and objects, when it answered. */ + storage?: { used: number; quota: number; objects: number } | null; + /** Recent sync failures, newest first: the error kind and when. */ + errors: { kind: string; atMs: number }[]; + profiles: SyncedProfileState[]; +} + +/** A profile that syncs here: batches waiting for it to run, and this device's last snapshot. */ +export interface SyncedProfileState { + profile: string; + link: string; + kept: number; + ownSnapshotMs?: number | null; +} + export const getLinkedStatus = () => invokeBackend("linked_status"); +export const getDashboard = () => invokeBackend("linked_dashboard"); export const getLinkedProfiles = () => invokeBackend("linked_profiles"); /** Syncs `profile` under a link of its own, or joining `link`. */ export const syncProfile = (profile: string, link: string | null) => diff --git a/src/lib/i18n/index.test.ts b/src/lib/i18n/index.test.ts new file mode 100644 index 00000000..e201cafd --- /dev/null +++ b/src/lib/i18n/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { getString, type StringKey } from "./index"; +import strings from "./strings.json"; + +describe("getString", () => { + it("fills several values in order", () => { + expect(getString("linked_dashboard_used", "1 MB", "2 MB")).toBe("1 MB of 2 MB used"); + }); + + it("only meets placeholders it can fill", () => { + const unfilled = Object.keys(strings).filter((key) => + /%(?:\d+\$)?[a-z]/.test(getString(key as StringKey, "x", "x", "x")), + ); + expect(unfilled).toEqual([]); + }); +}); diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index 31c4edd3..8ab1c9c4 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -1950,6 +1950,25 @@ "linked_profile_start_new": "Start a new one", "linked_error_last_profile": "Keep at least one profile syncing, or leave the group.", "linked_error_profile_taken": "That profile already syncs on this computer.", + "linked_tab_devices": "Linked devices", + "linked_tab_dashboard": "Dashboard", + "linked_tabs_label": "Linked devices views", + "linked_dashboard_store": "Store", + "linked_dashboard_protocols": "Speaks %s", + "linked_dashboard_live_on": "Live playback relay is on", + "linked_dashboard_live_off": "Live playback relay is off", + "linked_dashboard_storage": "Storage", + "linked_dashboard_used": "%s of %s used", + "linked_dashboard_objects": "%s objects", + "linked_dashboard_storage_unknown": "This store doesn't report its usage.", + "linked_dashboard_profiles": "Synced profiles", + "linked_dashboard_kept": "%s updates wait until you switch to it", + "linked_dashboard_up_to_date": "Up to date", + "linked_dashboard_snapshot": "Snapshot from this computer %s", + "linked_dashboard_no_snapshot": "No snapshot from this computer yet", + "linked_dashboard_errors": "Recent errors", + "linked_dashboard_no_errors": "No sync errors.", + "linked_dashboard_error": "Couldn't load the dashboard: %s", "linked_error_profile_not_running": "Switch to this profile before syncing it.", "linked_error_lan_unreachable": "The other device couldn't be reached on this network. Connect both devices to the same Wi-Fi or LAN and try again.", "player_resolving_stream": "Resolving stream", diff --git a/src/store/useDashboardStore.test.ts b/src/store/useDashboardStore.test.ts new file mode 100644 index 00000000..fa9fb9b2 --- /dev/null +++ b/src/store/useDashboardStore.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getDashboard } = vi.hoisted(() => ({ getDashboard: vi.fn() })); +vi.mock("../lib/api/linked", () => ({ getDashboard })); + +import { getString } from "../lib/i18n/index"; +import { useDashboardStore } from "./useDashboardStore"; + +const dashboard = { + kind: "server" as const, + storeUrl: "https://sync.example", + server: { protocols: ["FLOW-SYNC/2"], live: true }, + storage: { used: 10, quota: 100, objects: 3 }, + errors: [{ kind: "linkedUnreachable", atMs: 5 }], + profiles: [{ profile: "home", link: "link-home", kept: 2, ownSnapshotMs: null }], +}; + +beforeEach(() => { + getDashboard.mockReset(); + useDashboardStore.setState({ dashboard: null, error: null, loading: false }); +}); + +describe("useDashboardStore", () => { + it("loads what the backend reports", async () => { + getDashboard.mockResolvedValue(dashboard); + await useDashboardStore.getState().load(); + expect(useDashboardStore.getState()).toMatchObject({ dashboard, error: null, loading: false }); + }); + + it("keeps the last dashboard and shows why a load failed", async () => { + getDashboard.mockResolvedValueOnce(dashboard); + await useDashboardStore.getState().load(); + getDashboard.mockRejectedValueOnce({ kind: "linkedNotLinked", message: "not linked" }); + await useDashboardStore.getState().load(); + const state = useDashboardStore.getState(); + expect(state.dashboard).toEqual(dashboard); + expect(state.error).toBe(getString("linked_error_not_linked")); + }); +}); diff --git a/src/store/useDashboardStore.ts b/src/store/useDashboardStore.ts new file mode 100644 index 00000000..d7f98956 --- /dev/null +++ b/src/store/useDashboardStore.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; + +import { getDashboard, type Dashboard } from "../lib/api/linked"; +import { normalizeBackendError } from "../lib/api/errors"; +import { syncErrorMessage } from "../lib/syncErrors"; + +interface DashboardState { + dashboard: Dashboard | null; + error: string | null; + loading: boolean; + load: () => Promise; +} + +/** The Sync page's dashboard, loaded when its tab opens and after each sync. */ +export const useDashboardStore = create((set) => ({ + dashboard: null, + error: null, + loading: false, + load: async () => { + set({ loading: true, error: null }); + try { + set({ dashboard: await getDashboard() }); + } catch (reason) { + const { kind, message } = normalizeBackendError(reason); + set({ error: syncErrorMessage(kind, message) }); + } finally { + set({ loading: false }); + } + }, +}));