From bfc4cc639bba6f9d383c66cf1317695a3ad03d74 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 16 Jan 2026 06:40:53 -0800 Subject: [PATCH 1/4] feat(volume): introduce debug volume detection example and enhance fingerprinting methods This commit adds a new example for debugging volume detection, showcasing how to detect and display various volume properties, including auto-track eligibility and primary volume identification. Additionally, it refactors the VolumeFingerprint struct to provide stable fingerprinting methods for primary, external, and network volumes, improving the reliability of volume identification across different scenarios. These changes enhance the overall volume management capabilities and provide a clearer debugging interface for developers. --- core/examples/debug_volumes.rs | 74 ++++++++++ core/examples/fingerprint_test.rs | 113 +++++++-------- core/src/domain/volume.rs | 38 ++++- ...01_fix_search_index_include_directories.rs | 12 +- core/src/ops/volumes/add_cloud/action.rs | 8 +- core/src/ops/volumes/list/query.rs | 16 +- core/src/ops/volumes/speed_test/action.rs | 22 +++ core/src/volume/fs/apfs.rs | 13 +- core/src/volume/manager.rs | 72 ++++++++- core/src/volume/platform/linux.rs | 53 ++++++- core/src/volume/platform/macos.rs | 43 ++++-- core/src/volume/platform/windows.rs | 51 ++++++- core/src/volume/speed.rs | 7 +- core/src/volume/utils.rs | 137 +++++++++++++++++- packages/interface/src/ShellLayout.tsx | 23 +-- .../Inspector/variants/FileInspector.tsx | 4 +- .../JobManager/JobManagerPopover.tsx | 4 +- .../JobManager/JobsScreen/index.tsx | 4 +- .../JobManager/hooks/JobsContext.tsx | 36 +++++ .../components/SpacesSidebar/VolumesGroup.tsx | 110 ++++++++------ .../hooks/useVolumeContextMenu.ts | 109 ++++++++++++++ .../src/components/SpacesSidebar/index.tsx | 11 +- .../src/routes/overview/DevicePanel.tsx | 7 +- .../src/routes/overview/VolumeBar.tsx | 86 +++++------ 24 files changed, 813 insertions(+), 240 deletions(-) create mode 100644 core/examples/debug_volumes.rs create mode 100644 packages/interface/src/components/JobManager/hooks/JobsContext.tsx create mode 100644 packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts diff --git a/core/examples/debug_volumes.rs b/core/examples/debug_volumes.rs new file mode 100644 index 000000000..f00ce0f20 --- /dev/null +++ b/core/examples/debug_volumes.rs @@ -0,0 +1,74 @@ +//! Debug volume detection +//! +//! Run with: cargo run --example debug_volumes + +use sd_core::volume::{ + detection::detect_volumes, + types::{VolumeDetectionConfig, VolumeType}, +}; +use uuid::Uuid; + +#[tokio::main] +async fn main() { + // Enable debug logging + tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .with_target(true) + .init(); + + println!("\n=== Volume Detection Debug ===\n"); + + let device_id = Uuid::new_v4(); + let config = VolumeDetectionConfig { + include_system: true, + include_virtual: false, + run_speed_test: false, + refresh_interval_secs: 0, + }; + + match detect_volumes(device_id, &config).await { + Ok(volumes) => { + println!("Detected {} volumes:\n", volumes.len()); + + for vol in &volumes { + println!("Volume: {}", vol.name); + println!(" Display name: {}", vol.display_name.as_ref().unwrap_or(&"None".to_string())); + println!(" Mount point: {}", vol.mount_point.display()); + println!(" Type: {:?}", vol.volume_type); + println!(" Filesystem: {}", vol.file_system); + println!(" Fingerprint: {} ({})", vol.fingerprint.short_id(), vol.fingerprint.0); + println!(" Is user visible: {}", vol.is_user_visible); + println!(" Auto-track eligible: {}", vol.auto_track_eligible); + println!(" Is tracked: {}", vol.is_tracked); + println!(); + } + + // Show specifically which volumes are auto-track eligible + let auto_track: Vec<_> = volumes + .iter() + .filter(|v| v.auto_track_eligible) + .collect(); + + println!("=== Auto-Track Eligible Volumes ({}) ===", auto_track.len()); + for vol in auto_track { + println!(" - {} ({})", vol.display_name.as_ref().unwrap_or(&vol.name), vol.mount_point.display()); + } + + // Show Primary volumes specifically + let primary: Vec<_> = volumes + .iter() + .filter(|v| matches!(v.volume_type, VolumeType::Primary)) + .collect(); + + println!("\n=== Primary Volumes ({}) ===", primary.len()); + for vol in primary { + println!(" - {} at {}", vol.name, vol.mount_point.display()); + println!(" Auto-track eligible: {}", vol.auto_track_eligible); + println!(" Is user visible: {}", vol.is_user_visible); + } + } + Err(e) => { + eprintln!("Error detecting volumes: {}", e); + } + } +} diff --git a/core/examples/fingerprint_test.rs b/core/examples/fingerprint_test.rs index 4669ba0c0..a2fb4d66f 100644 --- a/core/examples/fingerprint_test.rs +++ b/core/examples/fingerprint_test.rs @@ -3,86 +3,79 @@ //! Run with: cargo run --example fingerprint_test use sd_core::domain::volume::VolumeFingerprint; +use std::path::PathBuf; +use uuid::Uuid; fn main() { println!("\n=== Volume Fingerprint Stability Tests ===\n"); - // Test 1: Deterministic - println!("Test 1: Same inputs → Same fingerprint"); - let uuid_pair = "CONTAINER-UUID:VOLUME-UUID"; - let capacity = 1_000_000_000_000u64; // 1TB + let device_id = Uuid::new_v4(); - let fp1 = VolumeFingerprint::new(uuid_pair, capacity, "APFS"); - let fp2 = VolumeFingerprint::new(uuid_pair, capacity, "APFS"); + // Test 1: Primary volume stability + println!("Test 1: Primary volume - Same inputs → Same fingerprint"); + let mount_point = PathBuf::from("/System/Volumes/Data"); + + let fp1 = VolumeFingerprint::from_primary_volume(&mount_point, device_id); + let fp2 = VolumeFingerprint::from_primary_volume(&mount_point, device_id); println!(" First run: {}", fp1.short_id()); println!(" Second run: {}", fp2.short_id()); println!(" Match: {}\n", fp1 == fp2); - // Test 2: Total capacity vs consumed capacity - println!("Test 2: TOTAL capacity (stable) vs CONSUMED (changes)"); - let container_total = 1_000_000_000_000u64; // Physical drive: 1TB (never changes) - let consumed_today = 50_000_000_000u64; // Used space: 50GB today - let consumed_tomorrow = 100_000_000_000u64; // Used space: 100GB tomorrow + // Test 2: External volume with dotfile UUID + println!("Test 2: External volume - Dotfile UUID provides stability"); + let spacedrive_id = Uuid::new_v4(); + let fp_ext1 = VolumeFingerprint::from_external_volume(spacedrive_id, device_id); + let fp_ext2 = VolumeFingerprint::from_external_volume(spacedrive_id, device_id); - let fp_with_total = VolumeFingerprint::new(uuid_pair, container_total, "APFS"); - let fp_with_consumed_50 = VolumeFingerprint::new(uuid_pair, consumed_today, "APFS"); - let fp_with_consumed_100 = VolumeFingerprint::new(uuid_pair, consumed_tomorrow, "APFS"); + println!(" With same dotfile UUID: {} == {}", fp_ext1.short_id(), fp_ext2.short_id()); + println!(" Match: {}\n", fp_ext1 == fp_ext2); - println!( - " Using total (1TB): {}", - fp_with_total.short_id() - ); - println!( - " Using consumed (50GB today): {}", - fp_with_consumed_50.short_id() - ); - println!( - " Using consumed (100GB tmrw): {}", - fp_with_consumed_100.short_id() - ); - println!(" Total stays same: {}", fp_with_total == fp_with_total); - println!( - " Consumed changes: {} (BAD!)\n", - fp_with_consumed_50 != fp_with_consumed_100 - ); + // Test 3: Network volume stability + println!("Test 3: Network volume - Backend ID provides stability"); + let backend_id = "s3"; + let bucket_name = "my-bucket"; - // Test 3: Disk IDs vs UUIDs - println!("Test 3: disk3 → disk4 on reboot (unstable) vs UUID (stable)"); + let fp_net1 = VolumeFingerprint::from_network_volume(backend_id, bucket_name); + let fp_net2 = VolumeFingerprint::from_network_volume(backend_id, bucket_name); - // UUID-based (stable) - let uuid_based = "ABCD-1234:VOL-5678"; - let fp_uuid_run1 = VolumeFingerprint::new(uuid_based, capacity, "APFS"); - let fp_uuid_run2 = VolumeFingerprint::new(uuid_based, capacity, "APFS"); + println!(" First run: {}", fp_net1.short_id()); + println!(" Second run: {}", fp_net2.short_id()); + println!(" Match: {}\n", fp_net1 == fp_net2); - // Disk ID-based (changes on reboot) - let disk_id_before = "disk3:disk3s5"; // Before reboot - let disk_id_after = "disk4:disk4s5"; // After reboot (same physical volume!) + // Test 4: Primary volume - Mount point changes break fingerprint + println!("Test 4: Primary volume - Different mount points = Different fingerprints"); + let mount1 = PathBuf::from("/Volumes/MyDrive"); + let mount2 = PathBuf::from("/Volumes/MyDrive1"); // Remounted at different path - let fp_disk3 = VolumeFingerprint::new(disk_id_before, capacity, "APFS"); - let fp_disk4 = VolumeFingerprint::new(disk_id_after, capacity, "APFS"); + let fp_mount1 = VolumeFingerprint::from_primary_volume(&mount1, device_id); + let fp_mount2 = VolumeFingerprint::from_primary_volume(&mount2, device_id); - println!(" UUID-based before reboot: {}", fp_uuid_run1.short_id()); - println!(" UUID-based after reboot: {}", fp_uuid_run2.short_id()); - println!(" UUID stable: {}\n", fp_uuid_run1 == fp_uuid_run2); + println!(" Mount at /Volumes/MyDrive: {}", fp_mount1.short_id()); + println!(" Mount at /Volumes/MyDrive1: {}", fp_mount2.short_id()); + println!(" Different: {} (expected for primary volumes)\n", fp_mount1 != fp_mount2); - println!(" disk3 before reboot: {}", fp_disk3.short_id()); - println!(" disk4 after reboot: {}", fp_disk4.short_id()); - println!( - " Disk ID changes: {} (creates duplicates!)\n", - fp_disk3 != fp_disk4 - ); + // Test 5: External volume - Same dotfile UUID, different mount points + println!("Test 5: External volume - Dotfile UUID stable across remounts"); + let ext_uuid = Uuid::new_v4(); + let fp_at_mount1 = VolumeFingerprint::from_external_volume(ext_uuid, device_id); + let fp_at_mount2 = VolumeFingerprint::from_external_volume(ext_uuid, device_id); + + println!(" Mounted at /Volumes/USB: {}", fp_at_mount1.short_id()); + println!(" Mounted at /Volumes/USB1: {}", fp_at_mount2.short_id()); + println!(" Match: {} (dotfile UUID is stable!)\n", fp_at_mount1 == fp_at_mount2); // Summary println!("=== Summary ==="); - println!("GOOD: Use container.uuid:volume.uuid + container.total_capacity"); - println!("BAD: Use container_id:disk_id (changes on reboot)"); - println!("BAD: Use capacity_consumed (changes with file operations)"); + println!("Primary volumes: Use mount_point + device_id"); + println!(" - Stable for system volumes with fixed mount points"); + println!(" - Examples: /System/Volumes/Data, C:\\, /"); println!(); - println!("Current implementation:"); - println!(" VolumeFingerprint::new("); - println!(" &format!(\"{{}}:{{}}\", container.uuid, volume.uuid),"); - println!(" container.total_capacity, // ← Stable!"); - println!(" \"APFS\""); - println!(" )"); + println!("External volumes: Use dotfile UUID + device_id"); + println!(" - Stable across remounts to different paths"); + println!(" - Fallback to mount_point + device_id if read-only"); + println!(); + println!("Network volumes: Use backend_id + mount_uri"); + println!(" - Stable based on cloud service and identifier"); + println!(" - Examples: S3 bucket ARN, WebDAV URL"); } diff --git a/core/src/domain/volume.rs b/core/src/domain/volume.rs index 6ac755a71..98033c8ec 100644 --- a/core/src/domain/volume.rs +++ b/core/src/domain/volume.rs @@ -12,24 +12,46 @@ use std::path::PathBuf; use std::sync::Arc; use uuid::Uuid; +/// Dotfile name for persistent volume identification +pub const SPACEDRIVE_VOLUME_ID_FILE: &str = ".spacedrive-volume-id"; + /// Unique fingerprint for a storage volume #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, Type)] pub struct VolumeFingerprint(pub String); impl VolumeFingerprint { - /// Create a new volume fingerprint from volume properties - /// Uses stable identifiers: UUIDs + total physical capacity + filesystem - pub fn new(name: &str, total_bytes: u64, file_system: &str) -> Self { + /// Create fingerprint for primary/system volume using stable mount point + device + /// This is used for system volumes where the mount point is stable and never changes + pub fn from_primary_volume(mount_point: &std::path::Path, device_id: Uuid) -> Self { let mut hasher = blake3::Hasher::new(); - hasher.update(b"uuid_based:"); - hasher.update(name.as_bytes()); - hasher.update(&total_bytes.to_be_bytes()); - hasher.update(file_system.as_bytes()); - hasher.update(&(name.len() as u64).to_be_bytes()); + hasher.update(b"stable_primary_v1:"); + hasher.update(mount_point.to_string_lossy().as_bytes()); + hasher.update(device_id.as_bytes()); + Self(hasher.finalize().to_hex().to_string()) + } + + /// Create fingerprint for external volume using dotfile UUID + /// This is used for removable drives with a .spacedrive-volume-id file + pub fn from_external_volume(spacedrive_id: Uuid, device_id: Uuid) -> Self { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"stable_external_v1:"); + hasher.update(spacedrive_id.as_bytes()); + hasher.update(device_id.as_bytes()); + Self(hasher.finalize().to_hex().to_string()) + } + + /// Create fingerprint for network/cloud volume using backend identifier + /// This is used for network shares, cloud storage, etc. + pub fn from_network_volume(backend_id: &str, mount_uri: &str) -> Self { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"stable_network_v1:"); + hasher.update(backend_id.as_bytes()); + hasher.update(mount_uri.as_bytes()); Self(hasher.finalize().to_hex().to_string()) } /// Create a fingerprint from a Spacedrive identifier UUID + /// Deprecated: Use from_external_volume instead for proper device binding pub fn from_spacedrive_id(spacedrive_id: Uuid) -> Self { let mut hasher = blake3::Hasher::new(); hasher.update(b"spacedrive_id:"); diff --git a/core/src/infra/db/migration/m20260114_000001_fix_search_index_include_directories.rs b/core/src/infra/db/migration/m20260114_000001_fix_search_index_include_directories.rs index c2f9494b0..67617da7b 100644 --- a/core/src/infra/db/migration/m20260114_000001_fix_search_index_include_directories.rs +++ b/core/src/infra/db/migration/m20260114_000001_fix_search_index_include_directories.rs @@ -34,7 +34,7 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared( r#" - CREATE TRIGGER entries_search_insert + CREATE TRIGGER IF NOT EXISTS entries_search_insert AFTER INSERT ON entries BEGIN INSERT INTO search_index(rowid, name, extension) @@ -49,7 +49,7 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared( r#" - CREATE TRIGGER entries_search_update + CREATE TRIGGER IF NOT EXISTS entries_search_update AFTER UPDATE ON entries BEGIN UPDATE search_index SET @@ -66,7 +66,7 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared( r#" - CREATE TRIGGER entries_search_delete + CREATE TRIGGER IF NOT EXISTS entries_search_delete AFTER DELETE ON entries BEGIN DELETE FROM search_index WHERE rowid = old.id; @@ -112,7 +112,7 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared( r#" - CREATE TRIGGER entries_search_insert + CREATE TRIGGER IF NOT EXISTS entries_search_insert AFTER INSERT ON entries WHEN new.kind = 0 BEGIN INSERT INTO search_index(rowid, name, extension) @@ -127,7 +127,7 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared( r#" - CREATE TRIGGER entries_search_update + CREATE TRIGGER IF NOT EXISTS entries_search_update AFTER UPDATE ON entries WHEN new.kind = 0 BEGIN UPDATE search_index SET @@ -144,7 +144,7 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared( r#" - CREATE TRIGGER entries_search_delete + CREATE TRIGGER IF NOT EXISTS entries_search_delete AFTER DELETE ON entries WHEN old.kind = 0 BEGIN DELETE FROM search_index WHERE rowid = old.id; diff --git a/core/src/ops/volumes/add_cloud/action.rs b/core/src/ops/volumes/add_cloud/action.rs index 7b591b771..a8da48b7d 100644 --- a/core/src/ops/volumes/add_cloud/action.rs +++ b/core/src/ops/volumes/add_cloud/action.rs @@ -407,11 +407,9 @@ impl LibraryAction for VolumeAddCloudAction { } }; - let fingerprint = VolumeFingerprint::new( - &self.input.display_name, - 0, // Cloud volumes don't have a fixed size - &format!("{:?}", self.input.service), - ); + // Generate stable fingerprint for cloud volume using service type and cloud identifier + let backend_id = format!("{:?}", self.input.service); + let fingerprint = VolumeFingerprint::from_network_volume(&backend_id, &cloud_identifier); let backend_arc: Arc = Arc::new(backend); let now = chrono::Utc::now(); diff --git a/core/src/ops/volumes/list/query.rs b/core/src/ops/volumes/list/query.rs index d0da94d7c..f59b7612d 100644 --- a/core/src/ops/volumes/list/query.rs +++ b/core/src/ops/volumes/list/query.rs @@ -208,16 +208,18 @@ impl LibraryQuery for VolumeListQuery { VolumeFilter::TrackedOnly | VolumeFilter::All => { // For tracked volumes, prefer live data if available, otherwise use DB for tracked_vol in tracked_map.values() { - if let Some(live_vol) = live_volumes_map.remove(&tracked_vol.fingerprint) { + if let Some(mut live_vol) = live_volumes_map.remove(&tracked_vol.fingerprint) { // Use live volume data (current device, online) + // Mark as tracked since it's in the database + live_vol.is_tracked = true; + live_vol.library_id = Some(library_id); volumes.push(live_vol); } else { - // Volume is offline or on another device - // Skip offline volumes from current device to avoid duplicates - if tracked_vol.device_id == current_device_id && !tracked_vol.is_online { - continue; - } - volumes.push(tracked_vol.to_tracked_volume().to_offline_volume()); + // Volume is offline or on another device + // Skip offline volumes from current device to avoid duplicates + if tracked_vol.device_id == current_device_id && !tracked_vol.is_online { + continue; + } volumes.push(tracked_vol.to_tracked_volume().to_offline_volume()); } } diff --git a/core/src/ops/volumes/speed_test/action.rs b/core/src/ops/volumes/speed_test/action.rs index 11b20ec2d..b305da6fc 100644 --- a/core/src/ops/volumes/speed_test/action.rs +++ b/core/src/ops/volumes/speed_test/action.rs @@ -65,6 +65,28 @@ impl LibraryAction for VolumeSpeedTestAction { let read_speed = volume.read_speed_mbps.unwrap_or(0); let write_speed = volume.write_speed_mbps.unwrap_or(0); + // Save results to database + context + .volume_manager + .save_speed_test_results( + &self.input.fingerprint, + read_speed, + write_speed, + &[library.clone()], + ) + .await + .map_err(|e| { + ActionError::InvalidInput(format!("Failed to save speed test results: {}", e)) + })?; + + // Emit ResourceChanged event for the volume + use crate::domain::resource::EventEmitter; + volume + .emit_changed(&context.events) + .map_err(|e| { + ActionError::Internal(format!("Failed to emit volume event: {}", e)) + })?; + // Return native output directly Ok(VolumeSpeedTestOutput::new( self.input.fingerprint, diff --git a/core/src/volume/fs/apfs.rs b/core/src/volume/fs/apfs.rs index d619594a0..9a1a77a6b 100644 --- a/core/src/volume/fs/apfs.rs +++ b/core/src/volume/fs/apfs.rs @@ -335,15 +335,10 @@ pub fn containers_to_volumes( Vec::new() }; - // Create volume fingerprint using stable identifiers only - // Use UUIDs for fingerprint (stable across reboots), not disk IDs (disk3 can become disk4) - // DO NOT use capacity_consumed as it changes when files are added/deleted! - // Use container total capacity (stable for the physical drive) - let fingerprint = crate::volume::types::VolumeFingerprint::new( - &format!("{}:{}", container.uuid, volume_info.uuid), - container.total_capacity, // Use container capacity (stable), not consumed (changes) - "APFS", - ); + // Create stable volume fingerprint for APFS volumes + // APFS volumes are always local system/primary volumes, use mount_point + device_id + let fingerprint = + crate::volume::types::VolumeFingerprint::from_primary_volume(mount_point, device_id); debug!( "APFS_CONVERT: Generated fingerprint {} for volume '{}' (consumed: {} bytes)", diff --git a/core/src/volume/manager.rs b/core/src/volume/manager.rs index db6cb105d..07a51f5aa 100644 --- a/core/src/volume/manager.rs +++ b/core/src/volume/manager.rs @@ -82,6 +82,7 @@ pub struct VolumeManager { /// Weak reference to library manager for database operations library_manager: Arc>>>, + } impl VolumeManager { @@ -111,7 +112,7 @@ impl VolumeManager { /// Initialize the volume manager and perform initial detection #[instrument(skip(self))] - pub async fn initialize(&self) -> VolumeResult<()> { + pub async fn initialize(self: &Arc) -> VolumeResult<()> { info!("Initializing volume manager"); // Perform initial volume detection (for local volumes) @@ -470,6 +471,7 @@ impl VolumeManager { &events, &config, &library_manager, + None, ) .await { @@ -552,6 +554,7 @@ impl VolumeManager { &events, &config, &library_manager, + None, ) .await { @@ -591,7 +594,7 @@ impl VolumeManager { /// Refresh all volumes and detect changes #[instrument(skip(self))] - pub async fn refresh_volumes(&self) -> VolumeResult<()> { + pub async fn refresh_volumes(self: &Arc) -> VolumeResult<()> { Self::refresh_volumes_internal( self.device_id, &self.volumes, @@ -599,6 +602,7 @@ impl VolumeManager { &self.events, &self.config, &self.library_manager, + Some(self.clone()), ) .await } @@ -611,11 +615,13 @@ impl VolumeManager { events: &Arc, config: &VolumeDetectionConfig, library_manager: &RwLock>>, + manager: Option>, ) -> VolumeResult<()> { debug!("Refreshing volumes for device {}", device_id); // Detect current volumes let detected_volumes = detection::detect_volumes(device_id, config).await?; + debug!("VOLUME_DETECT: Detected {} volumes", detected_volumes.len()); for vol in &detected_volumes { debug!( @@ -725,6 +731,24 @@ impl VolumeManager { fingerprint: fingerprint.clone(), is_mounted: new_info.is_mounted, }); + + // Auto-run speed test when volume is mounted + if new_info.is_mounted + && updated_volume.is_user_visible + && !updated_volume.is_read_only + { + if let Some(ref mgr) = manager { + let mgr = mgr.clone(); + let fp = fingerprint.clone(); + let vol_name = updated_volume.name.clone(); + tokio::spawn(async move { + info!("Auto-running speed test for mounted volume: {}", vol_name); + if let Err(e) = mgr.run_speed_test(&fp).await { + warn!("Auto speed test failed: {}", e); + } + }); + } + } } // Emit ResourceChanged event for UI reactivity (only for user-visible volumes) @@ -751,6 +775,21 @@ impl VolumeManager { // Emit volume added event events.emit(Event::VolumeAdded(detected.clone())); + // Auto-run speed test for newly discovered mounted volumes + if detected.is_mounted && detected.is_user_visible && !detected.is_read_only { + if let Some(ref mgr) = manager { + let mgr = mgr.clone(); + let fp = fingerprint.clone(); + let vol_name = detected.name.clone(); + tokio::spawn(async move { + info!("Auto-running speed test for new volume: {}", vol_name); + if let Err(e) = mgr.run_speed_test(&fp).await { + warn!("Auto speed test failed: {}", e); + } + }); + } + } + // Emit ResourceChanged event for UI reactivity (only for user-visible volumes) if detected.is_user_visible { debug!( @@ -1306,11 +1345,39 @@ impl VolumeManager { ..Default::default() }; + debug!( + "About to insert volume into database - fingerprint: {}, device_id: {}, display_name: {:?}", + fingerprint.0, + volume.device_id, + final_display_name + ); + let model = active_model .insert(library.db().conn()) .await .map_err(|e| VolumeError::Database(e.to_string()))?; + debug!( + "Successfully inserted volume into database - id: {}, uuid: {}", + model.id, + model.uuid + ); + + // Verify the insert by immediately querying it back + let verify_count = entities::volume::Entity::find() + .filter(entities::volume::Column::Fingerprint.eq(fingerprint.0.clone())) + .filter(entities::volume::Column::DeviceId.eq(volume.device_id)) + .count(library.db().conn()) + .await + .unwrap_or(0); + + debug!( + "Verification query after insert - found {} volumes with fingerprint {} and device_id {}", + verify_count, + fingerprint.0, + volume.device_id + ); + info!( "Tracked volume '{}' for library '{}'", final_display_name.as_ref().unwrap_or(&volume.name), @@ -1980,6 +2047,7 @@ impl VolumeManager { None } + } /// Statistics about detected volumes diff --git a/core/src/volume/platform/linux.rs b/core/src/volume/platform/linux.rs index fa15a31ce..98d611d33 100644 --- a/core/src/volume/platform/linux.rs +++ b/core/src/volume/platform/linux.rs @@ -103,7 +103,33 @@ fn parse_df_line( let disk_type = detect_disk_type_linux(filesystem_device)?; let file_system = utils::parse_filesystem_type(filesystem_type); let volume_type = classify_volume(&mount_path, &file_system, &name); - let fingerprint = VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()); + + // Generate stable fingerprint based on volume type + let fingerprint = match volume_type { + crate::volume::types::VolumeType::External => { + // Try to read/create dotfile for external volumes + if let Some(spacedrive_id) = + utils::read_or_create_dotfile_sync(&mount_path, device_id, None) + { + VolumeFingerprint::from_external_volume(spacedrive_id, device_id) + } else { + // Fallback to mount_point + device_id for read-only external volumes + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + } + crate::volume::types::VolumeType::Network => { + // Use filesystem device as backend identifier for network volumes + VolumeFingerprint::from_network_volume( + filesystem_device, + &mount_path.to_string_lossy(), + ) + } + _ => { + // Primary, UserData, Secondary, System, Virtual, Unknown + // All use stable mount_point + device_id + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + }; let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); @@ -247,7 +273,30 @@ pub fn create_volume_from_mount(mount: MountInfo, device_id: Uuid) -> VolumeResu let mount_type = determine_mount_type(&mount.mount_point, &mount.device); let disk_type = detect_disk_type_linux(&mount.device)?; let volume_type = classify_volume(&mount_path, &file_system, &name); - let fingerprint = VolumeFingerprint::new(&name, mount.total_bytes, &file_system.to_string()); + + // Generate stable fingerprint based on volume type + let fingerprint = match volume_type { + crate::volume::types::VolumeType::External => { + // Try to read/create dotfile for external volumes + if let Some(spacedrive_id) = + utils::read_or_create_dotfile_sync(&mount_path, device_id, None) + { + VolumeFingerprint::from_external_volume(spacedrive_id, device_id) + } else { + // Fallback to mount_point + device_id for read-only external volumes + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + } + crate::volume::types::VolumeType::Network => { + // Use device as backend identifier for network volumes + VolumeFingerprint::from_network_volume(&mount.device, &mount_path.to_string_lossy()) + } + _ => { + // Primary, UserData, Secondary, System, Virtual, Unknown + // All use stable mount_point + device_id + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + }; let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); diff --git a/core/src/volume/platform/macos.rs b/core/src/volume/platform/macos.rs index 8c566f57b..65ddfb573 100644 --- a/core/src/volume/platform/macos.rs +++ b/core/src/volume/platform/macos.rs @@ -84,22 +84,41 @@ pub async fn detect_non_apfs_volumes( .unwrap_or(FileSystem::Other("Unknown".to_string())); let volume_type = classify_volume(&mount_path, &file_system, &name); - let fingerprint = - VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()); + + // Generate stable fingerprint based on volume type + let fingerprint = match volume_type { + crate::volume::types::VolumeType::External => { + // Try to read/create dotfile for external volumes + if let Some(spacedrive_id) = + utils::read_or_create_dotfile_sync(&mount_path, device_id, None) + { + VolumeFingerprint::from_external_volume(spacedrive_id, device_id) + } else { + // Fallback to mount_point + device_id for read-only external volumes + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + } + crate::volume::types::VolumeType::Network => { + // Use filesystem as backend identifier for network volumes + VolumeFingerprint::from_network_volume( + filesystem, + &mount_path.to_string_lossy(), + ) + } + _ => { + // Primary, UserData, Secondary, System, Virtual, Unknown + // All use stable mount_point + device_id + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + }; // Check if volume should be user-visible let is_user_visible = should_be_user_visible(&mount_path, &name); - // Auto-track eligibility: Primary, UserData, System volumes - // Also track Secondary volumes if they're system mounts - let auto_track_eligible = matches!( - volume_type, - crate::volume::types::VolumeType::Primary - | crate::volume::types::VolumeType::UserData - | crate::volume::types::VolumeType::System - ) || (volume_type - == crate::volume::types::VolumeType::Secondary - && mount_type == crate::volume::types::MountType::System); + // Auto-track eligibility: Only Primary volumes that are user-visible + let auto_track_eligible = + matches!(volume_type, crate::volume::types::VolumeType::Primary) + && is_user_visible; let now = chrono::Utc::now(); diff --git a/core/src/volume/platform/windows.rs b/core/src/volume/platform/windows.rs index b94aff4fb..703469588 100644 --- a/core/src/volume/platform/windows.rs +++ b/core/src/volume/platform/windows.rs @@ -135,7 +135,30 @@ fn parse_wmic_output( let disk_type = DiskType::Unknown; // Would need additional WMI queries let volume_type = classify_volume(&mount_path, &file_system, &name); - let fingerprint = VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()); + + // Generate stable fingerprint based on volume type + let fingerprint = match volume_type { + crate::volume::types::VolumeType::External => { + // Try to read/create dotfile for external volumes + if let Some(spacedrive_id) = + utils::read_or_create_dotfile_sync(&mount_path, device_id, None) + { + VolumeFingerprint::from_external_volume(spacedrive_id, device_id) + } else { + // Fallback to mount_point + device_id for read-only external volumes + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + } + crate::volume::types::VolumeType::Network => { + // Use caption as backend identifier for network volumes + VolumeFingerprint::from_network_volume(caption, &mount_path.to_string_lossy()) + } + _ => { + // Primary, UserData, Secondary, System, Virtual, Unknown + // All use stable mount_point + device_id + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + }; let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); @@ -215,7 +238,31 @@ pub fn create_volume_from_windows_info( MountType::System }; let volume_type = classify_volume(&mount_path, &file_system, &name); - let fingerprint = VolumeFingerprint::new(&name, info.size, &file_system.to_string()); + + // Generate stable fingerprint based on volume type + let fingerprint = match volume_type { + crate::volume::types::VolumeType::External => { + // Try to read/create dotfile for external volumes + if let Some(spacedrive_id) = + utils::read_or_create_dotfile_sync(&mount_path, device_id, None) + { + VolumeFingerprint::from_external_volume(spacedrive_id, device_id) + } else { + // Fallback to mount_point + device_id for read-only external volumes + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + } + crate::volume::types::VolumeType::Network => { + // Use mount path as backend identifier for network volumes + let backend_id = info.volume_guid.as_deref().unwrap_or(&mount_path.to_string_lossy()); + VolumeFingerprint::from_network_volume(backend_id, &mount_path.to_string_lossy()) + } + _ => { + // Primary, UserData, Secondary, System, Virtual, Unknown + // All use stable mount_point + device_id + VolumeFingerprint::from_primary_volume(&mount_path, device_id) + } + }; let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); diff --git a/core/src/volume/speed.rs b/core/src/volume/speed.rs index 01d304289..7959537d8 100644 --- a/core/src/volume/speed.rs +++ b/core/src/volume/speed.rs @@ -333,16 +333,17 @@ mod tests { async fn test_full_speed_test() { let temp_dir = TempDir::new().unwrap(); - let fingerprint = VolumeFingerprint::new("Test Volume", 1000000000, "test"); - let now = chrono::Utc::now(); + let device_id = uuid::Uuid::new_v4(); let mount_path = temp_dir.path().to_path_buf(); + let fingerprint = VolumeFingerprint::from_primary_volume(&mount_path, device_id); + let now = chrono::Utc::now(); let volume = Volume { id: uuid::Uuid::new_v4(), fingerprint, cloud_identifier: None, cloud_config: None, - device_id: uuid::Uuid::new_v4(), + device_id, name: "Test Volume".to_string(), library_id: None, is_tracked: false, diff --git a/core/src/volume/utils.rs b/core/src/volume/utils.rs index a8702394a..588cc14d2 100644 --- a/core/src/volume/utils.rs +++ b/core/src/volume/utils.rs @@ -1,10 +1,15 @@ //! Shared utilities for volume detection across platforms -use crate::volume::{ - error::{VolumeError, VolumeResult}, - types::FileSystem, +use crate::{ + domain::volume::{SpacedriveVolumeId, SPACEDRIVE_VOLUME_ID_FILE}, + volume::{ + error::{VolumeError, VolumeResult}, + types::FileSystem, + }, }; -use tracing::warn; +use std::path::Path; +use tracing::{debug, info, warn}; +use uuid::Uuid; /// Parse size strings from df output (e.g., "1.5G", "931Gi", "1024K") pub fn parse_size_string(size_str: &str) -> VolumeResult { @@ -79,6 +84,130 @@ pub fn parse_filesystem_type(fs_type: &str) -> FileSystem { } } +/// Read or create .spacedrive-volume-id file on external volumes (async version) +/// This file provides persistent identification for removable drives +/// Returns the UUID from the file, or None if the volume is read-only +pub async fn read_or_create_dotfile( + mount_point: &Path, + device_id: Uuid, + library_id: Option, +) -> Option { + let id_file_path = mount_point.join(SPACEDRIVE_VOLUME_ID_FILE); + + // Try to read existing dotfile + if let Ok(content) = tokio::fs::read_to_string(&id_file_path).await { + if let Ok(spacedrive_id) = serde_json::from_str::(&content) { + debug!( + "Found existing dotfile ID: {} at {}", + spacedrive_id.id, + id_file_path.display() + ); + return Some(spacedrive_id.id); + } + } + + // Try to create new dotfile (if writable) + if !mount_point.exists() { + return None; + } + + let spacedrive_id = SpacedriveVolumeId { + id: Uuid::new_v4(), + created: chrono::Utc::now(), + device_name: None, + volume_name: mount_point + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Unknown".to_string()), + device_id, + library_id: library_id.unwrap_or(Uuid::nil()), + }; + + if let Ok(content) = serde_json::to_string_pretty(&spacedrive_id) { + match tokio::fs::write(&id_file_path, content).await { + Ok(()) => { + info!( + "Created dotfile with ID: {} at {}", + spacedrive_id.id, + id_file_path.display() + ); + return Some(spacedrive_id.id); + } + Err(e) => { + debug!( + "Could not write dotfile to {}: {}", + id_file_path.display(), + e + ); + } + } + } + + None +} + +/// Read or create .spacedrive-volume-id file on external volumes (sync version) +/// This file provides persistent identification for removable drives +/// Returns the UUID from the file, or None if the volume is read-only +pub fn read_or_create_dotfile_sync( + mount_point: &Path, + device_id: Uuid, + library_id: Option, +) -> Option { + let id_file_path = mount_point.join(SPACEDRIVE_VOLUME_ID_FILE); + + // Try to read existing dotfile + if let Ok(content) = std::fs::read_to_string(&id_file_path) { + if let Ok(spacedrive_id) = serde_json::from_str::(&content) { + debug!( + "Found existing dotfile ID: {} at {}", + spacedrive_id.id, + id_file_path.display() + ); + return Some(spacedrive_id.id); + } + } + + // Try to create new dotfile (if writable) + if !mount_point.exists() { + return None; + } + + let spacedrive_id = SpacedriveVolumeId { + id: Uuid::new_v4(), + created: chrono::Utc::now(), + device_name: None, + volume_name: mount_point + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Unknown".to_string()), + device_id, + library_id: library_id.unwrap_or(Uuid::nil()), + }; + + if let Ok(content) = serde_json::to_string_pretty(&spacedrive_id) { + match std::fs::write(&id_file_path, content) { + Ok(()) => { + info!( + "Created dotfile with ID: {} at {}", + spacedrive_id.id, + id_file_path.display() + ); + return Some(spacedrive_id.id); + } + Err(e) => { + debug!( + "Could not write dotfile to {}: {}", + id_file_path.display(), + e + ); + } + } + } + + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/interface/src/ShellLayout.tsx b/packages/interface/src/ShellLayout.tsx index 018f59093..193444fda 100644 --- a/packages/interface/src/ShellLayout.tsx +++ b/packages/interface/src/ShellLayout.tsx @@ -4,6 +4,7 @@ import {AnimatePresence, motion} from 'framer-motion'; import {useEffect, useMemo} from 'react'; import {Outlet, useLocation, useParams} from 'react-router-dom'; import {Inspector} from './components/Inspector/Inspector'; +import {JobsProvider} from './components/JobManager/hooks/JobsContext'; import { PREVIEW_LAYER_ID, QuickPreviewController, @@ -271,15 +272,17 @@ function ShellLayoutContent() { export function ShellLayout() { return ( - - - - {/* Sync tab navigation and defaults with router */} - - - - - - + + + + + {/* Sync tab navigation and defaults with router */} + + + + + + + ); } diff --git a/packages/interface/src/components/Inspector/variants/FileInspector.tsx b/packages/interface/src/components/Inspector/variants/FileInspector.tsx index 16884db91..a705b0600 100644 --- a/packages/interface/src/components/Inspector/variants/FileInspector.tsx +++ b/packages/interface/src/components/Inspector/variants/FileInspector.tsx @@ -28,7 +28,7 @@ import type {File} from '@sd/ts-client'; import {toast} from '@sd/ui'; import clsx from 'clsx'; import {useState} from 'react'; -import {useJobs} from '../../../components/JobManager/hooks/useJobs'; +import {useJobsContext} from '../../../components/JobManager/hooks/JobsContext'; import {TagSelectorButton} from '../../../components/Tags'; import {usePlatform} from '../../../contexts/PlatformContext'; import {useServer} from '../../../contexts/ServerContext'; @@ -136,7 +136,7 @@ function OverviewTab({file}: {file: File}) { const generateProxy = useLibraryMutation('media.proxy.generate'); // Job tracking for long-running operations - const {jobs} = useJobs(); + const {jobs} = useJobsContext(); const isSpeechJobRunning = jobs.some( (job) => job.name === 'speech_to_text' && diff --git a/packages/interface/src/components/JobManager/JobManagerPopover.tsx b/packages/interface/src/components/JobManager/JobManagerPopover.tsx index c0fe68717..fcf9808f4 100644 --- a/packages/interface/src/components/JobManager/JobManagerPopover.tsx +++ b/packages/interface/src/components/JobManager/JobManagerPopover.tsx @@ -5,7 +5,7 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { JobList } from "./components/JobList"; -import { useJobs } from "./hooks/useJobs"; +import { useJobsContext } from "./hooks/JobsContext"; import { CARD_HEIGHT } from "./types"; interface JobManagerPopoverProps { @@ -18,7 +18,7 @@ export function JobManagerPopover({ className }: JobManagerPopoverProps) { const [showOnlyRunning, setShowOnlyRunning] = useState(true); // Unified hook for job data and badge/icon - const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel, getSpeedHistory } = useJobs(); + const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel, getSpeedHistory } = useJobsContext(); // Reset filter to "active only" when popover opens useEffect(() => { diff --git a/packages/interface/src/components/JobManager/JobsScreen/index.tsx b/packages/interface/src/components/JobManager/JobsScreen/index.tsx index 1ffe25f6e..7e9a3b092 100644 --- a/packages/interface/src/components/JobManager/JobsScreen/index.tsx +++ b/packages/interface/src/components/JobManager/JobsScreen/index.tsx @@ -2,12 +2,12 @@ import { X, FunnelSimple } from "@phosphor-icons/react"; import { TopBarButton } from "@sd/ui"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useJobs } from "../hooks/useJobs"; +import { useJobsContext } from "../hooks/JobsContext"; import { JobRow } from "./JobRow"; export function JobsScreen() { const navigate = useNavigate(); - const { jobs, pause, resume, cancel } = useJobs(); + const { jobs, pause, resume, cancel } = useJobsContext(); const [showOnlyRunning, setShowOnlyRunning] = useState(false); // Filter jobs based on toggle diff --git a/packages/interface/src/components/JobManager/hooks/JobsContext.tsx b/packages/interface/src/components/JobManager/hooks/JobsContext.tsx new file mode 100644 index 000000000..e797916f8 --- /dev/null +++ b/packages/interface/src/components/JobManager/hooks/JobsContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useJobs } from './useJobs'; +import type { SpeedSample } from './useJobs'; +import type { JobListItem } from '../types'; + +interface JobsContextValue { + jobs: JobListItem[]; + activeJobCount: number; + hasRunningJobs: boolean; + pause: (jobId: string) => Promise; + resume: (jobId: string) => Promise; + cancel: (jobId: string) => Promise; + isLoading: boolean; + error: any; + getSpeedHistory: (jobId: string) => SpeedSample[]; +} + +const JobsContext = createContext(null); + +export function JobsProvider({ children }: { children: ReactNode }) { + const jobsData = useJobs(); + + return ( + + {children} + + ); +} + +export function useJobsContext() { + const context = useContext(JobsContext); + if (!context) { + throw new Error('useJobsContext must be used within JobsProvider'); + } + return context; +} diff --git a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx index 5102d8aaf..499e352d3 100644 --- a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx @@ -1,45 +1,79 @@ -import { useNavigate } from "react-router-dom"; -import { Plugs, WifiSlash } from "@phosphor-icons/react"; -import { useNormalizedQuery, getVolumeIcon } from "@sd/ts-client"; -import { SpaceItem } from "./SpaceItem"; -import { GroupHeader } from "./GroupHeader"; -import type { Volume } from "@sd/ts-client"; +import {EyeSlash, Plugs, WifiSlash} from '@phosphor-icons/react'; +import {getVolumeIcon, useNormalizedQuery} from '@sd/ts-client'; +import type {Volume} from '@sd/ts-client'; +import {useNavigate} from 'react-router-dom'; +import {GroupHeader} from './GroupHeader'; +import {SpaceItem} from './SpaceItem'; +import {useVolumeContextMenu} from './hooks/useVolumeContextMenu'; interface VolumesGroupProps { isCollapsed: boolean; onToggle: () => void; /** Filter to show tracked, untracked, or all volumes (default: "All") */ - filter?: "TrackedOnly" | "UntrackedOnly" | "All"; + filter?: 'TrackedOnly' | 'UntrackedOnly' | 'All'; sortableAttributes?: any; sortableListeners?: any; } +// Helper to render volume status indicator +const getVolumeIndicator = (volume: Volume) => ( + <> + {!volume.is_tracked && ( + + )} + +); + +// Component for individual volume items with context menu +function VolumeItem({volume, index, volumesLength}: {volume: Volume; index: number; volumesLength: number}) { + const contextMenu = useVolumeContextMenu({volume}); + + return ( + + ); +} + export function VolumesGroup({ isCollapsed, onToggle, - filter = "All", + filter = 'All', sortableAttributes, - sortableListeners, + sortableListeners }: VolumesGroupProps) { - const navigate = useNavigate(); - - const { data: volumesData } = useNormalizedQuery({ - wireMethod: "query:volumes.list", - input: { filter }, - resourceType: "volume", + const {data: volumesData} = useNormalizedQuery({ + wireMethod: 'query:volumes.list', + input: {filter}, + resourceType: 'volume' }); const volumes = volumesData?.volumes || []; - // Helper to render volume status indicator - const getVolumeIndicator = (volume: Volume) => ( - <> - {!volume.is_tracked && ( - - )} - - ); - return (
{volumes.length === 0 ? ( -
+
No volumes
) : ( volumes.map((volume, index) => ( - )) )} diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts b/packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts new file mode 100644 index 000000000..314cb6b14 --- /dev/null +++ b/packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts @@ -0,0 +1,109 @@ +import { + Database, + EyeSlash, + Eye, + Gauge, + EjectSimple +} from '@phosphor-icons/react'; +import type { Volume } from '@sd/ts-client'; +import { + useContextMenu, + type ContextMenuItem, + type ContextMenuResult +} from '../../../hooks/useContextMenu'; +import { useLibraryMutation } from '../../../contexts/SpacedriveContext'; + +interface UseVolumeContextMenuOptions { + volume: Volume; +} + +/** + * Provides context menu functionality for volume items. + * + * Menu items include: + * - Track Volume: Add volume to library tracking + * - Untrack Volume: Remove volume from library tracking + * - Speed Test: Test read/write performance + * - Index Volume: Trigger full volume indexing + */ +export function useVolumeContextMenu({ + volume +}: UseVolumeContextMenuOptions): ContextMenuResult { + const trackVolume = useLibraryMutation('volumes.track'); + const untrackVolume = useLibraryMutation('volumes.untrack'); + const speedTestVolume = useLibraryMutation('volumes.speed_test'); + const indexVolume = useLibraryMutation('volumes.index'); + + const items: ContextMenuItem[] = [ + { + icon: Eye, + label: 'Track Volume', + onClick: async () => { + try { + await trackVolume.mutateAsync({ + fingerprint: volume.fingerprint, + display_name: null + }); + } catch (err) { + console.error('Failed to track volume:', err); + } + }, + condition: () => !volume.is_tracked + }, + { + icon: EyeSlash, + label: 'Untrack Volume', + onClick: async () => { + try { + await untrackVolume.mutateAsync({ + volume_id: volume.id + }); + } catch (err) { + console.error('Failed to untrack volume:', err); + } + }, + variant: 'danger' as const, + condition: () => volume.is_tracked + }, + { type: 'separator' }, + { + icon: Database, + label: 'Index Volume', + onClick: async () => { + try { + const result = await indexVolume.mutateAsync({ + fingerprint: volume.fingerprint, + scope: 'Recursive' + }); + console.log('Volume indexed:', result.message); + } catch (err) { + console.error('Failed to index volume:', err); + } + }, + condition: () => volume.is_mounted + }, + { + icon: Gauge, + label: 'Speed Test', + onClick: async () => { + try { + const result = await speedTestVolume.mutateAsync({ + fingerprint: volume.fingerprint + }); + console.log( + 'Speed test complete:', + result.read_speed_mbps, + 'MB/s read,', + result.write_speed_mbps, + 'MB/s write' + ); + } catch (err) { + console.error('Failed to run speed test:', err); + } + }, + condition: () => volume.is_mounted + } + ]; + + return useContextMenu({ items }); +} diff --git a/packages/interface/src/components/SpacesSidebar/index.tsx b/packages/interface/src/components/SpacesSidebar/index.tsx index 3a5e06efa..f2f55ce35 100644 --- a/packages/interface/src/components/SpacesSidebar/index.tsx +++ b/packages/interface/src/components/SpacesSidebar/index.tsx @@ -12,7 +12,7 @@ import { SpaceCustomizationPanel } from "./SpaceCustomizationPanel"; import { useSpacedriveClient } from "../../contexts/SpacedriveContext"; import { useLibraries } from "../../hooks/useLibraries"; import { usePlatform } from "../../contexts/PlatformContext"; -import { useJobs } from "../JobManager/hooks/useJobs"; +import { useJobsContext } from "../JobManager/hooks/JobsContext"; import { useSyncCount } from "../SyncMonitor/hooks/useSyncCount"; import { useSyncMonitor } from "../SyncMonitor/hooks/useSyncMonitor"; import { PeerList } from "../SyncMonitor/components/PeerList"; @@ -208,6 +208,7 @@ const JobsButton = memo(function JobsButton({ pause, resume, cancel, + getSpeedHistory, navigate }: { activeJobCount: number; @@ -216,6 +217,7 @@ const JobsButton = memo(function JobsButton({ pause: (jobId: string) => Promise; resume: (jobId: string) => Promise; cancel: (jobId: string) => Promise; + getSpeedHistory: (jobId: string) => any[]; navigate: any; }) { const popover = usePopover(); @@ -286,7 +288,7 @@ const JobsButton = memo(function JobsButton({ }} transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }} > - + )} @@ -315,7 +317,7 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) { // Get sync and job status for icons const { onlinePeerCount, isSyncing } = useSyncCount(); - const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs(); + const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel, getSpeedHistory } = useJobsContext(); const { currentSpaceId, setCurrentSpace } = useSidebarStore(); const { data: spacesData } = useSpaces(); @@ -430,13 +432,14 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
- ))} diff --git a/packages/interface/src/routes/overview/VolumeBar.tsx b/packages/interface/src/routes/overview/VolumeBar.tsx index 7cdbc4477..cfbf7d9dc 100644 --- a/packages/interface/src/routes/overview/VolumeBar.tsx +++ b/packages/interface/src/routes/overview/VolumeBar.tsx @@ -1,4 +1,4 @@ -import {Database, Plus} from '@phosphor-icons/react'; +import {DotsThree, EyeSlash} from '@phosphor-icons/react'; import DatabaseIcon from '@sd/assets/icons/Database.png'; import DriveAmazonS3Icon from '@sd/assets/icons/Drive-AmazonS3.png'; import DriveDropboxIcon from '@sd/assets/icons/Drive-Dropbox.png'; @@ -6,9 +6,11 @@ import DriveGoogleDriveIcon from '@sd/assets/icons/Drive-GoogleDrive.png'; import DriveIcon from '@sd/assets/icons/Drive.png'; import HDDIcon from '@sd/assets/icons/HDD.png'; import ServerIcon from '@sd/assets/icons/Server.png'; +import {TopBarButton} from '@sd/ui'; import type {Device, VolumeItem} from '@sd/ts-client'; import {motion} from 'framer-motion'; import {useEffect, useState} from 'react'; +import {useVolumeContextMenu} from '../../components/SpacesSidebar/hooks/useVolumeContextMenu'; import { useLibraryMutation, useNormalizedQuery, @@ -34,12 +36,12 @@ interface IndexingProgress { } export function VolumeBar({volume, index}: VolumeBarProps) { - const trackVolume = useLibraryMutation('volumes.track'); - const indexVolume = useLibraryMutation('volumes.index'); const [indexingProgress, setIndexingProgress] = useState(null); const client = useSpacedriveClient(); + const contextMenu = useVolumeContextMenu({volume: volume as any}); + // Get the job ID for this volume from the store const jobId = useVolumeIndexingStore((state) => state.getJobId(volume.fingerprint) @@ -144,28 +146,6 @@ export function VolumeBar({volume, index}: VolumeBarProps) { const currentDevice = devicesQuery.data?.find((d) => d.is_current); - const handleTrack = async () => { - try { - await trackVolume.mutateAsync({ - fingerprint: volume.fingerprint - }); - } catch (error) { - console.error('Failed to track volume:', error); - } - }; - - const handleIndex = async () => { - try { - const result = await indexVolume.mutateAsync({ - fingerprint: volume.fingerprint, - scope: 'Recursive' - }); - console.log('Volume indexed:', result.message); - } catch (error) { - console.error('Failed to index volume:', error); - } - }; - if (!volume.total_capacity) { return null; } @@ -218,6 +198,7 @@ export function VolumeBar({volume, index}: VolumeBarProps) { animate={{opacity: 1, y: 0}} transition={{delay: index * 0.05}} className="bg-app-box border-app-line/50 overflow-hidden rounded-lg border" + onContextMenu={contextMenu.show} > {/* Top row: Info */}
@@ -240,34 +221,22 @@ export function VolumeBar({volume, index}: VolumeBarProps) { )} {!volume.is_tracked && ( - + )} {currentDevice && volume.device_id === currentDevice.id && ( - + { + e.stopPropagation(); + contextMenu.show(e); + }} + title="Volume actions" + /> )}
@@ -317,6 +286,23 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
{formatBytes(availableBytes)} free
+ {(volume.read_speed_mbps || volume.write_speed_mbps) && ( +
+ {volume.read_speed_mbps && ( + <> + {volume.read_speed_mbps}MB/s read + + )} + {volume.read_speed_mbps && volume.write_speed_mbps && ( + + )} + {volume.write_speed_mbps && ( + <> + {volume.write_speed_mbps}MB/s write + + )} +
+ )}
From 1b8e39d16bf05f725c00941aed8cfe72a317e8ad Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 16 Jan 2026 07:13:22 -0800 Subject: [PATCH 2/4] feat(volume): add volume eject functionality and update context menu This commit introduces a new feature for safely ejecting removable volumes, enhancing the volume management capabilities. It adds the `VolumeEjectAction`, `VolumeEjectInput`, and `VolumeEjectOutput` structures to handle the eject operation. Additionally, the context menu in the UI is updated to include an option for ejecting volumes, improving user experience. The volume display logic is also refined to ensure accurate representation of volume properties. These changes collectively enhance the overall functionality and usability of the volume management system. --- apps/cli/src/domains/location/mod.rs | 27 ++-- apps/cli/src/domains/volume/mod.rs | 6 +- core/src/domain/volume.rs | 2 + core/src/ops/volumes/eject/action.rs | 145 ++++++++++++++++++ core/src/ops/volumes/eject/input.rs | 8 + core/src/ops/volumes/eject/mod.rs | 7 + core/src/ops/volumes/eject/output.rs | 12 ++ core/src/ops/volumes/mod.rs | 3 + core/src/ops/volumes/speed_test/action.rs | 15 +- .../hooks/useVolumeContextMenu.ts | 24 +++ .../explorer/hooks/useVirtualListing.ts | 2 +- .../explorer/views/GridView/FileCard.tsx | 4 +- packages/ts-client/src/generated/types.ts | 28 ++++ 13 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 core/src/ops/volumes/eject/action.rs create mode 100644 core/src/ops/volumes/eject/input.rs create mode 100644 core/src/ops/volumes/eject/mod.rs create mode 100644 core/src/ops/volumes/eject/output.rs diff --git a/apps/cli/src/domains/location/mod.rs b/apps/cli/src/domains/location/mod.rs index ae688105c..146f1e39b 100644 --- a/apps/cli/src/domains/location/mod.rs +++ b/apps/cli/src/domains/location/mod.rs @@ -180,16 +180,13 @@ async fn run_interactive_add(ctx: &Context) -> Result { let volume_choices: Vec = volumes .volumes .iter() - .filter_map(|v| { - // Parse mount_point to get service and identifier - v.mount_point.as_ref().map(|mp| { - format!( - "{} ({}) - {}", - v.name, - mp, // Show mount point like "s3://bucket" - v.volume_type - ) - }) + .map(|v| { + format!( + "{} ({}) - {}", + v.name, + v.mount_point.display(), // Show mount point like "s3://bucket" + v.volume_type + ) }) .collect(); @@ -203,10 +200,7 @@ async fn run_interactive_add(ctx: &Context) -> Result { // Get the mount point for the selected volume let selected_volume = &volumes.volumes[volume_idx]; - let mount_point = selected_volume - .mount_point - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Selected volume has no mount point"))?; + let mount_point = &selected_volume.mount_point; // Get cloud path within the volume let cloud_path = text( @@ -216,10 +210,11 @@ async fn run_interactive_add(ctx: &Context) -> Result { .unwrap(); // Construct service-based URI: mount_point + path + let mount_point_str = mount_point.to_string_lossy(); let full_uri = if cloud_path.starts_with('/') { - format!("{}{}", mount_point, cloud_path) + format!("{}{}", mount_point_str, cloud_path) } else { - format!("{}/{}", mount_point, cloud_path) + format!("{}/{}", mount_point_str, cloud_path) }; // Parse the URI to create SdPath diff --git a/apps/cli/src/domains/volume/mod.rs b/apps/cli/src/domains/volume/mod.rs index 38ec87773..c58a2ca6b 100644 --- a/apps/cli/src/domains/volume/mod.rs +++ b/apps/cli/src/domains/volume/mod.rs @@ -86,9 +86,9 @@ pub async fn run(ctx: &Context, cmd: VolumeCmd) -> Result<()> { println!(" ID: {}", volume.id); println!(" Fingerprint: {}", volume.fingerprint); println!(" Type: {:?}", volume.volume_type); -ttttprintln!(" Mount: {}", volume.mount_point.display()); -ttttprintln!(" Mounted: {}", volume.is_mounted); -ttttprintln!(" Tracked: {}", volume.is_tracked); +println!(" Mount: {}", volume.mount_point.display()); +println!(" Mounted: {}", volume.is_mounted); +println!(" Tracked: {}", volume.is_tracked); println!(); } } diff --git a/core/src/domain/volume.rs b/core/src/domain/volume.rs index 98033c8ec..896a5e80b 100644 --- a/core/src/domain/volume.rs +++ b/core/src/domain/volume.rs @@ -330,6 +330,8 @@ pub struct Volume { pub is_read_only: bool, /// Whether volume is currently mounted/available + /// Also deserializes from legacy "is_online" field for backwards compatibility + #[serde(alias = "is_online")] pub is_mounted: bool, /// Hardware identifier (device path, UUID, etc.) diff --git a/core/src/ops/volumes/eject/action.rs b/core/src/ops/volumes/eject/action.rs new file mode 100644 index 000000000..9f663149a --- /dev/null +++ b/core/src/ops/volumes/eject/action.rs @@ -0,0 +1,145 @@ +//! Volume eject action + +use super::{VolumeEjectInput, VolumeEjectOutput}; +use crate::{ + context::CoreContext, + infra::action::error::ActionError, + volume::VolumeFingerprint, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::{error, info}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeEjectAction { + input: VolumeEjectInput, +} + +impl VolumeEjectAction { + pub fn new(input: VolumeEjectInput) -> Self { + Self { input } + } +} + +crate::register_library_action!(VolumeEjectAction, "volumes.eject"); + +impl crate::infra::action::LibraryAction for VolumeEjectAction { + type Input = VolumeEjectInput; + type Output = VolumeEjectOutput; + + fn from_input(input: Self::Input) -> Result { + Ok(VolumeEjectAction::new(input)) + } + + async fn execute( + self, + _library: Arc, + context: Arc, + ) -> Result { + let fingerprint = VolumeFingerprint(self.input.fingerprint.clone()); + + info!("Ejecting volume with fingerprint: {}", fingerprint); + + // Get the volume from the volume manager + let volume = context + .volume_manager + .get_volume(&fingerprint) + .await + .ok_or_else(|| { + ActionError::Internal(format!("Volume not found: {}", fingerprint)) + })?; + + // Check if volume is mounted + if !volume.is_mounted { + return Ok(VolumeEjectOutput { + fingerprint: self.input.fingerprint, + success: false, + message: Some("Volume is not mounted".to_string()), + }); + } + + // Platform-specific eject + let result = eject_volume_platform(&volume.mount_point.to_string_lossy()).await; + + match result { + Ok(message) => { + info!("Successfully ejected volume: {}", fingerprint); + Ok(VolumeEjectOutput { + fingerprint: self.input.fingerprint, + success: true, + message: Some(message), + }) + } + Err(e) => { + error!("Failed to eject volume {}: {}", fingerprint, e); + Ok(VolumeEjectOutput { + fingerprint: self.input.fingerprint, + success: false, + message: Some(format!("Eject failed: {}", e)), + }) + } + } + } + + fn action_kind(&self) -> &'static str { + "volumes.eject" + } +} + +/// Platform-specific volume ejection +#[cfg(target_os = "macos")] +async fn eject_volume_platform(mount_point: &str) -> Result { + use tokio::process::Command as TokioCommand; + + info!("Ejecting volume at mount point: {}", mount_point); + + // Use diskutil eject on macOS + let output = TokioCommand::new("diskutil") + .args(["eject", mount_point]) + .output() + .await + .map_err(|e| format!("Failed to execute diskutil: {}", e))?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(stderr.trim().to_string()) + } +} + +/// Platform-specific volume ejection +#[cfg(target_os = "linux")] +async fn eject_volume_platform(mount_point: &str) -> Result { + use tokio::process::Command as TokioCommand; + + info!("Unmounting volume at mount point: {}", mount_point); + + // Use umount on Linux + let output = TokioCommand::new("umount") + .arg(mount_point) + .output() + .await + .map_err(|e| format!("Failed to execute umount: {}", e))?; + + if output.status.success() { + Ok(format!("Successfully unmounted {}", mount_point)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Failed to unmount: {}", stderr.trim())) + } +} + +/// Platform-specific volume ejection +#[cfg(target_os = "windows")] +async fn eject_volume_platform(_mount_point: &str) -> Result { + // Windows requires Win32 API calls or PowerShell + Err("Volume ejection is not yet implemented on Windows. Please use Windows Explorer to safely remove the device.".to_string()) +} + +/// Fallback for unsupported platforms +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +async fn eject_volume_platform(_mount_point: &str) -> Result { + Err("Volume ejection is not supported on this platform".to_string()) +} diff --git a/core/src/ops/volumes/eject/input.rs b/core/src/ops/volumes/eject/input.rs new file mode 100644 index 000000000..e1419bc99 --- /dev/null +++ b/core/src/ops/volumes/eject/input.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +/// Input for ejecting a volume +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +pub struct VolumeEjectInput { + /// Fingerprint of the volume to eject + pub fingerprint: String, +} diff --git a/core/src/ops/volumes/eject/mod.rs b/core/src/ops/volumes/eject/mod.rs new file mode 100644 index 000000000..e941e62eb --- /dev/null +++ b/core/src/ops/volumes/eject/mod.rs @@ -0,0 +1,7 @@ +pub mod action; +mod input; +mod output; + +pub use action::VolumeEjectAction; +pub use input::VolumeEjectInput; +pub use output::VolumeEjectOutput; diff --git a/core/src/ops/volumes/eject/output.rs b/core/src/ops/volumes/eject/output.rs new file mode 100644 index 000000000..03f171e54 --- /dev/null +++ b/core/src/ops/volumes/eject/output.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +/// Output from volume eject operation +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +pub struct VolumeEjectOutput { + /// The fingerprint of the ejected volume + pub fingerprint: String, + /// Whether the eject was successful + pub success: bool, + /// Optional message (error or success details) + pub message: Option, +} diff --git a/core/src/ops/volumes/mod.rs b/core/src/ops/volumes/mod.rs index 262c31997..a691417a0 100644 --- a/core/src/ops/volumes/mod.rs +++ b/core/src/ops/volumes/mod.rs @@ -6,8 +6,10 @@ //! - Adding/removing cloud volumes //! - Listing volumes //! - Ephemeral indexing entire volumes +//! - Ejecting removable volumes pub mod add_cloud; +pub mod eject; pub mod index; pub mod list; pub mod refresh; @@ -17,6 +19,7 @@ pub mod track; pub mod untrack; pub use add_cloud::{action::VolumeAddCloudAction, VolumeAddCloudOutput}; +pub use eject::{VolumeEjectAction, VolumeEjectInput, VolumeEjectOutput}; pub use index::{IndexVolumeAction, IndexVolumeInput, IndexVolumeOutput}; pub use list::{VolumeFilter, VolumeListOutput, VolumeListQuery, VolumeListQueryInput}; pub use refresh::{action::VolumeRefreshAction, VolumeRefreshOutput}; diff --git a/core/src/ops/volumes/speed_test/action.rs b/core/src/ops/volumes/speed_test/action.rs index b305da6fc..4524bdf96 100644 --- a/core/src/ops/volumes/speed_test/action.rs +++ b/core/src/ops/volumes/speed_test/action.rs @@ -53,7 +53,7 @@ impl LibraryAction for VolumeSpeedTestAction { .map_err(|e| ActionError::InvalidInput(format!("Speed test failed: {}", e)))?; // Get the updated volume with speed test results - let volume = context + let mut volume = context .volume_manager .get_volume(&self.input.fingerprint) .await @@ -79,7 +79,18 @@ impl LibraryAction for VolumeSpeedTestAction { ActionError::InvalidInput(format!("Failed to save speed test results: {}", e)) })?; - // Emit ResourceChanged event for the volume + // Update volume timestamps to match what was saved to database + volume.updated_at = chrono::Utc::now(); + + // Log the volume data before emitting to verify it has speeds + tracing::info!( + "Emitting ResourceChanged for volume '{}' with speeds: read={}MB/s write={}MB/s", + volume.name, + volume.read_speed_mbps.unwrap_or(0), + volume.write_speed_mbps.unwrap_or(0) + ); + + // Emit ResourceChanged event for the volume with complete data use crate::domain::resource::EventEmitter; volume .emit_changed(&context.events) diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts b/packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts index 314cb6b14..8efa69ea2 100644 --- a/packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts +++ b/packages/interface/src/components/SpacesSidebar/hooks/useVolumeContextMenu.ts @@ -25,6 +25,7 @@ interface UseVolumeContextMenuOptions { * - Untrack Volume: Remove volume from library tracking * - Speed Test: Test read/write performance * - Index Volume: Trigger full volume indexing + * - Eject Volume: Safely eject removable media */ export function useVolumeContextMenu({ volume @@ -33,6 +34,9 @@ export function useVolumeContextMenu({ const untrackVolume = useLibraryMutation('volumes.untrack'); const speedTestVolume = useLibraryMutation('volumes.speed_test'); const indexVolume = useLibraryMutation('volumes.index'); + const ejectVolume = useLibraryMutation('volumes.eject'); + + const isRemovable = volume.mount_type === 'External'; const items: ContextMenuItem[] = [ { @@ -102,6 +106,26 @@ export function useVolumeContextMenu({ } }, condition: () => volume.is_mounted + }, + { + icon: EjectSimple, + label: 'Eject', + onClick: async () => { + try { + const result = await ejectVolume.mutateAsync({ + fingerprint: volume.fingerprint + }); + if (result.success) { + console.log('Volume ejected successfully'); + } else { + console.error('Eject failed:', result.message); + } + } catch (err) { + console.error('Failed to eject volume:', err); + } + }, + keybind: '⌘E', + condition: () => isRemovable && volume.is_mounted } ]; diff --git a/packages/interface/src/routes/explorer/hooks/useVirtualListing.ts b/packages/interface/src/routes/explorer/hooks/useVirtualListing.ts index 34b97fb97..4d6db5dcf 100644 --- a/packages/interface/src/routes/explorer/hooks/useVirtualListing.ts +++ b/packages/interface/src/routes/explorer/hooks/useVirtualListing.ts @@ -100,7 +100,7 @@ export function useVirtualListing(): VirtualListingResult { // Add volumes for this device const deviceVolumes = volumes.filter( - (vol: any) => vol.device_slug === device.slug, + (vol: any) => vol.device_id === device.id, ); virtualFiles.push( diff --git a/packages/interface/src/routes/explorer/views/GridView/FileCard.tsx b/packages/interface/src/routes/explorer/views/GridView/FileCard.tsx index a084776c7..6c5a08c82 100644 --- a/packages/interface/src/routes/explorer/views/GridView/FileCard.tsx +++ b/packages/interface/src/routes/explorer/views/GridView/FileCard.tsx @@ -137,7 +137,7 @@ export const FileCard = memo( const volumeData = isVolume ? (file as any)._virtual.data : null; const hasVolumeCapacity = volumeData?.total_capacity != null && - volumeData?.available_capacity != null && + volumeData?.available_space != null && volumeData.total_capacity > 0; return ( @@ -199,7 +199,7 @@ export const FileCard = memo( {showFileSize && hasVolumeCapacity && ( )} diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index c23701ebc..d486406ed 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -4188,6 +4188,32 @@ export type VolumeAddCloudInput = { service: CloudServiceType; display_name: str export type VolumeAddCloudOutput = { fingerprint: VolumeFingerprint; volume_name: string; service: CloudServiceType }; +/** + * Input for ejecting a volume + */ +export type VolumeEjectInput = { +/** + * Fingerprint of the volume to eject + */ +fingerprint: string }; + +/** + * Output from volume eject operation + */ +export type VolumeEjectOutput = { +/** + * The fingerprint of the ejected volume + */ +fingerprint: string; +/** + * Whether the eject was successful + */ +success: boolean; +/** + * Optional message (error or success details) + */ +message: string | null }; + export type VolumeFilter = /** * Only return tracked volumes @@ -4408,6 +4434,7 @@ export type LibraryAction = | { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput } | { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput } | { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput } + | { type: 'volumes.eject'; input: VolumeEjectInput; output: VolumeEjectOutput } | { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput } | { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput } | { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput } @@ -4520,6 +4547,7 @@ export const WIRE_METHODS = { 'tags.apply': 'action:tags.apply.input', 'tags.create': 'action:tags.create.input', 'volumes.add_cloud': 'action:volumes.add_cloud.input', + 'volumes.eject': 'action:volumes.eject.input', 'volumes.index': 'action:volumes.index.input', 'volumes.refresh': 'action:volumes.refresh.input', 'volumes.remove_cloud': 'action:volumes.remove_cloud.input', From ff2d631a29ac26447891309762afba00fe054573 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 16 Jan 2026 08:26:00 -0800 Subject: [PATCH 3/4] refactor(logging): change info logs to debug level for improved log granularity This commit updates several logging statements across various modules, changing them from `info` to `debug` level. This adjustment aims to reduce log verbosity during normal operations while retaining detailed logs for debugging purposes. The changes enhance the overall logging strategy, allowing for better performance and easier troubleshooting without cluttering the log output in production environments. --- core/src/infra/daemon/rpc.rs | 2 +- core/src/infra/db/entities/device.rs | 14 ++-- core/src/infra/db/entities/entry.rs | 4 +- .../src/infra/db/entities/tag_relationship.rs | 4 +- core/src/infra/query/manager.rs | 2 +- core/src/infra/sync/registry.rs | 2 +- core/src/service/network/core/event_loop.rs | 3 - .../src/service/network/device/persistence.rs | 7 +- .../service/network/protocol/sync/handler.rs | 2 +- core/src/service/sync/backfill.rs | 50 +++++++++------ core/src/service/sync/mod.rs | 35 ++++++++++ core/src/service/sync/peer.rs | 25 ++++++-- core/src/volume/manager.rs | 21 ++++-- .../JobManager/components/CopyJobDetails.tsx | 47 ++++++-------- .../src/routes/overview/VolumeBar.tsx | 64 +++++++++---------- packages/interface/src/styles.css | 7 ++ packages/ts-client/src/generated/types.ts | 1 + 17 files changed, 173 insertions(+), 117 deletions(-) diff --git a/core/src/infra/daemon/rpc.rs b/core/src/infra/daemon/rpc.rs index 9294e2b08..c3b9a89bc 100644 --- a/core/src/infra/daemon/rpc.rs +++ b/core/src/infra/daemon/rpc.rs @@ -195,7 +195,7 @@ impl RpcServer { json_payload: serde_json::Value, core: &Arc, ) -> Result { - tracing::info!( + tracing::debug!( "[RPC Operation]: method={}, library_id={:?}", method, library_id diff --git a/core/src/infra/db/entities/device.rs b/core/src/infra/db/entities/device.rs index 9dd8a37bc..732b396c8 100644 --- a/core/src/infra/db/entities/device.rs +++ b/core/src/infra/db/entities/device.rs @@ -172,13 +172,13 @@ impl crate::infra::sync::Syncable for Model { data: serde_json::Value, db: &DatabaseConnection, ) -> Result<(), sea_orm::DbErr> { - tracing::info!("[DEVICE_SYNC] apply_state_change called"); + tracing::debug!("[DEVICE_SYNC] apply_state_change called"); // Deserialize incoming data let device: Model = serde_json::from_value(data) .map_err(|e| sea_orm::DbErr::Custom(format!("Device deserialization failed: {}", e)))?; - tracing::info!( + tracing::debug!( "[DEVICE_SYNC] Processing device: uuid={}, slug={}", device.uuid, device.slug @@ -195,14 +195,14 @@ impl crate::infra::sync::Syncable for Model { // Determine the slug to use let slug_to_use = if let Some(existing) = existing_device { // Device exists - keep its existing slug to avoid breaking references - tracing::info!( + tracing::debug!( "[DEVICE_SYNC] Device exists, keeping existing slug: {}", existing.slug ); existing.slug } else { // New device - check for slug collisions - tracing::info!("[DEVICE_SYNC] New device, checking for slug collisions"); + tracing::debug!("[DEVICE_SYNC] New device, checking for slug collisions"); let existing_slugs: Vec = Entity::find() .all(db) .await? @@ -210,7 +210,7 @@ impl crate::infra::sync::Syncable for Model { .map(|d| d.slug.clone()) .collect(); - tracing::info!( + tracing::debug!( "[DEVICE_SYNC] Existing slugs in database: {:?}", existing_slugs ); @@ -219,13 +219,13 @@ impl crate::infra::sync::Syncable for Model { crate::library::Library::ensure_unique_slug(&device.slug, &existing_slugs); if unique_slug != device.slug { - tracing::info!( + tracing::debug!( "[DEVICE_SYNC] Slug collision! Using '{}' instead of '{}'", unique_slug, device.slug ); } else { - tracing::info!("[DEVICE_SYNC] No collision, using slug: {}", unique_slug); + tracing::debug!("[DEVICE_SYNC] No collision, using slug: {}", unique_slug); } unique_slug diff --git a/core/src/infra/db/entities/entry.rs b/core/src/infra/db/entities/entry.rs index efca0f2b9..e931a47b1 100644 --- a/core/src/infra/db/entities/entry.rs +++ b/core/src/infra/db/entities/entry.rs @@ -719,7 +719,7 @@ impl Model { /// This is a safety measure to run after backfill or if the closure table /// becomes corrupted. Rebuilds all relationships from the parent_id links. pub async fn rebuild_all_entry_closures(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> { - tracing::info!("Starting bulk entry_closure rebuild..."); + tracing::debug!("Starting bulk entry_closure rebuild..."); // Clear existing closure table super::entry_closure::Entity::delete_many().exec(db).await?; @@ -781,7 +781,7 @@ impl Model { // Count final relationships let total = super::entry_closure::Entity::find().count(db).await?; - tracing::info!( + tracing::debug!( iterations = iteration, total_relationships = total, "Bulk entry_closure rebuild complete" diff --git a/core/src/infra/db/entities/tag_relationship.rs b/core/src/infra/db/entities/tag_relationship.rs index 01e37c621..58eb77af6 100644 --- a/core/src/infra/db/entities/tag_relationship.rs +++ b/core/src/infra/db/entities/tag_relationship.rs @@ -186,7 +186,7 @@ impl Syncable for Model { use super::tag_closure; use sea_orm::{ConnectionTrait, DbBackend, PaginatorTrait, Statement}; - tracing::info!("Starting tag_closure rebuild from tag_relationships..."); + tracing::debug!("Starting tag_closure rebuild from tag_relationships..."); // Clear existing tag_closure table tag_closure::Entity::delete_many().exec(db).await?; @@ -263,7 +263,7 @@ impl Syncable for Model { let total = tag_closure::Entity::find().count(db).await?; - tracing::info!( + tracing::debug!( iterations = iteration, total_relationships = total, "tag_closure rebuild complete" diff --git a/core/src/infra/query/manager.rs b/core/src/infra/query/manager.rs index 095fa9180..38d9ba637 100644 --- a/core/src/infra/query/manager.rs +++ b/core/src/infra/query/manager.rs @@ -73,7 +73,7 @@ impl QueryManager { ) -> QueryResult { let query_type = std::any::type_name::(); - tracing::info!( + tracing::debug!( query_type = query_type, library_id = %library_id, device_id = %session.auth.device_id, diff --git a/core/src/infra/sync/registry.rs b/core/src/infra/sync/registry.rs index a4c5a62bd..c22c64dd1 100644 --- a/core/src/infra/sync/registry.rs +++ b/core/src/infra/sync/registry.rs @@ -847,7 +847,7 @@ pub async fn run_post_backfill_rebuilds(db: Arc) -> Result<( }; for (model_type, rebuild_fn) in rebuild_fns { - tracing::info!(model = %model_type, "Running post-backfill rebuild"); + tracing::debug!(model = %model_type, "Running post-backfill rebuild"); rebuild_fn(db.clone()).await.map_err(|e| { ApplyError::DatabaseError(format!("{} rebuild failed: {}", model_type, e)) })?; diff --git a/core/src/service/network/core/event_loop.rs b/core/src/service/network/core/event_loop.rs index ce52b050a..c678c1e37 100644 --- a/core/src/service/network/core/event_loop.rs +++ b/core/src/service/network/core/event_loop.rs @@ -631,9 +631,6 @@ impl NetworkingEventLoop { // Spawn reconnection with a small delay to prevent immediate retry loops tokio::spawn(async move { - // Wait 2 seconds before attempting reconnection to avoid tight loop - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - crate::service::network::core::NetworkingService::attempt_device_reconnection( device_id, persisted_device, diff --git a/core/src/service/network/device/persistence.rs b/core/src/service/network/device/persistence.rs index 4b8eb538c..14b37647b 100644 --- a/core/src/service/network/device/persistence.rs +++ b/core/src/service/network/device/persistence.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; +use tracing::info; /// Persisted paired device data (plain data structure) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -107,7 +108,7 @@ impl DevicePersistence { /// Load paired devices from key manager (decrypt) pub async fn load_paired_devices(&self) -> Result> { let device_ids = self.get_device_list().await?; - tracing::info!("Loading {} device IDs from persistence", device_ids.len()); + tracing::debug!("Loading {} device IDs from persistence", device_ids.len()); let mut devices = HashMap::new(); for device_id in device_ids { @@ -149,7 +150,7 @@ impl DevicePersistence { } } - tracing::info!( + tracing::debug!( "Successfully loaded {} paired devices from persistence", devices.len() ); @@ -288,7 +289,7 @@ impl DevicePersistence { let should_reconnect = !is_expired && !is_blocked; // Debug logging - eprintln!( + info!( "[AUTO-RECONNECT] Device {}: trust={:?}, expired={}, blocked={}, include={}", device.device_info.device_name, device.trust_level, diff --git a/core/src/service/network/protocol/sync/handler.rs b/core/src/service/network/protocol/sync/handler.rs index cb2692acb..f63cb37d4 100644 --- a/core/src/service/network/protocol/sync/handler.rs +++ b/core/src/service/network/protocol/sync/handler.rs @@ -336,7 +336,7 @@ impl SyncProtocolHandler { None }; - info!( + debug!( count = entries.len(), has_more = has_more, has_state_snapshot = current_state.is_some(), diff --git a/core/src/service/sync/backfill.rs b/core/src/service/sync/backfill.rs index 3f063a5b8..dd94f9147 100644 --- a/core/src/service/sync/backfill.rs +++ b/core/src/service/sync/backfill.rs @@ -22,7 +22,7 @@ use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{oneshot, Mutex}; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use uuid::Uuid; /// Manages backfill process for new devices @@ -151,7 +151,7 @@ impl BackfillManager { let selected_peer = select_backfill_peer(available_peers.clone()).map_err(|e| anyhow::anyhow!("{}", e))?; - info!( + debug!( selected_peer = %selected_peer, "Selected backfill peer" ); @@ -195,11 +195,18 @@ impl BackfillManager { // Set state to Backfilling { + let old_state = self.peer_sync.state().await; let mut state = self.peer_sync.state.write().await; *state = DeviceSyncState::Backfilling { peer: selected_peer, progress: 0, }; + info!( + from_state = ?old_state, + to_state = ?DeviceSyncState::Backfilling { peer: selected_peer, progress: 0 }, + peer = %selected_peer, + "Sync state transition" + ); } // Phase 2: Backfill shared resources FIRST (entries depend on content_identities) @@ -214,7 +221,7 @@ impl BackfillManager { // Phase 3.5: Run post-backfill rebuilds via registry (polymorphic) // Models that registered post_backfill_rebuild will have their derived tables rebuilt // (e.g., entry_closure for entries, tag_closure for tag_relationships) - info!("Running post-backfill rebuilds via registry..."); + debug!("Running post-backfill rebuilds via registry..."); if let Err(e) = crate::infra::sync::registry::run_post_backfill_rebuilds(self.peer_sync.db().clone()) .await @@ -360,14 +367,14 @@ impl BackfillManager { primary_peer: Uuid, _since_watermark: Option>, // Deprecated: use per-resource watermarks ) -> Result> { - info!("Backfilling device-owned state with per-resource watermarks"); + debug!("Backfilling device-owned state with per-resource watermarks"); // Compute sync order based on model dependencies to prevent FK violations let sync_order = crate::infra::sync::compute_registry_sync_order() .await .map_err(|e| anyhow::anyhow!("Failed to compute sync order: {}", e))?; - info!( + debug!( sync_order = ?sync_order, "Computed dependency-ordered sync sequence" ); @@ -389,7 +396,7 @@ impl BackfillManager { .get_resource_watermark(primary_peer, &model_type) .await?; - info!( + debug!( model_type = %model_type, watermark = ?resource_watermark, "Backfilling resource type with per-resource watermark" @@ -405,7 +412,7 @@ impl BackfillManager { ) .await?; - info!( + debug!( model_type = %model_type, progress = checkpoint.progress, final_checkpoint = ?checkpoint.resume_token, @@ -420,7 +427,7 @@ impl BackfillManager { .update_resource_watermark(primary_peer, &model_type, max_ts) .await?; - info!( + debug!( model_type = %model_type, watermark = %max_ts, "Updated resource watermark from received data" @@ -428,9 +435,9 @@ impl BackfillManager { } else { // No data received - watermark MUST NOT advance! // If we advanced it, we'd filter out unsynced data permanently - info!( + debug!( model_type = %model_type, - "No data received, watermark unchanged (prevents data loss)" + "No data received, watermark unchanged" ); } @@ -438,7 +445,7 @@ impl BackfillManager { final_checkpoint = checkpoint.resume_token; } - info!("Device-owned state backfill complete (all resource types)"); + debug!("Device-owned state backfill complete"); // Return the final checkpoint for legacy watermark update Ok(final_checkpoint) @@ -465,7 +472,7 @@ impl BackfillManager { continue; // Already done } - info!( + debug!( peer = %peer, model_type = %model_type, "Backfilling model type" @@ -607,7 +614,7 @@ impl BackfillManager { // Add failed records to dependency tracker if !result.failed.is_empty() { - tracing::info!( + tracing::warn!( model_type = %model_type, failed_count = result.failed.len(), "Records have missing FK dependencies - adding to dependency tracker for retry" @@ -682,7 +689,7 @@ impl BackfillManager { self.peer_sync.dependency_tracker().resolve(uuid).await; if !waiting_updates.is_empty() { - tracing::info!( + tracing::debug!( resolved_uuid = %uuid, model_type = %model_type, waiting_count = waiting_updates.len(), @@ -768,7 +775,8 @@ impl BackfillManager { if let Some(hlc) = since_hlc { info!("Backfilling shared resources incrementally since {:?}", hlc); } else { - info!("Backfilling shared resources (full)"); + // NOTE: we keep hitting this almost always, I don't think I've ever seen the above log. This concerns me greatly. + info!("Backfilling all shared resources"); } // Request shared changes from peer in batches (can be 100k+ records) @@ -822,7 +830,7 @@ impl BackfillManager { }; if let Some(records_array) = records_value.as_array() { - info!( + debug!( model_type = %model_type, count = records_array.len(), "Applying current state snapshot for pre-sync data" @@ -1066,7 +1074,7 @@ impl BackfillManager { "Failed to send ACK for shared changes (pruning may be delayed)" ); } else { - info!( + debug!( peer = %peer, hlc = %up_to_hlc, batch_size = batch_size, @@ -1093,14 +1101,14 @@ impl BackfillManager { // Log progress every 10,000 records for large backfills if total_applied >= last_progress_log + 10_000 { - info!( + debug!( total_applied = total_applied, batch_size = batch_size, "Backfilling shared resources - progress update" ); last_progress_log = total_applied; } else { - info!( + debug!( "Applied {} shared changes (total: {})", batch_size, total_applied ); @@ -1120,7 +1128,7 @@ impl BackfillManager { } } - info!( + debug!( "Shared resources backfill complete (total: {} entries)", total_applied ); @@ -1140,7 +1148,7 @@ impl BackfillManager { // - Any peer entries created after now will have higher timestamp regardless of device_id if last_hlc.is_none() && received_any_data { let watermark_hlc = self.peer_sync.hlc_generator().lock().await.next(); - info!( + debug!( watermark_hlc = %watermark_hlc, "Snapshot-only sync (no peer log entries), using current time as watermark" ); diff --git a/core/src/service/sync/mod.rs b/core/src/service/sync/mod.rs index 4c70fcedb..4cbe7b741 100644 --- a/core/src/service/sync/mod.rs +++ b/core/src/service/sync/mod.rs @@ -364,8 +364,15 @@ impl SyncService { Err(e) => { warn!("Automatic backfill failed: {}", e); // Reset state to Uninitialized so retry logic runs + let old_state = peer_sync.state().await; let mut state = peer_sync.state.write().await; *state = DeviceSyncState::Uninitialized; + info!( + from_state = ?old_state, + to_state = ?DeviceSyncState::Uninitialized, + reason = "backfill_failed", + "Sync state transition" + ); // Reset flag to retry on next loop backfill_attempted = false; } @@ -432,8 +439,15 @@ impl SyncService { retry_state.record_success(); // Reset retry state // Transition to Uninitialized to trigger full backfill + let old_state = peer_sync.state().await; let mut state = peer_sync.state.write().await; *state = DeviceSyncState::Uninitialized; + info!( + from_state = ?old_state, + to_state = ?DeviceSyncState::Uninitialized, + reason = "too_many_catchup_failures", + "Sync state transition" + ); backfill_attempted = false; // Allow backfill to run again continue; // Skip to next iteration } @@ -452,8 +466,15 @@ impl SyncService { // Transition to CatchingUp state { + let old_state = peer_sync.state().await; let mut state = peer_sync.state.write().await; *state = DeviceSyncState::CatchingUp { buffered_count: 0 }; + info!( + from_state = ?old_state, + to_state = ?DeviceSyncState::CatchingUp { buffered_count: 0 }, + reason = "incremental_catchup", + "Sync state transition" + ); } // Perform incremental catch-up using watermarks @@ -469,15 +490,29 @@ impl SyncService { info!("Incremental catch-up completed"); retry_state.record_success(); // Transition back to Ready + let old_state = peer_sync.state().await; let mut state = peer_sync.state.write().await; *state = DeviceSyncState::Ready; + info!( + from_state = ?old_state, + to_state = ?DeviceSyncState::Ready, + reason = "catchup_completed", + "Sync state transition" + ); } Err(e) => { warn!("Incremental catch-up failed: {}", e); retry_state.record_failure(); // Transition back to Ready even on error + let old_state = peer_sync.state().await; let mut state = peer_sync.state.write().await; *state = DeviceSyncState::Ready; + info!( + from_state = ?old_state, + to_state = ?DeviceSyncState::Ready, + reason = "catchup_failed_but_continuing", + "Sync state transition" + ); } } } diff --git a/core/src/service/sync/peer.rs b/core/src/service/sync/peer.rs index efbca1bc5..9ce7661d3 100644 --- a/core/src/service/sync/peer.rs +++ b/core/src/service/sync/peer.rs @@ -426,7 +426,7 @@ impl PeerSync { .await .map_err(|e| anyhow::anyhow!("Failed to persist peer watermark: {}", e))?; - info!( + debug!( peer = %peer, max_received_hlc = %hlc, "Persisted shared watermark for peer" @@ -2674,7 +2674,7 @@ impl PeerSync { } } - info!( + debug!( count = all_records.len(), "Retrieved device state records for backfill" ); @@ -2741,7 +2741,7 @@ impl PeerSync { // Truncate to limit entries.truncate(limit); - info!( + debug!( count = entries.len(), has_more = has_more, "Retrieved shared changes from peer log" @@ -2818,11 +2818,16 @@ impl PeerSync { } // Set to catching up + let buffered_count = self.buffer.len().await + dep_stats.total_waiting_updates; { let mut state = self.state.write().await; - *state = DeviceSyncState::CatchingUp { - buffered_count: self.buffer.len().await + dep_stats.total_waiting_updates, - }; + *state = DeviceSyncState::CatchingUp { buffered_count }; + info!( + from_state = ?current_state, + to_state = ?DeviceSyncState::CatchingUp { buffered_count }, + buffered_count = buffered_count, + "Sync state transition" + ); } // Record state transition @@ -2912,7 +2917,7 @@ impl PeerSync { } } - info!( + debug!( state_changes = state_changes_to_broadcast.len(), shared_changes = shared_changes_to_broadcast.len(), "Processing buffered updates - will broadcast to peers after local application" @@ -2975,9 +2980,15 @@ impl PeerSync { } // Now ready! + let current_state_before_ready = self.state().await; { let mut state = self.state.write().await; *state = DeviceSyncState::Ready; + info!( + from_state = ?current_state_before_ready, + to_state = ?DeviceSyncState::Ready, + "Sync state transition" + ); } // Record state transition diff --git a/core/src/volume/manager.rs b/core/src/volume/manager.rs index 07a51f5aa..c36345f8a 100644 --- a/core/src/volume/manager.rs +++ b/core/src/volume/manager.rs @@ -634,7 +634,7 @@ impl VolumeManager { } // Query database for tracked volumes to merge metadata - let mut tracked_volumes_map: HashMap)> = + let mut tracked_volumes_map: HashMap, Option, Option)> = HashMap::new(); if let Some(lib_mgr) = library_manager.read().await.as_ref() { if let Some(lib_mgr) = lib_mgr.upgrade() { @@ -658,10 +658,17 @@ impl VolumeManager { ); for db_vol in tracked_vols { let fingerprint = VolumeFingerprint(db_vol.fingerprint.clone()); - debug!("DB_MERGE: Found tracked volume - fingerprint: {}, display_name: {:?}", - fingerprint.short_id(), db_vol.display_name); - tracked_volumes_map - .insert(fingerprint, (library.id(), db_vol.display_name)); + debug!("DB_MERGE: Found tracked volume - fingerprint: {}, display_name: {:?}, read_speed: {:?}, write_speed: {:?}", + fingerprint.short_id(), db_vol.display_name, db_vol.read_speed_mbps, db_vol.write_speed_mbps); + tracked_volumes_map.insert( + fingerprint, + ( + library.id(), + db_vol.display_name, + db_vol.read_speed_mbps.map(|s| s as u64), + db_vol.write_speed_mbps.map(|s| s as u64), + ), + ); } } else { debug!( @@ -689,10 +696,12 @@ impl VolumeManager { seen_fingerprints.insert(fingerprint.clone()); // Merge tracked volume metadata from database - if let Some((library_id, display_name)) = tracked_volumes_map.get(&fingerprint) { + if let Some((library_id, display_name, read_speed, write_speed)) = tracked_volumes_map.get(&fingerprint) { detected.is_tracked = true; detected.library_id = Some(*library_id); detected.display_name = display_name.clone(); + detected.read_speed_mbps = *read_speed; + detected.write_speed_mbps = *write_speed; } debug!( diff --git a/packages/interface/src/components/JobManager/components/CopyJobDetails.tsx b/packages/interface/src/components/JobManager/components/CopyJobDetails.tsx index d3239084d..f38da0449 100644 --- a/packages/interface/src/components/JobManager/components/CopyJobDetails.tsx +++ b/packages/interface/src/components/JobManager/components/CopyJobDetails.tsx @@ -17,6 +17,9 @@ interface CopyJobDetailsProps { export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) { const generic = job.generic_progress; const scrollContainerRef = useRef(null); + const prevScrollIndexRef = useRef(-1); + const prevCompletedRef = useRef(0); + const prevCurrentPathRef = useRef(""); // Fetch copy metadata (file queue with File objects) const { data: metadata, refetch } = useLibraryQuery({ @@ -24,9 +27,10 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) { input: { job_id: job.id }, }); + const files = metadata?.metadata?.files || []; + const fileObjects = metadata?.metadata?.file_objects || []; + // Refetch when completed count changes OR when current file changes - const prevCompletedRef = useRef(0); - const prevCurrentPathRef = useRef(""); useEffect(() => { const currentCompleted = generic?.completion?.completed || 0; const currentPath = generic?.current_path?.Physical?.path || @@ -43,42 +47,19 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) { } }, [generic?.completion?.completed, generic?.current_path, generic?.message, refetch]); - if (!generic) { - return ( -
- No progress data available -
- ); - } - - const files = metadata?.metadata?.files || []; - const fileObjects = metadata?.metadata?.file_objects || []; - - // Create a map of entry_id → File for quick lookup - const fileMap = new Map(); - fileObjects.forEach(file => { - fileMap.set(file.id, file); - }); - // Auto-scroll to center the currently copying file - // Match files by current_path from progress, not by "copying" status (which is too fast to catch) - const prevScrollIndexRef = useRef(-1); - useEffect(() => { const container = scrollContainerRef.current; if (!container || !generic?.current_path) return; - // Extract current file path from progress const currentPath = generic.current_path.Physical?.path || generic.current_path.Local?.path; if (!currentPath) return; - // Find the file in our list that matches the current path const currentIndex = files.findIndex(f => { const filePath = f.source_path?.Physical?.path || f.source_path?.Local?.path; return filePath === currentPath; }); - // Only scroll if the file index actually changed if (currentIndex === -1 || currentIndex === prevScrollIndexRef.current) return; prevScrollIndexRef.current = currentIndex; @@ -86,11 +67,9 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) { const currentElement = container.children[currentIndex] as HTMLElement; if (!currentElement) return; - // Manually calculate and scroll the container const containerRect = container.getBoundingClientRect(); const elementRect = currentElement.getBoundingClientRect(); - // Calculate how much to scroll to center the element const elementCenter = elementRect.top + elementRect.height / 2; const containerCenter = containerRect.top + containerRect.height / 2; const scrollOffset = elementCenter - containerCenter; @@ -101,6 +80,20 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) { }); }, [files, generic?.current_path]); + if (!generic) { + return ( +
+ No progress data available +
+ ); + } + + // Create a map of entry_id → File for quick lookup + const fileMap = new Map(); + fileObjects.forEach(file => { + fileMap.set(file.id, file); + }); + return (
{/* Speed graph */} diff --git a/packages/interface/src/routes/overview/VolumeBar.tsx b/packages/interface/src/routes/overview/VolumeBar.tsx index cfbf7d9dc..834d99b14 100644 --- a/packages/interface/src/routes/overview/VolumeBar.tsx +++ b/packages/interface/src/routes/overview/VolumeBar.tsx @@ -1,4 +1,4 @@ -import {DotsThree, EyeSlash} from '@phosphor-icons/react'; +import {ArrowDown, ArrowUp, DotsThree, EyeSlash} from '@phosphor-icons/react'; import DatabaseIcon from '@sd/assets/icons/Database.png'; import DriveAmazonS3Icon from '@sd/assets/icons/Drive-AmazonS3.png'; import DriveDropboxIcon from '@sd/assets/icons/Drive-Dropbox.png'; @@ -6,8 +6,8 @@ import DriveGoogleDriveIcon from '@sd/assets/icons/Drive-GoogleDrive.png'; import DriveIcon from '@sd/assets/icons/Drive.png'; import HDDIcon from '@sd/assets/icons/HDD.png'; import ServerIcon from '@sd/assets/icons/Server.png'; +import type {Device, Volume} from '@sd/ts-client'; import {TopBarButton} from '@sd/ui'; -import type {Device, VolumeItem} from '@sd/ts-client'; import {motion} from 'framer-motion'; import {useEffect, useState} from 'react'; import {useVolumeContextMenu} from '../../components/SpacesSidebar/hooks/useVolumeContextMenu'; @@ -24,7 +24,7 @@ function getDiskTypeLabel(diskType: string): string { } interface VolumeBarProps { - volume: VolumeItem; + volume: Volume; index: number; } @@ -189,7 +189,7 @@ export function VolumeBar({volume, index}: VolumeBarProps) { typeof volume.volume_type === 'string' ? volume.volume_type : (volume.volume_type as any)?.Other || - JSON.stringify(volume.volume_type) + JSON.stringify(volume.volume_type) ); return ( @@ -211,15 +211,10 @@ export function VolumeBar({volume, index}: VolumeBarProps) { {/* Name, actions, and badges */}
-
+
{volume.display_name || volume.name} - {!volume.is_mounted && ( - - Offline - - )} {!volume.is_tracked && ( {fileSystem && ( - - {fileSystem} - - )} - {diskType && ( - + + {fileSystem} + + )} + {diskType && ( + {getDiskTypeLabel(diskType)} - - )} - {volumeTypeStr && ( - - {volumeTypeStr} - - )} + + )} + {volumeTypeStr && ( + + {volumeTypeStr} + + )} {indexingProgress ? ( {indexingProgress.filesIndexed.toLocaleString()}{' '} @@ -268,9 +263,9 @@ export function VolumeBar({volume, index}: VolumeBarProps) { )} ) : ( - volume.total_file_count != null && ( + volume.total_files != null && ( - {volume.total_file_count.toLocaleString()}{' '} + {volume.total_files.toLocaleString()}{' '} files ) @@ -287,19 +282,18 @@ export function VolumeBar({volume, index}: VolumeBarProps) { {formatBytes(availableBytes)} free
{(volume.read_speed_mbps || volume.write_speed_mbps) && ( -
+
{volume.read_speed_mbps && ( - <> - {volume.read_speed_mbps}MB/s read - - )} - {volume.read_speed_mbps && volume.write_speed_mbps && ( - + + + {volume.read_speed_mbps}MB/s + )} {volume.write_speed_mbps && ( - <> - {volume.write_speed_mbps}MB/s write - + + + {volume.write_speed_mbps}MB/s + )}
)} diff --git a/packages/interface/src/styles.css b/packages/interface/src/styles.css index df9cf6cd5..109a9ae4f 100644 --- a/packages/interface/src/styles.css +++ b/packages/interface/src/styles.css @@ -1,5 +1,12 @@ @import "@sd/ui/style/colors.scss"; +/* Prevent overscroll/rubber-band effect on macOS */ +html, +body { + overscroll-behavior: none; + overflow: hidden; +} + /* Top bar blur effect (macOS specific) */ .top-bar-blur { backdrop-filter: saturate(120%) blur(18px); diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index d486406ed..8e803c0e8 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -4127,6 +4127,7 @@ available_space: number; is_read_only: boolean; /** * Whether volume is currently mounted/available + * Also deserializes from legacy "is_online" field for backwards compatibility */ is_mounted: boolean; /** From 6b6eef707089b0b35465de64b592646a64035a77 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 16 Jan 2026 13:45:39 -0800 Subject: [PATCH 4/4] refactor(volume): enhance VolumeBar layout and styling for improved UI consistency This commit refines the layout and styling of the VolumeBar component, ensuring fixed heights for various elements to maintain a consistent appearance. Adjustments include modifying the height of the top row, badges, and capacity info sections, as well as updating the icon size for better visual alignment. The three dots button for volume actions is repositioned to the far right, enhancing usability. These changes collectively improve the user interface and experience within the volume management system. --- .../src/routes/overview/VolumeBar.tsx | 69 +++++++++---------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/packages/interface/src/routes/overview/VolumeBar.tsx b/packages/interface/src/routes/overview/VolumeBar.tsx index 834d99b14..ee0b9bf40 100644 --- a/packages/interface/src/routes/overview/VolumeBar.tsx +++ b/packages/interface/src/routes/overview/VolumeBar.tsx @@ -200,18 +200,18 @@ export function VolumeBar({volume, index}: VolumeBarProps) { className="bg-app-box border-app-line/50 overflow-hidden rounded-lg border" onContextMenu={contextMenu.show} > - {/* Top row: Info */} -
+ {/* Top row: Info - fixed height */} +
{/* Icon */} {volumeTypeStr} {/* Name, actions, and badges */}
-
+
{volume.display_name || volume.name} @@ -222,21 +222,10 @@ export function VolumeBar({volume, index}: VolumeBarProps) { className="text-ink-faint/50" /> )} - {currentDevice && - volume.device_id === currentDevice.id && ( - { - e.stopPropagation(); - contextMenu.show(e); - }} - title="Volume actions" - /> - )}
- {/* Badges under name */} -
+ {/* Badges under name - fixed height */} +
{fileSystem && ( {fileSystem} @@ -273,35 +262,43 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
- {/* Capacity info */} -
+ {/* Capacity info - fixed 3-row layout */} +
{formatBytes(totalCapacity)}
{formatBytes(availableBytes)} free
- {(volume.read_speed_mbps || volume.write_speed_mbps) && ( -
- {volume.read_speed_mbps && ( - - - {volume.read_speed_mbps}MB/s - - )} - {volume.write_speed_mbps && ( - - - {volume.write_speed_mbps}MB/s - - )} -
- )} +
+ {volume.read_speed_mbps && ( + + + {volume.read_speed_mbps}MB/s + + )} + {volume.write_speed_mbps && ( + + + {volume.write_speed_mbps}MB/s + + )} +
+ + {/* Three dots button - far right */} + { + e.stopPropagation(); + contextMenu.show(e); + }} + title="Volume actions" + />
{/* Bottom: Full-width capacity bar with padding */} -
+
{/* Base capacity visualization */}