From 1eea61bcaf37fe547502e02e1d43938f062175a4 Mon Sep 17 00:00:00 2001 From: James Pine Date: Tue, 14 Apr 2026 19:19:56 -0700 Subject: [PATCH 01/14] feat: add redundancy awareness & cross-volume file comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface content redundancy data so users can answer "if this drive dies, what do I lose?" — builds on existing content identity and volume systems. Backend: - New `redundancy.summary` library query with per-volume at-risk vs redundant byte/file counts and a library-wide replication score - Extend `SearchFilters` with `at_risk`, `on_volumes`, `not_on_volumes`, `min_volume_count`, `max_volume_count` filters - Add composite index migration on entries(content_id, volume_id) Frontend: - `/redundancy` dashboard with replication score, volume bars, at-risk callout - `/redundancy/at-risk` paginated file list sorted by size - `/redundancy/compare` two-volume comparison (unique/shared toggle) - Sidebar ShieldCheck button linking to redundancy view Co-Authored-By: Claude Opus 4.6 (1M context) --- ...m20260414_000001_add_redundancy_indexes.rs | 37 +++ core/src/infra/db/migration/mod.rs | 2 + core/src/ops/mod.rs | 1 + core/src/ops/redundancy/mod.rs | 7 + core/src/ops/redundancy/summary/input.rs | 13 + core/src/ops/redundancy/summary/mod.rs | 9 + core/src/ops/redundancy/summary/output.rs | 48 +++ core/src/ops/redundancy/summary/query.rs | 295 ++++++++++++++++++ core/src/ops/search/filters.rs | 98 +++++- core/src/ops/search/input.rs | 22 +- core/src/ops/search/mod.rs | 8 +- core/src/ops/search/query.rs | 7 +- .../src/components/SpacesSidebar/index.tsx | 8 +- packages/interface/src/router.tsx | 20 ++ .../src/routes/redundancy/at-risk.tsx | 204 ++++++++++++ .../src/routes/redundancy/compare.tsx | 289 +++++++++++++++++ .../components/RedundancyVolumeBar.tsx | 131 ++++++++ .../interface/src/routes/redundancy/index.tsx | 223 +++++++++++++ packages/ts-client/src/generated/types.ts | 107 ++++++- 19 files changed, 1521 insertions(+), 8 deletions(-) create mode 100644 core/src/infra/db/migration/m20260414_000001_add_redundancy_indexes.rs create mode 100644 core/src/ops/redundancy/mod.rs create mode 100644 core/src/ops/redundancy/summary/input.rs create mode 100644 core/src/ops/redundancy/summary/mod.rs create mode 100644 core/src/ops/redundancy/summary/output.rs create mode 100644 core/src/ops/redundancy/summary/query.rs create mode 100644 packages/interface/src/routes/redundancy/at-risk.tsx create mode 100644 packages/interface/src/routes/redundancy/compare.tsx create mode 100644 packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx create mode 100644 packages/interface/src/routes/redundancy/index.tsx diff --git a/core/src/infra/db/migration/m20260414_000001_add_redundancy_indexes.rs b/core/src/infra/db/migration/m20260414_000001_add_redundancy_indexes.rs new file mode 100644 index 000000000000..884acd1d8fc3 --- /dev/null +++ b/core/src/infra/db/migration/m20260414_000001_add_redundancy_indexes.rs @@ -0,0 +1,37 @@ +//! Add composite index on entries(content_id, volume_id) for redundancy queries +//! +//! The redundancy feature needs to GROUP BY content_id and COUNT(DISTINCT volume_id) +//! across the entries table. Without this composite index, those queries would +//! require a full table scan on large libraries. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Composite index for redundancy queries: "which content exists on which volumes?" + // This covers GROUP BY content_id + COUNT(DISTINCT volume_id) patterns + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_entries_content_volume \ + ON entries(content_id, volume_id) \ + WHERE content_id IS NOT NULL AND volume_id IS NOT NULL", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP INDEX IF EXISTS idx_entries_content_volume") + .await?; + + Ok(()) + } +} diff --git a/core/src/infra/db/migration/mod.rs b/core/src/infra/db/migration/mod.rs index 1a301e8d93f8..35f88974e94f 100644 --- a/core/src/infra/db/migration/mod.rs +++ b/core/src/infra/db/migration/mod.rs @@ -38,6 +38,7 @@ mod m20260104_000001_replace_device_id_with_volume_id; mod m20260105_000001_add_volume_id_to_locations; mod m20260114_000001_fix_search_index_include_directories; mod m20260123_000001_remove_legacy_sync_columns; +mod m20260414_000001_add_redundancy_indexes; pub struct Migrator; @@ -81,6 +82,7 @@ impl MigratorTrait for Migrator { Box::new(m20260105_000001_add_volume_id_to_locations::Migration), Box::new(m20260114_000001_fix_search_index_include_directories::Migration), Box::new(m20260123_000001_remove_legacy_sync_columns::Migration), + Box::new(m20260414_000001_add_redundancy_indexes::Migration), ] } } diff --git a/core/src/ops/mod.rs b/core/src/ops/mod.rs index cdb15fc55fef..425e0dc23a9b 100644 --- a/core/src/ops/mod.rs +++ b/core/src/ops/mod.rs @@ -24,6 +24,7 @@ pub mod media; pub mod metadata; pub mod models; pub mod network; +pub mod redundancy; pub mod search; pub mod sidecar; pub mod sources; diff --git a/core/src/ops/redundancy/mod.rs b/core/src/ops/redundancy/mod.rs new file mode 100644 index 000000000000..6a12857def87 --- /dev/null +++ b/core/src/ops/redundancy/mod.rs @@ -0,0 +1,7 @@ +//! Redundancy awareness operations +//! +//! Provides queries for understanding data redundancy across volumes: +//! - Summary statistics (per-volume at-risk vs redundant bytes) +//! - Integration with search filters for file-level redundancy queries + +pub mod summary; diff --git a/core/src/ops/redundancy/summary/input.rs b/core/src/ops/redundancy/summary/input.rs new file mode 100644 index 000000000000..71022aa3671b --- /dev/null +++ b/core/src/ops/redundancy/summary/input.rs @@ -0,0 +1,13 @@ +//! Input types for redundancy summary query + +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +/// Input for the redundancy summary query +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct RedundancySummaryInput { + /// Optional: restrict summary to specific volumes. None = all volumes. + #[serde(default)] + pub volume_uuids: Option>, +} diff --git a/core/src/ops/redundancy/summary/mod.rs b/core/src/ops/redundancy/summary/mod.rs new file mode 100644 index 000000000000..daf4a68dbffd --- /dev/null +++ b/core/src/ops/redundancy/summary/mod.rs @@ -0,0 +1,9 @@ +//! Redundancy summary query + +pub mod input; +pub mod output; +pub mod query; + +pub use input::*; +pub use output::*; +pub use query::*; diff --git a/core/src/ops/redundancy/summary/output.rs b/core/src/ops/redundancy/summary/output.rs new file mode 100644 index 000000000000..55d831b0ba11 --- /dev/null +++ b/core/src/ops/redundancy/summary/output.rs @@ -0,0 +1,48 @@ +//! Output types for redundancy summary query + +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +/// Complete redundancy summary for the library +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct RedundancySummaryOutput { + /// Per-volume redundancy breakdown + pub volumes: Vec, + /// Library-wide totals + pub library_totals: LibraryRedundancyTotals, +} + +/// Redundancy breakdown for a single volume +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VolumeRedundancySummary { + /// Volume UUID + pub volume_uuid: Uuid, + /// Display name of the volume + pub display_name: Option, + /// Total bytes of file content on this volume (deduplicated within volume) + pub total_bytes: i64, + /// Bytes of content unique to this volume (at risk if volume is lost) + pub at_risk_bytes: i64, + /// Number of files whose content only exists on this volume + pub at_risk_file_count: u32, + /// Bytes of content that also exists on at least one other volume + pub redundant_bytes: i64, + /// Number of files whose content exists on other volumes too + pub redundant_file_count: u32, + /// Total number of files on this volume + pub total_file_count: u32, +} + +/// Library-wide redundancy totals +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct LibraryRedundancyTotals { + /// Total unique content bytes across the entire library (deduplicated) + pub total_unique_content_bytes: i64, + /// Content bytes that exist on only one volume + pub total_at_risk_bytes: i64, + /// Content bytes that exist on two or more volumes + pub total_redundant_bytes: i64, + /// Ratio of redundant to total content (0.0 = nothing replicated, 1.0 = everything replicated) + pub replication_score: f64, +} diff --git a/core/src/ops/redundancy/summary/query.rs b/core/src/ops/redundancy/summary/query.rs new file mode 100644 index 000000000000..16ac13d112c8 --- /dev/null +++ b/core/src/ops/redundancy/summary/query.rs @@ -0,0 +1,295 @@ +//! Redundancy summary query implementation + +use super::{ + input::RedundancySummaryInput, + output::{LibraryRedundancyTotals, RedundancySummaryOutput, VolumeRedundancySummary}, +}; +use crate::{ + context::CoreContext, + infra::{ + db::entities, + query::{LibraryQuery, QueryError, QueryResult}, + }, +}; +use sea_orm::{ + ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, QueryFilter, Statement, +}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::{collections::HashMap, sync::Arc}; +use uuid::Uuid; + +/// Redundancy summary query +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct RedundancySummaryQuery { + pub input: RedundancySummaryInput, +} + +/// Row result for per-volume redundancy stats +#[derive(FromQueryResult)] +struct VolumeRedundancyRow { + volume_id: i32, + file_count: i64, + content_bytes: i64, +} + +/// Row result for per-volume total file stats +#[derive(FromQueryResult)] +struct VolumeTotalRow { + volume_id: i32, + total_file_count: i64, + total_bytes: i64, +} + +/// Row result for library-wide unique content totals +#[derive(FromQueryResult)] +struct LibraryTotalRow { + total_bytes: i64, +} + +impl LibraryQuery for RedundancySummaryQuery { + type Input = RedundancySummaryInput; + type Output = RedundancySummaryOutput; + + fn from_input(input: Self::Input) -> QueryResult { + Ok(Self { input }) + } + + async fn execute( + self, + context: Arc, + session: crate::infra::api::SessionContext, + ) -> QueryResult { + let library_id = session + .current_library_id + .ok_or_else(|| QueryError::Internal("No library in session".to_string()))?; + + let library = context + .libraries() + .await + .get_library(library_id) + .await + .ok_or_else(|| QueryError::Internal("Library not found".to_string()))?; + + let db = library.db().conn(); + + // Build volume UUID filter if scoped + let volume_id_filter = if let Some(ref uuids) = self.input.volume_uuids { + if uuids.is_empty() { + None + } else { + // Resolve UUIDs to internal IDs + let volumes = entities::volume::Entity::find() + .filter(entities::volume::Column::Uuid.is_in(uuids.clone())) + .all(db) + .await?; + Some(volumes.iter().map(|v| v.id).collect::>()) + } + } else { + None + }; + + // Fetch all visible volumes for display names and UUID mapping + let all_volumes = entities::volume::Entity::find() + .filter(entities::volume::Column::IsUserVisible.eq(true)) + .all(db) + .await?; + + let volume_id_to_uuid: HashMap = + all_volumes.iter().map(|v| (v.id, v.uuid)).collect(); + let volume_id_to_name: HashMap> = + all_volumes.iter().map(|v| (v.id, v.display_name.clone())).collect(); + + // Helper to build volume ID WHERE clause + let volume_where = match &volume_id_filter { + Some(ids) => { + let id_list = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + format!("AND e.volume_id IN ({})", id_list) + } + None => String::new(), + }; + + // Query 1: Per-volume at-risk content (content existing on exactly one volume) + let at_risk_sql = format!( + r#" + SELECT e.volume_id as volume_id, + COUNT(*) as file_count, + COALESCE(SUM(ci.total_size), 0) as content_bytes + FROM entries e + INNER JOIN content_identities ci ON e.content_id = ci.id + WHERE e.content_id IS NOT NULL + AND e.volume_id IS NOT NULL + AND e.kind = 0 + {} + AND e.content_id IN ( + SELECT e2.content_id FROM entries e2 + WHERE e2.content_id IS NOT NULL AND e2.volume_id IS NOT NULL + GROUP BY e2.content_id + HAVING COUNT(DISTINCT e2.volume_id) = 1 + ) + GROUP BY e.volume_id + "#, + volume_where + ); + + let at_risk_rows = VolumeRedundancyRow::find_by_statement( + Statement::from_string(DbBackend::Sqlite, at_risk_sql), + ) + .all(db) + .await?; + + // Query 2: Per-volume redundant content (content existing on 2+ volumes) + let redundant_sql = format!( + r#" + SELECT e.volume_id as volume_id, + COUNT(*) as file_count, + COALESCE(SUM(ci.total_size), 0) as content_bytes + FROM entries e + INNER JOIN content_identities ci ON e.content_id = ci.id + WHERE e.content_id IS NOT NULL + AND e.volume_id IS NOT NULL + AND e.kind = 0 + {} + AND e.content_id IN ( + SELECT e2.content_id FROM entries e2 + WHERE e2.content_id IS NOT NULL AND e2.volume_id IS NOT NULL + GROUP BY e2.content_id + HAVING COUNT(DISTINCT e2.volume_id) > 1 + ) + GROUP BY e.volume_id + "#, + volume_where + ); + + let redundant_rows = VolumeRedundancyRow::find_by_statement( + Statement::from_string(DbBackend::Sqlite, redundant_sql), + ) + .all(db) + .await?; + + // Query 3: Per-volume total file counts + let totals_sql = format!( + r#" + SELECT e.volume_id as volume_id, + COUNT(*) as total_file_count, + COALESCE(SUM(e.size), 0) as total_bytes + FROM entries e + WHERE e.volume_id IS NOT NULL + AND e.kind = 0 + {} + GROUP BY e.volume_id + "#, + volume_where + ); + + let total_rows = VolumeTotalRow::find_by_statement(Statement::from_string( + DbBackend::Sqlite, + totals_sql, + )) + .all(db) + .await?; + + // Query 4: Library-wide unique content total (deduplicated) + let library_total_sql = r#" + SELECT COALESCE(SUM(ci.total_size), 0) as total_bytes + FROM content_identities ci + WHERE ci.id IN ( + SELECT DISTINCT e.content_id FROM entries e + WHERE e.content_id IS NOT NULL AND e.volume_id IS NOT NULL + ) + "#; + + let library_total = LibraryTotalRow::find_by_statement(Statement::from_string( + DbBackend::Sqlite, + library_total_sql.to_string(), + )) + .one(db) + .await?; + + // Build lookup maps + let mut at_risk_map: HashMap = HashMap::new(); + for row in &at_risk_rows { + at_risk_map.insert(row.volume_id, row); + } + + let mut redundant_map: HashMap = HashMap::new(); + for row in &redundant_rows { + redundant_map.insert(row.volume_id, row); + } + + let mut totals_map: HashMap = HashMap::new(); + for row in &total_rows { + totals_map.insert(row.volume_id, row); + } + + // Build per-volume summaries + let mut volumes = Vec::new(); + let mut lib_at_risk_bytes: i64 = 0; + let mut lib_redundant_bytes: i64 = 0; + + // Determine which volume IDs to include + let volume_ids: Vec = match &volume_id_filter { + Some(ids) => ids.clone(), + None => { + let mut ids: Vec = totals_map.keys().copied().collect(); + ids.sort(); + ids + } + }; + + for vol_id in &volume_ids { + let volume_uuid = match volume_id_to_uuid.get(vol_id) { + Some(uuid) => *uuid, + None => continue, + }; + + let at_risk = at_risk_map.get(vol_id); + let redundant = redundant_map.get(vol_id); + let total = totals_map.get(vol_id); + + let at_risk_bytes = at_risk.map_or(0, |r| r.content_bytes); + let at_risk_file_count = at_risk.map_or(0, |r| r.file_count as u32); + let redundant_bytes = redundant.map_or(0, |r| r.content_bytes); + let redundant_file_count = redundant.map_or(0, |r| r.file_count as u32); + let total_bytes = total.map_or(0, |r| r.total_bytes); + let total_file_count = total.map_or(0, |r| r.total_file_count as u32); + + lib_at_risk_bytes += at_risk_bytes; + lib_redundant_bytes += redundant_bytes; + + volumes.push(VolumeRedundancySummary { + volume_uuid, + display_name: volume_id_to_name.get(vol_id).cloned().flatten(), + total_bytes, + at_risk_bytes, + at_risk_file_count, + redundant_bytes, + redundant_file_count, + total_file_count, + }); + } + + let total_unique_content_bytes = library_total.map_or(0, |r| r.total_bytes); + let replication_score = if lib_at_risk_bytes + lib_redundant_bytes > 0 { + lib_redundant_bytes as f64 / (lib_at_risk_bytes + lib_redundant_bytes) as f64 + } else { + 0.0 + }; + + Ok(RedundancySummaryOutput { + volumes, + library_totals: LibraryRedundancyTotals { + total_unique_content_bytes, + total_at_risk_bytes: lib_at_risk_bytes, + total_redundant_bytes: lib_redundant_bytes, + replication_score, + }, + }) + } +} + +crate::register_library_query!(RedundancySummaryQuery, "redundancy.summary"); diff --git a/core/src/ops/search/filters.rs b/core/src/ops/search/filters.rs index c947252f782b..a6eeb212d2cc 100644 --- a/core/src/ops/search/filters.rs +++ b/core/src/ops/search/filters.rs @@ -3,7 +3,8 @@ use super::input::*; use crate::domain::ContentKind; use crate::filetype::FileTypeRegistry; -use sea_orm::{ColumnTrait, Condition}; +use sea_orm::{sea_query::Expr, ColumnTrait, Condition}; +use uuid::Uuid; /// Filter builder for search queries pub struct FilterBuilder { @@ -113,6 +114,101 @@ impl FilterBuilder { self } + /// Filter by redundancy status: at_risk=true means content on exactly 1 volume + pub fn at_risk(mut self, at_risk: &Option) -> Self { + if let Some(is_at_risk) = at_risk { + let having = if *is_at_risk { "= 1" } else { "> 1" }; + self.condition = self.condition.add(Expr::cust(format!( + "entries.content_id IN (\ + SELECT e2.content_id FROM entries e2 \ + WHERE e2.content_id IS NOT NULL AND e2.volume_id IS NOT NULL \ + GROUP BY e2.content_id \ + HAVING COUNT(DISTINCT e2.volume_id) {}\ + )", + having + ))); + } + self + } + + /// Filter to files whose content is present on the specified volumes + pub fn on_volumes(mut self, on_volumes: &Option>) -> Self { + if let Some(uuids) = on_volumes { + if !uuids.is_empty() { + let uuid_list = uuids + .iter() + .map(|u| format!("'{}'", u)) + .collect::>() + .join(","); + self.condition = self.condition.add(Expr::cust(format!( + "entries.content_id IN (\ + SELECT e2.content_id FROM entries e2 \ + INNER JOIN volumes v ON e2.volume_id = v.id \ + WHERE e2.content_id IS NOT NULL \ + AND v.uuid IN ({})\ + )", + uuid_list + ))); + } + } + self + } + + /// Filter to files whose content is NOT present on the specified volumes + pub fn not_on_volumes(mut self, not_on_volumes: &Option>) -> Self { + if let Some(uuids) = not_on_volumes { + if !uuids.is_empty() { + let uuid_list = uuids + .iter() + .map(|u| format!("'{}'", u)) + .collect::>() + .join(","); + self.condition = self.condition.add(Expr::cust(format!( + "entries.content_id NOT IN (\ + SELECT e2.content_id FROM entries e2 \ + INNER JOIN volumes v ON e2.volume_id = v.id \ + WHERE e2.content_id IS NOT NULL \ + AND v.uuid IN ({})\ + )", + uuid_list + ))); + } + } + self + } + + /// Filter by minimum number of volumes content exists on + pub fn min_volume_count(mut self, min_count: &Option) -> Self { + if let Some(min) = min_count { + self.condition = self.condition.add(Expr::cust(format!( + "entries.content_id IN (\ + SELECT e2.content_id FROM entries e2 \ + WHERE e2.content_id IS NOT NULL AND e2.volume_id IS NOT NULL \ + GROUP BY e2.content_id \ + HAVING COUNT(DISTINCT e2.volume_id) >= {}\ + )", + min + ))); + } + self + } + + /// Filter by maximum number of volumes content exists on + pub fn max_volume_count(mut self, max_count: &Option) -> Self { + if let Some(max) = max_count { + self.condition = self.condition.add(Expr::cust(format!( + "entries.content_id IN (\ + SELECT e2.content_id FROM entries e2 \ + WHERE e2.content_id IS NOT NULL AND e2.volume_id IS NOT NULL \ + GROUP BY e2.content_id \ + HAVING COUNT(DISTINCT e2.volume_id) <= {}\ + )", + max + ))); + } + self + } + /// Apply hidden files filter pub fn include_hidden(mut self, include_hidden: &Option) -> Self { if let Some(include) = include_hidden { diff --git a/core/src/ops/search/input.rs b/core/src/ops/search/input.rs index 8c2c45e3c6fb..28d2f28dcd5c 100644 --- a/core/src/ops/search/input.rs +++ b/core/src/ops/search/input.rs @@ -62,6 +62,19 @@ pub struct SearchFilters { pub content_types: Option>, pub include_hidden: Option, pub include_archived: Option, + + // Redundancy filters + /// Only return files that are at risk (true) or redundant (false). + /// At risk = content exists on exactly one volume. + pub at_risk: Option, + /// Only return files whose content is present on these volumes + pub on_volumes: Option>, + /// Only return files whose content is NOT present on these volumes + pub not_on_volumes: Option>, + /// Minimum number of distinct volumes the content must exist on + pub min_volume_count: Option, + /// Maximum number of distinct volumes the content can exist on + pub max_volume_count: Option, } /// Filter for tags, supporting complex boolean logic @@ -190,7 +203,14 @@ impl FileSearchInput { let is_recents_query = self.query.trim().is_empty() && matches!(self.sort.field, SortField::IndexedAt); - if self.query.trim().is_empty() && !is_recents_query { + // Allow empty queries when redundancy filters are active (browsing at-risk files) + let has_redundancy_filters = self.filters.at_risk.is_some() + || self.filters.on_volumes.is_some() + || self.filters.not_on_volumes.is_some() + || self.filters.min_volume_count.is_some() + || self.filters.max_volume_count.is_some(); + + if self.query.trim().is_empty() && !is_recents_query && !has_redundancy_filters { return Err("Query cannot be empty".to_string()); } diff --git a/core/src/ops/search/mod.rs b/core/src/ops/search/mod.rs index 043c2b95c664..b48273e64a60 100644 --- a/core/src/ops/search/mod.rs +++ b/core/src/ops/search/mod.rs @@ -41,6 +41,10 @@ pub enum FilterKind { ContentTypes, Tags, // Persistent only Locations, // Persistent only - Hidden, // Not implemented yet - Archived, // Not implemented yet + Hidden, // Not implemented yet + Archived, // Not implemented yet + AtRisk, // Redundancy: content on exactly one volume + OnVolumes, // Redundancy: content present on specific volumes + NotOnVolumes, // Redundancy: content absent from specific volumes + VolumeCount, // Redundancy: min/max volume count } diff --git a/core/src/ops/search/query.rs b/core/src/ops/search/query.rs index f1fb04bb8472..bd62fcb6192f 100644 --- a/core/src/ops/search/query.rs +++ b/core/src/ops/search/query.rs @@ -924,7 +924,12 @@ impl FileSearchQuery { let filter_builder = FilterBuilder::new() .file_types(&self.input.filters.file_types) .date_range(&self.input.filters.date_range) - .size_range(&self.input.filters.size_range); + .size_range(&self.input.filters.size_range) + .at_risk(&self.input.filters.at_risk) + .on_volumes(&self.input.filters.on_volumes) + .not_on_volumes(&self.input.filters.not_on_volumes) + .min_volume_count(&self.input.filters.min_volume_count) + .max_volume_count(&self.input.filters.max_volume_count); query = query.filter(filter_builder.build()); diff --git a/packages/interface/src/components/SpacesSidebar/index.tsx b/packages/interface/src/components/SpacesSidebar/index.tsx index 1db6a2197d56..ce44adddb560 100644 --- a/packages/interface/src/components/SpacesSidebar/index.tsx +++ b/packages/interface/src/components/SpacesSidebar/index.tsx @@ -12,7 +12,8 @@ import { FunnelSimple, GearSix, ListBullets, - Palette + Palette, + ShieldCheck } from '@phosphor-icons/react'; import {useSidebarStore} from '@sd/ts-client'; import type { @@ -556,6 +557,11 @@ export function SpacesSidebar({isPreviewActive = false}: SpacesSidebarProps) { getSpeedHistory={getSpeedHistory} navigate={navigate} /> + navigate('/redundancy')} + /> , }, + { + path: "redundancy", + children: [ + { + index: true, + element: , + }, + { + path: "at-risk", + element: , + }, + { + path: "compare", + element: , + }, + ], + }, { path: "search", element: ( diff --git a/packages/interface/src/routes/redundancy/at-risk.tsx b/packages/interface/src/routes/redundancy/at-risk.tsx new file mode 100644 index 000000000000..c4c6b08f88c7 --- /dev/null +++ b/packages/interface/src/routes/redundancy/at-risk.tsx @@ -0,0 +1,204 @@ +/** + * At-Risk Files View + * + * Paginated list of files that exist on only one volume, + * sorted by size (biggest risk first). + */ + +import { useState, useMemo } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { ShieldWarning, ArrowLeft, CaretLeft, CaretRight } from "@phosphor-icons/react"; +import { TopBarPortal, TopBarItem } from "../../TopBar"; +import { CircleButton } from "@spacedrive/primitives"; +import { useLibraryQuery } from "../../contexts/SpacedriveContext"; +import type { File as SdFile } from "@sd/ts-client"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +const PAGE_SIZE = 50; + +export function AtRiskFiles() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const volumeFilter = searchParams.get("volume"); + const atRiskParam = searchParams.get("at_risk"); + const isAtRisk = atRiskParam !== "false"; // default true + + const [offset, setOffset] = useState(0); + + const { data, isLoading } = useLibraryQuery({ + type: "search.files", + input: { + query: "", + scope: "Library", + mode: "Fast", + filters: { + file_types: null, + tags: null, + date_range: null, + size_range: null, + locations: null, + content_types: null, + include_hidden: null, + include_archived: null, + at_risk: isAtRisk, + on_volumes: volumeFilter ? [volumeFilter] : null, + not_on_volumes: null, + min_volume_count: null, + max_volume_count: null, + }, + sort: { field: "Size", direction: "Desc" }, + pagination: { limit: PAGE_SIZE, offset }, + }, + }); + + const files = data?.files ?? []; + const totalCount = data?.total_found ?? 0; + const hasMore = offset + PAGE_SIZE < totalCount; + const hasPrev = offset > 0; + const currentPage = Math.floor(offset / PAGE_SIZE) + 1; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + const topBarContent = useMemo( + () => ( +
+ navigate("/redundancy")} + /> + +

+ {isAtRisk ? "At-Risk" : "Redundant"} Files +

+ {totalCount > 0 && ( + + ({totalCount.toLocaleString()} files) + + )} +
+ ), + [navigate, isAtRisk, totalCount], + ); + + return ( + <> + + {topBarContent} + + } + /> + +
+ {isLoading ? ( +
+ Loading files... +
+ ) : files.length === 0 ? ( +
+ + + {isAtRisk + ? "No at-risk files found. All your data is safely replicated!" + : "No redundant files found."} + +
+ ) : ( + <> + {/* File List */} +
+ + + + + + + + + + + {files.map((file: SdFile) => ( + + + + + + + ))} + +
+ Name + + Size + + Extension + + Modified +
+ {file.name} + + {formatBytes(file.size)} + + {file.extension || "\u2014"} + + {new Date( + file.modified_at, + ).toLocaleDateString()} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {currentPage} of {totalPages} + +
+ + +
+
+ )} + + )} +
+ + ); +} diff --git a/packages/interface/src/routes/redundancy/compare.tsx b/packages/interface/src/routes/redundancy/compare.tsx new file mode 100644 index 000000000000..68b73dfe355d --- /dev/null +++ b/packages/interface/src/routes/redundancy/compare.tsx @@ -0,0 +1,289 @@ +/** + * Volume Comparison View + * + * Select two volumes to see what's unique to each and what's shared. + */ + +import { useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowLeft, ArrowsLeftRight, ShieldCheck } from "@phosphor-icons/react"; +import { TopBarPortal, TopBarItem } from "../../TopBar"; +import { CircleButton } from "@spacedrive/primitives"; +import { useLibraryQuery } from "../../contexts/SpacedriveContext"; +import type { SearchFilters, File as SdFile } from "@sd/ts-client"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +type CompareMode = "unique_a" | "unique_b" | "shared"; + +const NULL_FILTERS: SearchFilters = { + file_types: null, + tags: null, + date_range: null, + size_range: null, + locations: null, + content_types: null, + include_hidden: null, + include_archived: null, + at_risk: null, + on_volumes: null, + not_on_volumes: null, + min_volume_count: null, + max_volume_count: null, +}; + +export function CompareVolumes() { + const navigate = useNavigate(); + const [volumeA, setVolumeA] = useState(null); + const [volumeB, setVolumeB] = useState(null); + const [mode, setMode] = useState("unique_a"); + + // Fetch volume list for the picker + const { data: summaryData } = useLibraryQuery({ + type: "redundancy.summary", + input: {}, + }); + + const volumes = summaryData?.volumes ?? []; + + // Build search filters based on compare mode + const filters: SearchFilters | null = useMemo(() => { + if (!volumeA || !volumeB) return null; + + switch (mode) { + case "unique_a": + return { ...NULL_FILTERS, on_volumes: [volumeA], not_on_volumes: [volumeB] }; + case "unique_b": + return { ...NULL_FILTERS, on_volumes: [volumeB], not_on_volumes: [volumeA] }; + case "shared": + return { ...NULL_FILTERS, on_volumes: [volumeA, volumeB], min_volume_count: 2 }; + } + }, [volumeA, volumeB, mode]); + + // Search with redundancy filters + const { data: searchData, isLoading: searchLoading } = useLibraryQuery( + { + type: "search.files", + input: { + query: "", + scope: "Library", + mode: "Fast", + filters: filters ?? NULL_FILTERS, + sort: { field: "Size", direction: "Desc" }, + pagination: { limit: 50, offset: 0 }, + }, + }, + { enabled: !!filters }, + ); + + const files = searchData?.files ?? []; + const totalCount = searchData?.total_found ?? 0; + + const volumeAName = + volumes.find((v) => v.volume_uuid === volumeA)?.display_name ?? + "Volume A"; + const volumeBName = + volumes.find((v) => v.volume_uuid === volumeB)?.display_name ?? + "Volume B"; + + const topBarContent = useMemo( + () => ( +
+ navigate("/redundancy")} + /> + +

Compare Volumes

+
+ ), + [navigate], + ); + + return ( + <> + + {topBarContent} + + } + /> + +
+
+ {/* Volume Pickers */} +
+ + + + + +
+ + {/* Mode Toggle */} + {volumeA && volumeB && ( +
+ setMode("unique_a")} + label={`Unique to ${volumeAName}`} + /> + setMode("shared")} + label="Shared" + /> + setMode("unique_b")} + label={`Unique to ${volumeBName}`} + /> +
+ )} + + {/* Results */} + {!volumeA || !volumeB ? ( +
+ + + Select two volumes to compare their contents + +
+ ) : searchLoading ? ( +
+ Loading comparison... +
+ ) : files.length === 0 ? ( +
+ + + {mode === "shared" + ? "No shared files found between these volumes" + : `No unique files found on ${mode === "unique_a" ? volumeAName : volumeBName}`} + +
+ ) : ( +
+
+ {totalCount.toLocaleString()} files found +
+
+ + + + + + + + + + {files.map((file: SdFile) => ( + + + + + + ))} + +
+ Name + + Size + + Extension +
+ {file.name} + + {formatBytes(file.size)} + + {file.extension || "\u2014"} +
+
+
+ )} +
+
+ + ); +} + +function ModeButton({ + active, + onClick, + label, +}: { + active: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx b/packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx new file mode 100644 index 000000000000..98e4f647b1e7 --- /dev/null +++ b/packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx @@ -0,0 +1,131 @@ +import { motion } from "framer-motion"; +import { Tooltip } from "@spacedrive/primitives"; +import { useNavigate } from "react-router-dom"; + +interface RedundancyVolumeBarProps { + volumeUuid: string; + displayName: string | null; + totalBytes: number; + atRiskBytes: number; + atRiskFileCount: number; + redundantBytes: number; + redundantFileCount: number; + totalFileCount: number; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +export function RedundancyVolumeBar({ + volumeUuid, + displayName, + totalBytes, + atRiskBytes, + atRiskFileCount, + redundantBytes, + redundantFileCount, + totalFileCount, +}: RedundancyVolumeBarProps) { + const navigate = useNavigate(); + + const redundantPercent = + totalBytes > 0 ? (redundantBytes / totalBytes) * 100 : 0; + const atRiskPercent = + totalBytes > 0 ? (atRiskBytes / totalBytes) * 100 : 0; + // Remaining is unindexed content (no content_id) + const unindexedPercent = Math.max( + 0, + 100 - redundantPercent - atRiskPercent, + ); + + return ( +
+ {/* Header */} +
+ + {displayName || "Unknown Volume"} + + + {totalFileCount.toLocaleString()} files + +
+ + {/* Bar */} +
+ {/* Redundant segment (safe) */} + {redundantPercent > 0 && ( + + + navigate( + `/redundancy/at-risk?volume=${volumeUuid}&at_risk=false`, + ) + } + /> + + )} + {/* At-risk segment */} + {atRiskPercent > 0 && ( + + + navigate( + `/redundancy/at-risk?volume=${volumeUuid}&at_risk=true`, + ) + } + /> + + )} + {/* Unindexed segment */} + {unindexedPercent > 0 && ( + + )} +
+ + {/* Legend */} +
+
+
+ + Redundant ({formatBytes(redundantBytes)}) + +
+
+
+ + At Risk ({formatBytes(atRiskBytes)}) + +
+
+
+ ); +} diff --git a/packages/interface/src/routes/redundancy/index.tsx b/packages/interface/src/routes/redundancy/index.tsx new file mode 100644 index 000000000000..0200bd491bca --- /dev/null +++ b/packages/interface/src/routes/redundancy/index.tsx @@ -0,0 +1,223 @@ +/** + * Redundancy Dashboard + * + * Shows library-wide replication score, per-volume redundancy bars, + * and an at-risk data callout. + */ + +import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { ShieldCheck, Warning, ArrowRight } from "@phosphor-icons/react"; +import { motion } from "framer-motion"; +import { TopBarPortal, TopBarItem } from "../../TopBar"; +import { useLibraryQuery } from "../../contexts/SpacedriveContext"; +import { RedundancyVolumeBar } from "./components/RedundancyVolumeBar"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +export function RedundancyDashboard() { + const navigate = useNavigate(); + + const { data, isLoading } = useLibraryQuery({ + type: "redundancy.summary", + input: {}, + }); + + const scorePercent = useMemo(() => { + if (!data) return 0; + return Math.round(data.library_totals.replication_score * 100); + }, [data]); + + const scoreColor = + scorePercent >= 75 + ? "text-green-400" + : scorePercent >= 40 + ? "text-amber-400" + : "text-red-400"; + + const topBarTitle = useMemo( + () => ( +
+ +

Redundancy

+
+ ), + [], + ); + + if (isLoading || !data) { + return ( + <> + + {topBarTitle} + + } + /> +
+ Loading redundancy data... +
+ + ); + } + + const { library_totals, volumes } = data; + const totalAtRiskFiles = volumes.reduce( + (sum, v) => sum + v.at_risk_file_count, + 0, + ); + + return ( + <> + + {topBarTitle} + + } + /> + +
+
+ {/* Replication Score + At-Risk Summary */} +
+ {/* Replication Score Card */} +
+
+ + {scorePercent}% + + + Replication Score + +
+
+
+ + {formatBytes( + library_totals.total_redundant_bytes, + )} + {" "} + safely replicated +
+
+ + {formatBytes( + library_totals.total_at_risk_bytes, + )} + {" "} + at risk (single copy) +
+
+ + {formatBytes( + library_totals.total_unique_content_bytes, + )} + {" "} + unique content total +
+
+
+ + {/* At-Risk Callout */} + +
+ + {/* Per-Volume Redundancy Bars */} +
+

+ Per-Volume Breakdown +

+
+ {volumes.length === 0 ? ( +
+ No volumes with indexed content found. + Index a volume to see redundancy data. +
+ ) : ( + volumes.map((vol) => ( + + )) + )} +
+
+ + {/* Quick Actions */} +
+ + +
+
+
+ + ); +} diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index cddc78b86f45..7c2a82bf02a0 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -1468,7 +1468,7 @@ export type FileSystem = /** * Indicates which filters are available for a given search type */ -export type FilterKind = "FileTypes" | "DateRange" | "SizeRange" | "ContentTypes" | "Tags" | "Locations" | "Hidden" | "Archived"; +export type FilterKind = "FileTypes" | "DateRange" | "SizeRange" | "ContentTypes" | "Tags" | "Locations" | "Hidden" | "Archived" | "AtRisk" | "OnVolumes" | "NotOnVolumes" | "VolumeCount"; /** * Raw filesystem event kinds emitted by the watcher without DB resolution @@ -2420,6 +2420,27 @@ name: string; */ path: string }; +/** + * Library-wide redundancy totals + */ +export type LibraryRedundancyTotals = { +/** + * Total unique content bytes across the entire library (deduplicated) + */ +total_unique_content_bytes: number; +/** + * Content bytes that exist on only one volume + */ +total_at_risk_bytes: number; +/** + * Content bytes that exist on two or more volumes + */ +total_redundant_bytes: number; +/** + * Ratio of redundant to total content (0.0 = nothing replicated, 1.0 = everything replicated) + */ +replication_score: number }; + export type LibraryRenameInput = { library_id: string; new_name: string }; export type LibraryRenameOutput = { library_id: string; old_name: string; new_name: string }; @@ -3318,6 +3339,28 @@ enabled: boolean; */ regenerate: boolean }; +/** + * Input for the redundancy summary query + */ +export type RedundancySummaryInput = { +/** + * Optional: restrict summary to specific volumes. None = all volumes. + */ +volume_uuids?: string[] | null }; + +/** + * Complete redundancy summary for the library + */ +export type RedundancySummaryOutput = { +/** + * Per-volume redundancy breakdown + */ +volumes: VolumeRedundancySummary[]; +/** + * Library-wide totals + */ +library_totals: LibraryRedundancyTotals }; + export type RegenerateThumbnailInput = { /** * UUID of the entry to regenerate thumbnails for @@ -3560,7 +3603,28 @@ export type SearchFacets = { file_types: { [key in string]: number }; tags: { [k /** * Container for all structured filters */ -export type SearchFilters = { file_types: string[] | null; tags: TagFilter | null; date_range: DateRangeFilter | null; size_range: SizeRangeFilter | null; locations: string[] | null; content_types: ContentKind[] | null; include_hidden: boolean | null; include_archived: boolean | null }; +export type SearchFilters = { file_types: string[] | null; tags: TagFilter | null; date_range: DateRangeFilter | null; size_range: SizeRangeFilter | null; locations: string[] | null; content_types: ContentKind[] | null; include_hidden: boolean | null; include_archived: boolean | null; +/** + * Only return files that are at risk (true) or redundant (false). + * At risk = content exists on exactly one volume. + */ +at_risk: boolean | null; +/** + * Only return files whose content is present on these volumes + */ +on_volumes: string[] | null; +/** + * Only return files whose content is NOT present on these volumes + */ +not_on_volumes: string[] | null; +/** + * Minimum number of distinct volumes the content must exist on + */ +min_volume_count: number | null; +/** + * Maximum number of distinct volumes the content can exist on + */ +max_volume_count: number | null }; /** * Defines the search mode and performance characteristics @@ -4746,6 +4810,43 @@ export type VolumeListQueryInput = { */ filter?: VolumeFilter }; +/** + * Redundancy breakdown for a single volume + */ +export type VolumeRedundancySummary = { +/** + * Volume UUID + */ +volume_uuid: string; +/** + * Display name of the volume + */ +display_name: string | null; +/** + * Total bytes of file content on this volume (deduplicated within volume) + */ +total_bytes: number; +/** + * Bytes of content unique to this volume (at risk if volume is lost) + */ +at_risk_bytes: number; +/** + * Number of files whose content only exists on this volume + */ +at_risk_file_count: number; +/** + * Bytes of content that also exists on at least one other volume + */ +redundant_bytes: number; +/** + * Number of files whose content exists on other volumes too + */ +redundant_file_count: number; +/** + * Total number of files on this volume + */ +total_file_count: number }; + export type VolumeRefreshInput = { /** * Optional: Set to true to force recalculation even if recently calculated @@ -4995,6 +5096,7 @@ export type LibraryQuery = | { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput } | { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput } | { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput } + | { type: 'redundancy.summary'; input: RedundancySummaryInput; output: RedundancySummaryOutput } | { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput } | { type: 'sources.get'; input: GetSourceInput; output: SourceInfo } | { type: 'sources.list'; input: ListSourcesInput; output: [SourceInfo] } @@ -5126,6 +5228,7 @@ export const WIRE_METHODS = { 'locations.list': 'query:locations.list', 'locations.suggested': 'query:locations.suggested', 'locations.validate_path': 'query:locations.validate_path', + 'redundancy.summary': 'query:redundancy.summary', 'search.files': 'query:search.files', 'sources.get': 'query:sources.get', 'sources.list': 'query:sources.list', From 2926675e6e383f2dce5727ebfba4a52a513c32ef Mon Sep 17 00:00:00 2001 From: James Pine Date: Wed, 15 Apr 2026 18:57:01 -0700 Subject: [PATCH 02/14] redundancy UI improvements + ZFS volume detection fix Co-Authored-By: Claude Opus 4.6 (1M context) --- core/src/domain/space.rs | 3 + core/src/library/manager.rs | 1 + core/src/volume/detection.rs | 30 +++++--- core/src/volume/fs/zfs.rs | 45 ++++++++++++ .../SpacesSidebar/SpaceCustomizationPanel.tsx | 4 ++ .../SpacesSidebar/hooks/spaceItemUtils.ts | 8 +++ .../src/components/SpacesSidebar/index.tsx | 6 -- .../src/routes/redundancy/at-risk.tsx | 2 +- .../components/RedundancyVolumeBar.tsx | 69 +++++++++---------- .../interface/src/routes/redundancy/index.tsx | 10 +-- packages/ts-client/src/generated/types.ts | 6 +- 11 files changed, 125 insertions(+), 59 deletions(-) diff --git a/core/src/domain/space.rs b/core/src/domain/space.rs index 5ed2e643dd29..e0f561cb1dbe 100644 --- a/core/src/domain/space.rs +++ b/core/src/domain/space.rs @@ -445,6 +445,9 @@ pub enum ItemType { /// Specific archive data source Source { source_id: String }, + + /// Redundancy awareness dashboard + Redundancy, } /// Complete sidebar layout for a space #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/core/src/library/manager.rs b/core/src/library/manager.rs index 61b016f16ffe..3cad1b9afe1c 100644 --- a/core/src/library/manager.rs +++ b/core/src/library/manager.rs @@ -1245,6 +1245,7 @@ impl LibraryManager { (ItemType::Recents, "Recents", 1), (ItemType::Favorites, "Favorites", 2), (ItemType::FileKinds, "File Kinds", 3), + (ItemType::Redundancy, "Redundancy", 4), ]; use crate::infra::db::entities::space_item::{Column as ItemColumn, Entity as ItemEntity}; diff --git a/core/src/volume/detection.rs b/core/src/volume/detection.rs index 58cd61f339be..565e31040be7 100644 --- a/core/src/volume/detection.rs +++ b/core/src/volume/detection.rs @@ -67,8 +67,8 @@ async fn enhance_volumes_with_fs_capabilities(volumes: &mut Vec) -> Volu } #[cfg(target_os = "linux")] crate::volume::types::FileSystem::ZFS => { - // Add ZFS pool and clone capability detection - fs::zfs::enhance_volume_from_mount(volume).await?; + // ZFS enhancement is handled in detect_linux_volumes with cached output + // to avoid running `zfs list` once per volume } #[cfg(target_os = "windows")] crate::volume::types::FileSystem::ReFS => { @@ -146,15 +146,25 @@ async fn detect_linux_volumes( let mut volumes = linux::detect_volumes(device_id, config).await?; // Enhance with filesystem-specific capabilities - for volume in &mut volumes { - match &volume.file_system { - crate::volume::types::FileSystem::Btrfs => { - fs::btrfs::enhance_volume_from_mount(volume).await?; - } - crate::volume::types::FileSystem::ZFS => { - fs::zfs::enhance_volume_from_mount(volume).await?; + // For ZFS, fetch dataset info once and apply to all ZFS volumes + let zfs_volumes_exist = volumes + .iter() + .any(|v| matches!(v.file_system, crate::volume::types::FileSystem::ZFS)); + + if zfs_volumes_exist { + let zfs_output = fs::zfs::fetch_zfs_list_output().await.ok(); + for volume in &mut volumes { + if matches!(volume.file_system, crate::volume::types::FileSystem::ZFS) { + if let Some(ref output) = zfs_output { + fs::zfs::enhance_volume_with_cached_output(volume, output); + } } - _ => {} + } + } + + for volume in &mut volumes { + if matches!(volume.file_system, crate::volume::types::FileSystem::Btrfs) { + fs::btrfs::enhance_volume_from_mount(volume).await?; } } diff --git a/core/src/volume/fs/zfs.rs b/core/src/volume/fs/zfs.rs index 899b912aecd8..93b87d8af600 100644 --- a/core/src/volume/fs/zfs.rs +++ b/core/src/volume/fs/zfs.rs @@ -371,6 +371,51 @@ fn parse_zfs_size(size_str: &str) -> Option { Some((number * multiplier as f64) as u64) } +/// Fetch `zfs list` output once for reuse across multiple volumes +pub async fn fetch_zfs_list_output() -> VolumeResult { + task::spawn_blocking(|| { + let output = Command::new("zfs") + .args([ + "list", + "-H", + "-o", + "name,mountpoint,used,available,type", + "-t", + "filesystem", + ]) + .output() + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!( + "Failed to run zfs list: {}", + e + )) + })?; + + if !output.status.success() { + return Err(crate::volume::error::VolumeError::platform( + "zfs list command failed".to_string(), + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + }) + .await + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)) + })? +} + +/// Enhance a volume using pre-fetched `zfs list` output (no subprocess call) +pub fn enhance_volume_with_cached_output(volume: &mut Volume, zfs_list_output: &str) { + if let Some(mount_point) = volume.mount_point.to_str() { + if let Ok(dataset_info) = + find_dataset_for_path(zfs_list_output, Path::new(mount_point)) + { + debug!("Enhanced ZFS volume with dataset info: {:?}", dataset_info); + } + } +} + /// Enhance volume with ZFS-specific information from mount point pub async fn enhance_volume_from_mount(volume: &mut Volume) -> VolumeResult<()> { use super::FilesystemHandler; diff --git a/packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx b/packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx index f75ec47553f0..2ee7b28c7831 100644 --- a/packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx +++ b/packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx @@ -35,6 +35,10 @@ const PALETTE_ITEMS: PaletteItem[] = [ type: "Sources", label: "Sources", }, + { + type: "Redundancy", + label: "Redundancy", + }, ]; function DraggablePaletteItem({ item }: { item: PaletteItem }) { diff --git a/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts b/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts index 12027acf1e87..825e6b1e42ff 100644 --- a/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts +++ b/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts @@ -6,6 +6,7 @@ import { Tag as TagIcon, Folders, Database, + ShieldCheck, } from "@phosphor-icons/react"; import { Location } from "@sd/assets/icons"; import type { @@ -69,6 +70,10 @@ export function isSourcesItem(t: ItemType): t is "Sources" { return t === "Sources"; } +export function isRedundancyItem(t: ItemType): t is "Redundancy" { + return t === "Redundancy"; +} + export function isSourceItem( t: ItemType, ): t is { Source: { source_id: string } } { @@ -89,6 +94,7 @@ function getItemIcon(itemType: ItemType): IconData { if (isFavoritesItem(itemType)) return { type: "component", icon: Heart }; if (isFileKindsItem(itemType)) return { type: "component", icon: Folders }; if (isSourcesItem(itemType)) return { type: "component", icon: Database }; + if (isRedundancyItem(itemType)) return { type: "component", icon: ShieldCheck }; if (isLocationItem(itemType)) return { type: "image", icon: Location }; if (isVolumeItem(itemType)) return { type: "component", icon: HardDrive }; if (isTagItem(itemType)) return { type: "component", icon: TagIcon }; @@ -104,6 +110,7 @@ function getItemLabel(itemType: ItemType, resolvedFile?: File | null): string { if (isFavoritesItem(itemType)) return "Favorites"; if (isFileKindsItem(itemType)) return "File Kinds"; if (isSourcesItem(itemType)) return "Sources"; + if (isRedundancyItem(itemType)) return "Redundancy"; if (isLocationItem(itemType)) return resolvedFile?.name || "Unnamed Location"; if (isVolumeItem(itemType)) return resolvedFile?.name || (itemType as { Volume: { volume_id: string; name?: string } }).Volume.name || "Unnamed Volume"; if (isTagItem(itemType)) return resolvedFile?.name || "Unnamed Tag"; @@ -134,6 +141,7 @@ function getItemPath( if (isFavoritesItem(itemType)) return "/favorites"; if (isFileKindsItem(itemType)) return "/file-kinds"; if (isSourcesItem(itemType)) return "/sources"; + if (isRedundancyItem(itemType)) return "/redundancy"; if (isLocationItem(itemType)) { // Use explorer route with location's SD path (passed from item.sd_path) diff --git a/packages/interface/src/components/SpacesSidebar/index.tsx b/packages/interface/src/components/SpacesSidebar/index.tsx index ce44adddb560..6b66547dc9a0 100644 --- a/packages/interface/src/components/SpacesSidebar/index.tsx +++ b/packages/interface/src/components/SpacesSidebar/index.tsx @@ -13,7 +13,6 @@ import { GearSix, ListBullets, Palette, - ShieldCheck } from '@phosphor-icons/react'; import {useSidebarStore} from '@sd/ts-client'; import type { @@ -557,11 +556,6 @@ export function SpacesSidebar({isPreviewActive = false}: SpacesSidebarProps) { getSpeedHistory={getSpeedHistory} navigate={navigate} /> - navigate('/redundancy')} - />

{isAtRisk ? "At-Risk" : "Redundant"} Files diff --git a/packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx b/packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx index 98e4f647b1e7..80b3832d3720 100644 --- a/packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx +++ b/packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx @@ -1,5 +1,4 @@ import { motion } from "framer-motion"; -import { Tooltip } from "@spacedrive/primitives"; import { useNavigate } from "react-router-dom"; interface RedundancyVolumeBarProps { @@ -59,42 +58,40 @@ export function RedundancyVolumeBar({
{/* Redundant segment (safe) */} {redundantPercent > 0 && ( - - - navigate( - `/redundancy/at-risk?volume=${volumeUuid}&at_risk=false`, - ) - } - /> - + + navigate( + `/redundancy/at-risk?volume=${volumeUuid}&at_risk=false`, + ) + } + /> )} {/* At-risk segment */} {atRiskPercent > 0 && ( - - - navigate( - `/redundancy/at-risk?volume=${volumeUuid}&at_risk=true`, - ) - } - /> - + + navigate( + `/redundancy/at-risk?volume=${volumeUuid}&at_risk=true`, + ) + } + /> )} {/* Unindexed segment */} {unindexedPercent > 0 && ( @@ -114,13 +111,13 @@ export function RedundancyVolumeBar({ {/* Legend */}
-
+
Redundant ({formatBytes(redundantBytes)})
-
+
At Risk ({formatBytes(atRiskBytes)}) diff --git a/packages/interface/src/routes/redundancy/index.tsx b/packages/interface/src/routes/redundancy/index.tsx index 0200bd491bca..d041f51594b6 100644 --- a/packages/interface/src/routes/redundancy/index.tsx +++ b/packages/interface/src/routes/redundancy/index.tsx @@ -36,10 +36,10 @@ export function RedundancyDashboard() { const scoreColor = scorePercent >= 75 - ? "text-green-400" + ? "text-status-success" : scorePercent >= 40 - ? "text-amber-400" - : "text-red-400"; + ? "text-status-warning" + : "text-status-error"; const topBarTitle = useMemo( () => ( @@ -142,12 +142,12 @@ export function RedundancyDashboard() { {/* At-Risk Callout */} - -
-
- )} - - )} -
+ ); } diff --git a/packages/interface/src/routes/redundancy/compare.tsx b/packages/interface/src/routes/redundancy/compare.tsx index 68b73dfe355d..f1a508bbf9ba 100644 --- a/packages/interface/src/routes/redundancy/compare.tsx +++ b/packages/interface/src/routes/redundancy/compare.tsx @@ -1,28 +1,24 @@ /** * Volume Comparison View * - * Select two volumes to see what's unique to each and what's shared. + * Pick two volumes and see which files are unique to each or shared. + * Once both volumes are selected, switches the Explorer into filtered mode + * with the appropriate SearchFilters — the real ExplorerView renders the + * results so users keep selection, QuickPreview, context menus, drag-drop, etc. */ -import { useState, useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ArrowLeft, ArrowsLeftRight, ShieldCheck } from "@phosphor-icons/react"; -import { TopBarPortal, TopBarItem } from "../../TopBar"; import { CircleButton } from "@spacedrive/primitives"; +import type { SearchFilters } from "@sd/ts-client"; +import { TopBarPortal, TopBarItem } from "../../TopBar"; import { useLibraryQuery } from "../../contexts/SpacedriveContext"; -import type { SearchFilters, File as SdFile } from "@sd/ts-client"; +import { ExplorerView, useExplorer } from "../explorer"; -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; -} - -type CompareMode = "unique_a" | "unique_b" | "shared"; +type CompareMode = "unique_a" | "shared" | "unique_b"; -const NULL_FILTERS: SearchFilters = { +const EMPTY_FILTERS: SearchFilters = { file_types: null, tags: null, date_range: null, @@ -38,13 +34,22 @@ const NULL_FILTERS: SearchFilters = { max_volume_count: null, }; +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + export function CompareVolumes() { const navigate = useNavigate(); const [volumeA, setVolumeA] = useState(null); const [volumeB, setVolumeB] = useState(null); const [mode, setMode] = useState("unique_a"); - // Fetch volume list for the picker + const { enterFilteredMode, exitFilteredMode, setSortBy } = useExplorer(); + const { data: summaryData } = useLibraryQuery({ type: "redundancy.summary", input: {}, @@ -52,47 +57,76 @@ export function CompareVolumes() { const volumes = summaryData?.volumes ?? []; - // Build search filters based on compare mode - const filters: SearchFilters | null = useMemo(() => { - if (!volumeA || !volumeB) return null; + const volumeAName = + volumes.find((v) => v.volume_uuid === volumeA)?.display_name ?? + "Volume A"; + const volumeBName = + volumes.find((v) => v.volume_uuid === volumeB)?.display_name ?? + "Volume B"; + + const hasBoth = !!volumeA && !!volumeB; + const filters = useMemo(() => { + if (!hasBoth) return null; switch (mode) { case "unique_a": - return { ...NULL_FILTERS, on_volumes: [volumeA], not_on_volumes: [volumeB] }; + return { + ...EMPTY_FILTERS, + on_volumes: [volumeA!], + not_on_volumes: [volumeB!], + }; case "unique_b": - return { ...NULL_FILTERS, on_volumes: [volumeB], not_on_volumes: [volumeA] }; + return { + ...EMPTY_FILTERS, + on_volumes: [volumeB!], + not_on_volumes: [volumeA!], + }; case "shared": - return { ...NULL_FILTERS, on_volumes: [volumeA, volumeB], min_volume_count: 2 }; + return { + ...EMPTY_FILTERS, + on_volumes: [volumeA!, volumeB!], + min_volume_count: 2, + }; } - }, [volumeA, volumeB, mode]); + }, [hasBoth, mode, volumeA, volumeB]); - // Search with redundancy filters - const { data: searchData, isLoading: searchLoading } = useLibraryQuery( - { - type: "search.files", - input: { - query: "", - scope: "Library", - mode: "Fast", - filters: filters ?? NULL_FILTERS, - sort: { field: "Size", direction: "Desc" }, - pagination: { limit: 50, offset: 0 }, - }, - }, - { enabled: !!filters }, - ); + const label = useMemo(() => { + if (!hasBoth) return "Compare Volumes"; + switch (mode) { + case "unique_a": + return `Unique to ${volumeAName}`; + case "unique_b": + return `Unique to ${volumeBName}`; + case "shared": + return `Shared between ${volumeAName} & ${volumeBName}`; + } + }, [hasBoth, mode, volumeAName, volumeBName]); - const files = searchData?.files ?? []; - const totalCount = searchData?.total_found ?? 0; + // Sync filtered mode whenever selection changes (only when both selected) + useEffect(() => { + if (filters) { + enterFilteredMode(filters, label); + } else { + // Ensure we're not stuck in an old filtered state if user clears a picker + exitFilteredMode(); + } + }, [enterFilteredMode, exitFilteredMode, filters, label]); - const volumeAName = - volumes.find((v) => v.volume_uuid === volumeA)?.display_name ?? - "Volume A"; - const volumeBName = - volumes.find((v) => v.volume_uuid === volumeB)?.display_name ?? - "Volume B"; + // Default sort to size (largest first) on mount. Guarded with a ref + // because setSortBy's identity churns when context deps update. + const didSetSort = useRef(false); + useEffect(() => { + if (didSetSort.current) return; + didSetSort.current = true; + setSortBy("size"); + }, [setSortBy]); - const topBarContent = useMemo( + // Exit filtered mode when leaving the route + useEffect(() => { + return () => exitFilteredMode(); + }, [exitFilteredMode]); + + const titleItem = useMemo( () => (
navigate("/redundancy")} /> - -

Compare Volumes

+ + {label}
), - [navigate], + [navigate, label], ); return ( <> - {topBarContent} + {titleItem} } />
-
- {/* Volume Pickers */} -
+ {/* Picker + mode toggle */} +
+
- -
- {/* Mode Toggle */} - {volumeA && volumeB && ( + {hasBoth && (
)} +
- {/* Results */} - {!volumeA || !volumeB ? ( + {/* Results */} +
+ {!hasBoth ? (
Select two volumes to compare their contents
- ) : searchLoading ? ( -
- Loading comparison... -
- ) : files.length === 0 ? ( -
- - - {mode === "shared" - ? "No shared files found between these volumes" - : `No unique files found on ${mode === "unique_a" ? volumeAName : volumeBName}`} - -
) : ( -
-
- {totalCount.toLocaleString()} files found -
-
- - - - - - - - - - {files.map((file: SdFile) => ( - - - - - - ))} - -
- Name - - Size - - Extension -
- {file.name} - - {formatBytes(file.size)} - - {file.extension || "\u2014"} -
-
-
+ )}
@@ -278,9 +251,7 @@ function ModeButton({