mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-24 08:22:10 -04:00
Merge pull request #2977 from spacedriveapp/stable-volume-fingerprint
Stable volume fingerprint
This commit is contained in:
@@ -180,16 +180,13 @@ async fn run_interactive_add(ctx: &Context) -> Result<LocationAddInput> {
|
||||
let volume_choices: Vec<String> = 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<LocationAddInput> {
|
||||
|
||||
// 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<LocationAddInput> {
|
||||
.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
|
||||
|
||||
@@ -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!();
|
||||
}
|
||||
}
|
||||
|
||||
74
core/examples/debug_volumes.rs
Normal file
74
core/examples/debug_volumes.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:");
|
||||
@@ -308,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.)
|
||||
|
||||
@@ -195,7 +195,7 @@ impl RpcServer {
|
||||
json_payload: serde_json::Value,
|
||||
core: &Arc<crate::Core>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"[RPC Operation]: method={}, library_id={:?}",
|
||||
method,
|
||||
library_id
|
||||
|
||||
@@ -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<String> = 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -73,7 +73,7 @@ impl QueryManager {
|
||||
) -> QueryResult<Q::Output> {
|
||||
let query_type = std::any::type_name::<Q>();
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
query_type = query_type,
|
||||
library_id = %library_id,
|
||||
device_id = %session.auth.device_id,
|
||||
|
||||
@@ -847,7 +847,7 @@ pub async fn run_post_backfill_rebuilds(db: Arc<DatabaseConnection>) -> 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))
|
||||
})?;
|
||||
|
||||
@@ -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<dyn crate::volume::VolumeBackend> = Arc::new(backend);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
145
core/src/ops/volumes/eject/action.rs
Normal file
145
core/src/ops/volumes/eject/action.rs
Normal file
@@ -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<Self, String> {
|
||||
Ok(VolumeEjectAction::new(input))
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
self,
|
||||
_library: Arc<crate::library::Library>,
|
||||
context: Arc<CoreContext>,
|
||||
) -> Result<Self::Output, ActionError> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
// 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<String, String> {
|
||||
Err("Volume ejection is not supported on this platform".to_string())
|
||||
}
|
||||
8
core/src/ops/volumes/eject/input.rs
Normal file
8
core/src/ops/volumes/eject/input.rs
Normal file
@@ -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,
|
||||
}
|
||||
7
core/src/ops/volumes/eject/mod.rs
Normal file
7
core/src/ops/volumes/eject/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod action;
|
||||
mod input;
|
||||
mod output;
|
||||
|
||||
pub use action::VolumeEjectAction;
|
||||
pub use input::VolumeEjectInput;
|
||||
pub use output::VolumeEjectOutput;
|
||||
12
core/src/ops/volumes/eject/output.rs
Normal file
12
core/src/ops/volumes/eject/output.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
@@ -65,6 +65,39 @@ 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))
|
||||
})?;
|
||||
|
||||
// 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)
|
||||
.map_err(|e| {
|
||||
ActionError::Internal(format!("Failed to emit volume event: {}", e))
|
||||
})?;
|
||||
|
||||
// Return native output directly
|
||||
Ok(VolumeSpeedTestOutput::new(
|
||||
self.input.fingerprint,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HashMap<Uuid, PersistedPairedDevice>> {
|
||||
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,
|
||||
|
||||
@@ -336,7 +336,7 @@ impl SyncProtocolHandler {
|
||||
None
|
||||
};
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
count = entries.len(),
|
||||
has_more = has_more,
|
||||
has_state_snapshot = current_state.is_some(),
|
||||
|
||||
@@ -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<chrono::DateTime<chrono::Utc>>, // Deprecated: use per-resource watermarks
|
||||
) -> Result<Option<String>> {
|
||||
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"
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -82,6 +82,7 @@ pub struct VolumeManager {
|
||||
|
||||
/// Weak reference to library manager for database operations
|
||||
library_manager: Arc<RwLock<Option<Weak<LibraryManager>>>>,
|
||||
|
||||
}
|
||||
|
||||
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<Self>) -> 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<Self>) -> 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<EventBus>,
|
||||
config: &VolumeDetectionConfig,
|
||||
library_manager: &RwLock<Option<Weak<LibraryManager>>>,
|
||||
manager: Option<Arc<VolumeManager>>,
|
||||
) -> 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!(
|
||||
@@ -628,7 +634,7 @@ impl VolumeManager {
|
||||
}
|
||||
|
||||
// Query database for tracked volumes to merge metadata
|
||||
let mut tracked_volumes_map: HashMap<VolumeFingerprint, (Uuid, Option<String>)> =
|
||||
let mut tracked_volumes_map: HashMap<VolumeFingerprint, (Uuid, Option<String>, Option<u64>, Option<u64>)> =
|
||||
HashMap::new();
|
||||
if let Some(lib_mgr) = library_manager.read().await.as_ref() {
|
||||
if let Some(lib_mgr) = lib_mgr.upgrade() {
|
||||
@@ -652,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!(
|
||||
@@ -683,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!(
|
||||
@@ -725,6 +740,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 +784,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 +1354,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 +2056,7 @@ impl VolumeManager {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Statistics about detected volumes
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u64> {
|
||||
@@ -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<Uuid>,
|
||||
) -> Option<Uuid> {
|
||||
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::<SpacedriveVolumeId>(&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<Uuid>,
|
||||
) -> Option<Uuid> {
|
||||
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::<SpacedriveVolumeId>(&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::*;
|
||||
|
||||
@@ -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 (
|
||||
<TopBarProvider>
|
||||
<SelectionProvider>
|
||||
<ExplorerProvider>
|
||||
{/* Sync tab navigation and defaults with router */}
|
||||
<TabNavigationSync />
|
||||
<TabDefaultsSync />
|
||||
<ShellLayoutContent />
|
||||
</ExplorerProvider>
|
||||
</SelectionProvider>
|
||||
</TopBarProvider>
|
||||
<JobsProvider>
|
||||
<TopBarProvider>
|
||||
<SelectionProvider>
|
||||
<ExplorerProvider>
|
||||
{/* Sync tab navigation and defaults with router */}
|
||||
<TabNavigationSync />
|
||||
<TabDefaultsSync />
|
||||
<ShellLayoutContent />
|
||||
</ExplorerProvider>
|
||||
</SelectionProvider>
|
||||
</TopBarProvider>
|
||||
</JobsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,9 @@ interface CopyJobDetailsProps {
|
||||
export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) {
|
||||
const generic = job.generic_progress;
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const prevScrollIndexRef = useRef<number>(-1);
|
||||
const prevCompletedRef = useRef<number>(0);
|
||||
const prevCurrentPathRef = useRef<string>("");
|
||||
|
||||
// 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<number>(0);
|
||||
const prevCurrentPathRef = useRef<string>("");
|
||||
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 (
|
||||
<div className="p-4 text-xs text-ink-faint">
|
||||
No progress data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, File>();
|
||||
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<number>(-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 (
|
||||
<div className="p-4 text-xs text-ink-faint">
|
||||
No progress data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create a map of entry_id → File for quick lookup
|
||||
const fileMap = new Map<string, File>();
|
||||
fileObjects.forEach(file => {
|
||||
fileMap.set(file.id, file);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Speed graph */}
|
||||
|
||||
@@ -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<void>;
|
||||
resume: (jobId: string) => Promise<void>;
|
||||
cancel: (jobId: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
error: any;
|
||||
getSpeedHistory: (jobId: string) => SpeedSample[];
|
||||
}
|
||||
|
||||
const JobsContext = createContext<JobsContextValue | null>(null);
|
||||
|
||||
export function JobsProvider({ children }: { children: ReactNode }) {
|
||||
const jobsData = useJobs();
|
||||
|
||||
return (
|
||||
<JobsContext.Provider value={jobsData}>
|
||||
{children}
|
||||
</JobsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useJobsContext() {
|
||||
const context = useContext(JobsContext);
|
||||
if (!context) {
|
||||
throw new Error('useJobsContext must be used within JobsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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 && (
|
||||
<EyeSlash
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="text-ink-faint/50"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Component for individual volume items with context menu
|
||||
function VolumeItem({volume, index, volumesLength}: {volume: Volume; index: number; volumesLength: number}) {
|
||||
const contextMenu = useVolumeContextMenu({volume});
|
||||
|
||||
return (
|
||||
<SpaceItem
|
||||
key={volume.id}
|
||||
item={
|
||||
{
|
||||
id: volume.id,
|
||||
item_type: {
|
||||
Volume: {
|
||||
volume_id: volume.id,
|
||||
name: volume.display_name || volume.name
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
volumeData={{
|
||||
device_slug: volume.device_id,
|
||||
mount_path: volume.mount_point || '/'
|
||||
}}
|
||||
rightComponent={getVolumeIndicator(volume)}
|
||||
customIcon={getVolumeIcon(volume)}
|
||||
allowInsertion={false}
|
||||
isLastItem={index === volumesLength - 1}
|
||||
onContextMenu={contextMenu.show}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<Plugs size={14} weight="bold" className="text-ink-faint" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GroupHeader
|
||||
@@ -54,34 +88,16 @@ export function VolumesGroup({
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5">
|
||||
{volumes.length === 0 ? (
|
||||
<div className="px-2 py-1 text-xs text-ink-faint">
|
||||
<div className="text-ink-faint px-2 py-1 text-xs">
|
||||
No volumes
|
||||
</div>
|
||||
) : (
|
||||
volumes.map((volume, index) => (
|
||||
<SpaceItem
|
||||
<VolumeItem
|
||||
key={volume.id}
|
||||
item={
|
||||
{
|
||||
id: volume.id,
|
||||
item_type: {
|
||||
Volume: {
|
||||
volume_id: volume.id,
|
||||
name:
|
||||
volume.display_name ||
|
||||
volume.name,
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
volumeData={{
|
||||
device_slug: volume.device_slug,
|
||||
mount_path: volume.mount_point || "/",
|
||||
}}
|
||||
rightComponent={getVolumeIndicator(volume)}
|
||||
customIcon={getVolumeIcon(volume)}
|
||||
allowInsertion={false}
|
||||
isLastItem={index === volumes.length - 1}
|
||||
volume={volume}
|
||||
index={index}
|
||||
volumesLength={volumes.length}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
* - Eject Volume: Safely eject removable media
|
||||
*/
|
||||
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 ejectVolume = useLibraryMutation('volumes.eject');
|
||||
|
||||
const isRemovable = volume.mount_type === 'External';
|
||||
|
||||
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
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
];
|
||||
|
||||
return useContextMenu({ items });
|
||||
}
|
||||
@@ -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<void>;
|
||||
resume: (jobId: string) => Promise<void>;
|
||||
cancel: (jobId: string) => Promise<void>;
|
||||
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] }}
|
||||
>
|
||||
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} />
|
||||
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} getSpeedHistory={getSpeedHistory} />
|
||||
</motion.div>
|
||||
)}
|
||||
</Popover>
|
||||
@@ -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) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SyncButton />
|
||||
<JobsButton
|
||||
<JobsButton
|
||||
activeJobCount={activeJobCount}
|
||||
hasRunningJobs={hasRunningJobs}
|
||||
jobs={jobs}
|
||||
pause={pause}
|
||||
resume={resume}
|
||||
cancel={cancel}
|
||||
getSpeedHistory={getSpeedHistory}
|
||||
navigate={navigate}
|
||||
/>
|
||||
<TopBarButton
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 && (
|
||||
<VolumeSizeBar
|
||||
totalBytes={Number(volumeData.total_capacity)}
|
||||
availableBytes={Number(volumeData.available_capacity)}
|
||||
availableBytes={Number(volumeData.available_space)}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ import clsx from 'clsx';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import Masonry from 'react-masonry-css';
|
||||
import {JobCard} from '../../components/JobManager/components/JobCard';
|
||||
import {useJobs} from '../../components/JobManager/hooks/useJobs';
|
||||
import {useJobsContext} from '../../components/JobManager/hooks/JobsContext';
|
||||
import {
|
||||
getDeviceIcon,
|
||||
useCoreQuery,
|
||||
@@ -107,7 +107,7 @@ export function DevicePanel({onLocationSelect}: DevicePanelProps = {}) {
|
||||
});
|
||||
|
||||
// Get all jobs with real-time updates (local jobs)
|
||||
const {jobs: localJobs} = useJobs();
|
||||
const {jobs: localJobs} = useJobsContext();
|
||||
|
||||
// Get remote device jobs
|
||||
// TODO: This should have its own hook like useJobs, this will not work reactively
|
||||
@@ -311,7 +311,7 @@ function DeviceCard({
|
||||
}: DeviceCardProps) {
|
||||
const deviceName = device?.name || 'Unknown Device';
|
||||
const deviceIconSrc = device ? getDeviceIcon(device) : null;
|
||||
const {pause, resume, getSpeedHistory} = useJobs();
|
||||
const {pause, resume, cancel, getSpeedHistory} = useJobsContext();
|
||||
// Format hardware specs
|
||||
const cpuInfo = device?.cpu_model
|
||||
? `${device.cpu_model}${device.cpu_cores_physical ? ` <20> ${device.cpu_cores_physical}C` : ''}`
|
||||
@@ -430,6 +430,7 @@ function DeviceCard({
|
||||
job={job}
|
||||
onPause={pause}
|
||||
onResume={resume}
|
||||
onCancel={cancel}
|
||||
getSpeedHistory={getSpeedHistory}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Database, Plus} 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,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 type {Device, VolumeItem} from '@sd/ts-client';
|
||||
import type {Device, Volume} from '@sd/ts-client';
|
||||
import {TopBarButton} from '@sd/ui';
|
||||
import {motion} from 'framer-motion';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useVolumeContextMenu} from '../../components/SpacesSidebar/hooks/useVolumeContextMenu';
|
||||
import {
|
||||
useLibraryMutation,
|
||||
useNormalizedQuery,
|
||||
@@ -22,7 +24,7 @@ function getDiskTypeLabel(diskType: string): string {
|
||||
}
|
||||
|
||||
interface VolumeBarProps {
|
||||
volume: VolumeItem;
|
||||
volume: Volume;
|
||||
index: number;
|
||||
}
|
||||
|
||||
@@ -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<IndexingProgress | null>(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;
|
||||
}
|
||||
@@ -209,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 (
|
||||
@@ -218,76 +198,49 @@ 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 */}
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
{/* Top row: Info - fixed height */}
|
||||
<div className="flex h-[64px] items-center gap-3 px-3">
|
||||
{/* Icon */}
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt={volumeTypeStr}
|
||||
className="size-6 flex-shrink-0 opacity-80"
|
||||
className="size-10 flex-shrink-0 opacity-80"
|
||||
/>
|
||||
|
||||
{/* Name, actions, and badges */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="text-ink truncate text-sm font-semibold">
|
||||
{volume.display_name || volume.name}
|
||||
</span>
|
||||
{!volume.is_mounted && (
|
||||
<span className="bg-app-box text-ink-faint border-app-line rounded border px-1.5 py-0.5 text-[10px]">
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
{!volume.is_tracked && (
|
||||
<button
|
||||
onClick={handleTrack}
|
||||
disabled={trackVolume.isPending}
|
||||
className="bg-accent/10 hover:bg-accent/20 text-accent border-accent/20 hover:border-accent/30 flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] transition-colors disabled:opacity-50"
|
||||
title="Track this volume"
|
||||
>
|
||||
<Plus className="size-2.5" weight="bold" />
|
||||
{trackVolume.isPending
|
||||
? 'Tracking...'
|
||||
: 'Track'}
|
||||
</button>
|
||||
<EyeSlash
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="text-ink-faint/50"
|
||||
/>
|
||||
)}
|
||||
{currentDevice &&
|
||||
volume.device_id === currentDevice.id && (
|
||||
<button
|
||||
onClick={handleIndex}
|
||||
disabled={indexVolume.isPending}
|
||||
className="bg-sidebar-box hover:bg-sidebar-selected text-sidebar-ink border-sidebar-line flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] transition-colors disabled:opacity-50"
|
||||
title="Index this volume"
|
||||
>
|
||||
<Database
|
||||
className="size-2.5"
|
||||
weight="bold"
|
||||
/>
|
||||
{indexVolume.isPending
|
||||
? 'Indexing...'
|
||||
: 'Index'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badges under name */}
|
||||
<div className="text-ink-dull flex flex-wrap items-center gap-1.5 text-[10px]">
|
||||
{/* Badges under name - fixed height */}
|
||||
<div className="text-ink-dull flex h-[18px] items-center gap-1.5 text-[10px]">
|
||||
{fileSystem && (
|
||||
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
|
||||
{fileSystem}
|
||||
</span>
|
||||
)}
|
||||
{diskType && (
|
||||
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
|
||||
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
|
||||
{fileSystem}
|
||||
</span>
|
||||
)}
|
||||
{diskType && (
|
||||
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
|
||||
{getDiskTypeLabel(diskType)}
|
||||
</span>
|
||||
)}
|
||||
{volumeTypeStr && (
|
||||
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
|
||||
{volumeTypeStr}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{volumeTypeStr && (
|
||||
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
|
||||
{volumeTypeStr}
|
||||
</span>
|
||||
)}
|
||||
{indexingProgress ? (
|
||||
<span className="bg-accent/20 border-accent/30 text-accent rounded border px-1.5 py-0.5 font-medium">
|
||||
{indexingProgress.filesIndexed.toLocaleString()}{' '}
|
||||
@@ -299,9 +252,9 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
volume.total_file_count != null && (
|
||||
volume.total_files != null && (
|
||||
<span className="bg-accent/10 border-accent/20 text-accent rounded border px-1.5 py-0.5">
|
||||
{volume.total_file_count.toLocaleString()}{' '}
|
||||
{volume.total_files.toLocaleString()}{' '}
|
||||
files
|
||||
</span>
|
||||
)
|
||||
@@ -309,19 +262,43 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capacity info */}
|
||||
<div className="flex-shrink-0 text-right">
|
||||
{/* Capacity info - fixed 3-row layout */}
|
||||
<div className="flex h-[48px] flex-shrink-0 flex-col justify-between text-right">
|
||||
<div className="text-ink text-sm font-medium">
|
||||
{formatBytes(totalCapacity)}
|
||||
</div>
|
||||
<div className="text-ink-dull text-[10px]">
|
||||
{formatBytes(availableBytes)} free
|
||||
</div>
|
||||
<div className="text-ink-faint flex h-3.5 items-center justify-end gap-1.5 text-[10px]">
|
||||
{volume.read_speed_mbps && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDown size={10} weight="bold" />
|
||||
{volume.read_speed_mbps}MB/s
|
||||
</span>
|
||||
)}
|
||||
{volume.write_speed_mbps && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUp size={10} weight="bold" />
|
||||
{volume.write_speed_mbps}MB/s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three dots button - far right */}
|
||||
<TopBarButton
|
||||
icon={DotsThree}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
contextMenu.show(e);
|
||||
}}
|
||||
title="Volume actions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Full-width capacity bar with padding */}
|
||||
<div className="px-3 pb-3 pt-2">
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<div className="bg-app border-app-line relative h-8 overflow-hidden rounded-md border">
|
||||
{/* Base capacity visualization */}
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
@@ -4188,6 +4189,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 +4435,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 +4548,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',
|
||||
|
||||
Reference in New Issue
Block a user