feat(types): generate TypeScript types for core and tauri applications

This commit introduces auto-generated TypeScript types for both the core and Tauri applications, enhancing type safety and consistency across the codebase. The generated types include various data structures and interfaces used in the applications, facilitating better integration and development experience. The types are generated using Specta and are intended to be maintained automatically, ensuring they remain up-to-date with the underlying data models.
This commit is contained in:
Jamie Pine
2026-01-12 19:37:40 -08:00
parent bab1675a10
commit ccf421bc49
14 changed files with 9942 additions and 273 deletions

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -352,6 +352,7 @@ impl DeviceManager {
is_current: true,
is_paired: false,
is_connected: false,
connection_method: None, // Current device doesn't connect to itself
}
}

View File

@@ -106,6 +106,35 @@ pub struct Device {
/// Whether this device is currently connected via network
#[serde(default)]
pub is_connected: bool,
/// Connection method when connected (Direct, Relay, or Mixed)
#[serde(default)]
#[specta(optional)]
pub connection_method: Option<ConnectionMethod>,
}
/// Network connection method for a device
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Type)]
pub enum ConnectionMethod {
/// Direct peer-to-peer connection (mDNS/local network)
Direct,
/// Connection via relay server
Relay,
/// Mixed connection (both direct and relay)
Mixed,
}
impl ConnectionMethod {
/// Convert from Iroh's ConnectionType
pub fn from_iroh_connection_type(conn_type: iroh::endpoint::ConnectionType) -> Option<Self> {
use iroh::endpoint::ConnectionType;
match conn_type {
ConnectionType::Direct(_) => Some(Self::Direct),
ConnectionType::Relay(_) => Some(Self::Relay),
ConnectionType::Mixed(_, _) => Some(Self::Mixed),
ConnectionType::None => None,
}
}
}
/// Operating system types
@@ -184,6 +213,7 @@ impl Device {
is_current: false,
is_paired: false,
is_connected: false,
connection_method: None,
}
}
@@ -223,6 +253,7 @@ impl Device {
pub fn from_network_info(
info: &crate::service::network::device::DeviceInfo,
is_connected: bool,
connection_method: Option<ConnectionMethod>,
) -> Self {
use crate::service::network::device::DeviceType;
@@ -292,6 +323,7 @@ impl Device {
is_current: false,
is_paired: true,
is_connected,
connection_method,
}
}
}
@@ -1158,6 +1190,7 @@ impl TryFrom<entities::device::Model> for Device {
is_current: false,
is_paired: false,
is_connected: false,
connection_method: None, // Populated by caller when connection info available
})
}
}

View File

@@ -24,7 +24,7 @@ pub mod volume;
// Re-export commonly used types
pub use addressing::{PathResolutionError, SdPath, SdPathBatch, SdPathParseError};
pub use content_identity::{ContentHashError, ContentHashGenerator, ContentIdentity, ContentKind};
pub use device::{Device, OperatingSystem};
pub use device::{ConnectionMethod, Device, OperatingSystem};
pub use file::{EntryKind, File, Sidecar};
pub use library::Library;
pub use location::{IndexMode, Location, ScanState};

View File

@@ -329,6 +329,8 @@ impl Core {
context.set_networking(networking.clone()).await;
// Set event bus for device registry to emit ResourceChanged events
networking.set_event_bus(context.events.clone()).await;
// Set library manager for device registry to query complete device data
networking.set_library_manager(Arc::downgrade(&context.libraries().await)).await;
info!("Networking service registered in context");
// Initialize sync service on already-loaded libraries
@@ -540,6 +542,8 @@ impl Core {
// Set event bus for device registry to emit ResourceChanged events
networking_service.set_event_bus(self.events.clone()).await;
// Set library manager for device registry to query complete device data
networking_service.set_library_manager(Arc::downgrade(&self.context.libraries().await)).await;
}
logger.info("Networking initialized successfully").await;

View File

@@ -120,6 +120,15 @@ impl LibraryQuery for ListLibraryDevicesQuery {
device.is_current = device.id == current_device_id;
device.is_paired = false; // Updated below if device is also in network registry
device.is_connected = false; // Updated below if device is connected via network
// For remote devices, set is_online based on network connection (will be updated below)
// For current device, it's always online
if device.is_current {
device.is_online = true;
} else {
device.is_online = false; // Will be set to true if connected via network
}
result.push(device);
}
Err(e) => {
@@ -128,64 +137,84 @@ impl LibraryQuery for ListLibraryDevicesQuery {
}
}
// If show_paired is true, also fetch paired network devices
if self.input.show_paired {
// Get networking service
if let Some(networking) = context.get_networking().await {
let device_registry = networking.device_registry();
let registry = device_registry.read().await;
let all_devices = registry.get_all_devices();
// Always check network registry to update connection status for database devices
// and optionally add paired-only devices
if let Some(networking) = context.get_networking().await {
let device_registry = networking.device_registry();
let registry = device_registry.read().await;
let all_devices = registry.get_all_devices();
// Get Iroh endpoint for verifying actual connection status
// This is the source of truth, not the cached DeviceState
let endpoint = networking.endpoint();
// Get Iroh endpoint for verifying actual connection status
// This is the source of truth, not the cached DeviceState
let endpoint = networking.endpoint();
for (device_id, state) in all_devices {
use crate::service::network::device::DeviceState;
for (device_id, state) in all_devices {
use crate::service::network::device::DeviceState;
// Query Iroh directly for actual connection status
let is_actually_connected = if let Some(ep) = endpoint {
registry.is_node_connected(ep, device_id)
// Query Iroh directly for actual connection status and method
let (is_actually_connected, connection_method) = if let Some(ep) = endpoint {
// Get node ID for this device
let node_id = registry.get_node_id_for_device(device_id);
if let Some(node_id) = node_id {
// Query Iroh for connection info
if let Some(remote_info) = ep.remote_info(node_id) {
let conn_method = crate::domain::device::ConnectionMethod::from_iroh_connection_type(remote_info.conn_type);
let is_connected = conn_method.is_some();
(is_connected, conn_method)
} else {
(false, None)
}
} else {
// No endpoint available, fall back to cached state
matches!(state, DeviceState::Connected { .. })
};
(false, None)
}
} else {
// No endpoint available, fall back to cached state
let is_connected = matches!(state, DeviceState::Connected { .. });
(is_connected, None)
};
// Check if this device is already in the library results
if let Some(existing) = result.iter_mut().find(|d| d.id == device_id) {
// Update pairing/connection status for library device that's also in network registry
match state {
DeviceState::Paired { .. }
| DeviceState::Connected { .. }
| DeviceState::Disconnected { .. } => {
existing.is_paired = true;
}
_ => {}
}
if is_actually_connected {
existing.is_connected = true;
existing.is_online = true;
// Check if this device is already in the library results
if let Some(existing) = result.iter_mut().find(|d| d.id == device_id) {
// Update pairing/connection status for library device that's also in network registry
match state {
DeviceState::Paired { .. }
| DeviceState::Connected { .. }
| DeviceState::Disconnected { .. } => {
existing.is_paired = true;
}
_ => {}
}
// Always update online/connected status based on current network state
// (database is_online column can be stale for remote devices)
existing.is_connected = is_actually_connected;
existing.is_online = is_actually_connected;
existing.connection_method = connection_method;
continue;
}
// Only add paired-only devices (not in database) if show_paired is true
if !self.input.show_paired {
continue;
}
let device_info = match state {
DeviceState::Paired { info, .. } => Some(info),
DeviceState::Connected { info, .. } => Some(info),
DeviceState::Disconnected { info, .. } => Some(info),
_ => None,
};
if let Some(info) = device_info {
// Filter by online status if requested
if !self.input.include_offline && !is_actually_connected {
continue;
}
let device_info = match state {
DeviceState::Paired { info, .. } => Some(info),
DeviceState::Connected { info, .. } => Some(info),
DeviceState::Disconnected { info, .. } => Some(info),
_ => None,
};
if let Some(info) = device_info {
// Filter by online status if requested
if !self.input.include_offline && !is_actually_connected {
continue;
}
// Convert network DeviceInfo to domain Device
let device = Device::from_network_info(&info, is_actually_connected);
result.push(device);
}
// Convert network DeviceInfo to domain Device
let mut device = Device::from_network_info(&info, is_actually_connected, connection_method);
result.push(device);
}
}
}

View File

@@ -111,14 +111,22 @@ impl CopyJobMetadata {
/// Update status of a file by source path
pub fn update_status(&mut self, source_path: &SdPath, status: CopyFileStatus) {
if let Some(entry) = self.files.iter_mut().find(|e| &e.source_path == source_path) {
if let Some(entry) = self
.files
.iter_mut()
.find(|e| &e.source_path == source_path)
{
entry.status = status;
}
}
/// Set error for a file by source path
pub fn set_error(&mut self, source_path: &SdPath, error: String) {
if let Some(entry) = self.files.iter_mut().find(|e| &e.source_path == source_path) {
if let Some(entry) = self
.files
.iter_mut()
.find(|e| &e.source_path == source_path)
{
entry.status = CopyFileStatus::Failed;
entry.error = Some(error);
}

View File

@@ -84,6 +84,9 @@ pub struct NetworkingEventLoop {
/// Active connections tracker (keyed by NodeId and ALPN)
active_connections: Arc<RwLock<std::collections::HashMap<(NodeId, Vec<u8>), Connection>>>,
/// Nodes that already have connection watchers spawned (to prevent duplicates)
watched_nodes: Arc<RwLock<std::collections::HashSet<NodeId>>>,
/// Logger for event loop operations
logger: Arc<dyn NetworkLogger>,
}
@@ -113,6 +116,7 @@ impl NetworkingEventLoop {
shutdown_tx,
identity,
active_connections,
watched_nodes: Arc::new(RwLock::new(std::collections::HashSet::new())),
logger,
}
}
@@ -214,6 +218,9 @@ impl NetworkingEventLoop {
connections.insert((remote_node_id, alpn_bytes), conn.clone());
}
// Spawn a task to watch for connection closure for instant reactivity
self.spawn_connection_watcher(conn.clone(), remote_node_id).await;
// For now, we'll need to detect ALPN from the first stream
// TODO: Find the correct way to get ALPN from iroh Connection
let alpn = PAIRING_ALPN; // Default to pairing, will be overridden based on stream detection
@@ -647,6 +654,9 @@ impl NetworkingEventLoop {
connections.insert((node_id, alpn_bytes.clone()), conn.clone());
}
// Spawn a task to watch for connection closure for instant reactivity
self.spawn_connection_watcher(conn.clone(), node_id).await;
self.logger
.info(&format!(
"Tracking outbound connection to {} (ALPN: {:?}), spawning stream handler",
@@ -756,6 +766,9 @@ impl NetworkingEventLoop {
connections.insert((node_id, alpn_bytes), conn.clone());
}
// Spawn a task to watch for connection closure for instant reactivity
self.spawn_connection_watcher(conn.clone(), node_id).await;
// Open appropriate stream based on protocol
match protocol {
"pairing" | "messaging" => {
@@ -969,4 +982,79 @@ impl NetworkingEventLoop {
}
}
}
/// Spawn a background task to watch for connection closure
///
/// This provides instant reactivity when connections drop, instead of waiting
/// for the 10-second polling interval in update_connection_states().
async fn spawn_connection_watcher(&self, conn: Connection, node_id: NodeId) {
// Check if we already have a watcher for this node
{
let mut watched = self.watched_nodes.write().await;
if watched.contains(&node_id) {
// Already watching this node, skip to prevent duplicates
return;
}
watched.insert(node_id);
}
let device_registry = self.device_registry.clone();
let active_connections = self.active_connections.clone();
let watched_nodes = self.watched_nodes.clone();
let logger = self.logger.clone();
tokio::spawn(async move {
// Wait for the connection to close
let close_reason = conn.closed().await;
logger
.info(&format!(
"Connection to {} closed instantly: {:?}",
node_id, close_reason
))
.await;
// Remove from active connections
{
let mut connections = active_connections.write().await;
connections.retain(|(nid, _alpn), _conn| *nid != node_id);
}
// Remove from watched nodes set so future reconnections can spawn a new watcher
{
let mut watched = watched_nodes.write().await;
watched.remove(&node_id);
}
// Find the device ID for this node and update state
let mut registry = device_registry.write().await;
if let Some(device_id) = registry.get_device_by_node_id(node_id) {
// Use update_device_from_connection with ConnectionType::None
// This handles any current state and transitions appropriately
if let Err(e) = registry
.update_device_from_connection(
device_id,
node_id,
iroh::endpoint::ConnectionType::None,
None,
)
.await
{
logger
.warn(&format!(
"Failed to update device {} after connection closed: {}",
device_id, e
))
.await;
} else {
logger
.info(&format!(
"Device {} instantly marked as offline after connection closed",
device_id
))
.await;
}
}
});
}
}

View File

@@ -107,6 +107,9 @@ pub struct NetworkingService {
/// Each ALPN protocol requires its own connection since ALPN is negotiated at connection establishment
active_connections: Arc<RwLock<std::collections::HashMap<(NodeId, Vec<u8>), Connection>>>,
/// Nodes that already have connection watchers spawned (to prevent duplicates)
watched_nodes: Arc<RwLock<std::collections::HashSet<NodeId>>>,
/// Sync multiplexer for routing sync messages to correct library
sync_multiplexer: Arc<SyncMultiplexer>,
@@ -159,6 +162,7 @@ impl NetworkingService {
device_registry,
event_sender,
active_connections: Arc::new(RwLock::new(std::collections::HashMap::new())),
watched_nodes: Arc::new(RwLock::new(std::collections::HashSet::new())),
sync_multiplexer,
logger,
})
@@ -173,6 +177,15 @@ impl NetworkingService {
registry.set_event_bus(event_bus);
}
/// Set the library manager for querying complete device data
///
/// This enables the device registry to emit complete device data with hardware_model
/// by querying the library database instead of just using network DeviceInfo.
pub async fn set_library_manager(&self, library_manager: std::sync::Weak<crate::library::LibraryManager>) {
let mut registry = self.device_registry.write().await;
registry.set_library_manager(library_manager);
}
/// Start the networking service
pub async fn start(&mut self) -> Result<()> {
// Check if already started
@@ -965,6 +978,82 @@ impl NetworkingService {
)
}
/// Spawn a background task to watch for connection closure
///
/// This provides instant reactivity when connections drop by waiting on
/// Iroh's Connection::closed() future, instead of relying on the 10-second
/// polling interval in update_connection_states().
async fn spawn_connection_watcher(&self, conn: Connection, node_id: NodeId) {
// Check if we already have a watcher for this node
{
let mut watched = self.watched_nodes.write().await;
if watched.contains(&node_id) {
// Already watching this node, skip to prevent duplicates
return;
}
watched.insert(node_id);
}
let device_registry = self.device_registry.clone();
let active_connections = self.active_connections.clone();
let watched_nodes = self.watched_nodes.clone();
let logger = self.logger.clone();
tokio::spawn(async move {
// Wait for the connection to close
let close_reason = conn.closed().await;
logger
.info(&format!(
"Connection to {} closed instantly: {:?}",
node_id, close_reason
))
.await;
// Remove from active connections
{
let mut connections = active_connections.write().await;
connections.retain(|(nid, _alpn), _conn| *nid != node_id);
}
// Remove from watched nodes set so future reconnections can spawn a new watcher
{
let mut watched = watched_nodes.write().await;
watched.remove(&node_id);
}
// Find the device ID for this node and update state
let mut registry = device_registry.write().await;
if let Some(device_id) = registry.get_device_by_node_id(node_id) {
// Use update_device_from_connection with ConnectionType::None
// This handles any current state and transitions appropriately
if let Err(e) = registry
.update_device_from_connection(
device_id,
node_id,
iroh::endpoint::ConnectionType::None,
None,
)
.await
{
logger
.warn(&format!(
"Failed to update device {} after connection closed: {}",
device_id, e
))
.await;
} else {
logger
.info(&format!(
"Device {} instantly marked as offline after connection closed",
device_id
))
.await;
}
}
});
}
/// Connect to a node at a specific address
///
/// # Parameters
@@ -989,7 +1078,7 @@ impl NetworkingService {
let node_id = node_addr.node_id;
{
let mut connections = self.active_connections.write().await;
connections.insert((node_id, PAIRING_ALPN.to_vec()), conn);
connections.insert((node_id, PAIRING_ALPN.to_vec()), conn.clone());
self.logger
.info(&format!(
"Tracked outbound pairing connection to {}",
@@ -998,6 +1087,9 @@ impl NetworkingService {
.await;
}
// Spawn a task to watch for connection closure for instant reactivity
self.spawn_connection_watcher(conn, node_id).await;
Ok(())
} else {
Err(NetworkingError::ConnectionFailed(
@@ -1160,9 +1252,12 @@ impl NetworkingService {
// Track the connection for the pairing protocol
{
let mut connections = self.active_connections.write().await;
connections.insert((node_id, PAIRING_ALPN.to_vec()), conn);
connections.insert((node_id, PAIRING_ALPN.to_vec()), conn.clone());
}
// Spawn a task to watch for connection closure for instant reactivity
self.spawn_connection_watcher(conn, node_id).await;
Ok(())
}
Ok(Err(e)) => {

View File

@@ -36,6 +36,9 @@ pub struct DeviceRegistry {
/// Event bus for emitting resource change events
event_bus: Option<Arc<EventBus>>,
/// Library manager for querying device data from database
library_manager: Option<std::sync::Weak<crate::library::LibraryManager>>,
}
impl DeviceRegistry {
@@ -55,6 +58,7 @@ impl DeviceRegistry {
persistence,
logger,
event_bus: None,
library_manager: None,
}
}
@@ -63,14 +67,126 @@ impl DeviceRegistry {
self.event_bus = Some(event_bus);
}
/// Emit a ResourceChanged event for a device
/// Set the library manager for querying device data
pub fn set_library_manager(&mut self, library_manager: std::sync::Weak<crate::library::LibraryManager>) {
self.library_manager = Some(library_manager);
}
/// Update device online status in library database
async fn update_device_online_status(&self, device_id: Uuid, is_online: bool) {
let Some(library_manager_weak) = &self.library_manager else {
return;
};
let Some(library_manager) = library_manager_weak.upgrade() else {
return;
};
// Update in all libraries (device data is synced across libraries)
let all_libraries = library_manager.list().await;
for lib in all_libraries {
let db = lib.db().conn();
// Update device is_online status in database
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
match crate::infra::db::entities::device::Entity::find()
.filter(crate::infra::db::entities::device::Column::Uuid.eq(device_id.as_bytes().to_vec()))
.one(db)
.await
{
Ok(Some(model)) => {
let mut active_model: crate::infra::db::entities::device::ActiveModel = model.into();
active_model.is_online = Set(is_online);
active_model.last_seen_at = Set(chrono::Utc::now());
if let Err(e) = active_model.update(db).await {
tracing::warn!(
device_id = %device_id,
error = %e,
"Failed to update device online status in database"
);
}
}
Ok(None) => {
// Device not in this library's database, skip
}
Err(e) => {
tracing::warn!(
device_id = %device_id,
error = %e,
"Failed to query device from database"
);
}
}
}
}
/// Emit a ResourceChanged event for a device with complete database data
fn emit_device_changed(&self, device_id: Uuid, info: &DeviceInfo, is_connected: bool) {
let Some(event_bus) = &self.event_bus else {
return;
};
// Convert network DeviceInfo to domain Device
let device = crate::domain::Device::from_network_info(info, is_connected);
// Try to query the full device from database to get hardware_model
let device = if let Some(library_manager_weak) = &self.library_manager {
if let Some(library_manager) = library_manager_weak.upgrade() {
// Try to get device from first available library
// (device data should be consistent across libraries since it's synced)
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
let device_result = handle.block_on(async {
// Get all libraries
let all_libraries = library_manager.list().await;
for lib in all_libraries {
let db = lib.db().conn();
// Query device from database by UUID
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
if let Ok(Some(model)) = crate::infra::db::entities::device::Entity::find()
.filter(crate::infra::db::entities::device::Column::Uuid.eq(device_id.as_bytes().to_vec()))
.one(db)
.await
{
// Convert to domain Device
if let Ok(mut device) = crate::domain::Device::try_from(model) {
// Merge with network state
device.is_connected = is_connected;
device.is_online = is_connected;
device.is_paired = true;
// Note: We don't have access to endpoint here to get connection_method
// but that's okay - the query will populate it with the correct data
// from Iroh's RemoteInfo
device.connection_method = None;
return Some(device);
}
}
}
None
});
if let Some(device) = device_result {
device
} else {
// Fallback to network data if database query fails
crate::domain::Device::from_network_info(info, is_connected, None)
}
} else {
// No runtime, fallback to network data
crate::domain::Device::from_network_info(info, is_connected, None)
}
} else {
// Libraries dropped, fallback to network data
crate::domain::Device::from_network_info(info, is_connected, None)
}
} else {
// No libraries set, fallback to network data
crate::domain::Device::from_network_info(info, is_connected, None)
};
use crate::domain::resource::EventEmitter;
if let Err(e) = device.emit_changed(event_bus) {
@@ -329,6 +445,9 @@ impl DeviceRegistry {
.await;
}
// Update device online status in library database
self.update_device_online_status(device_id, true).await;
// Emit ResourceChanged event for UI reactivity
self.emit_device_changed(device_id, &info, true);
@@ -383,6 +502,9 @@ impl DeviceRegistry {
.await;
}
// Update device online status in library database
self.update_device_online_status(device_id, false).await;
// Emit ResourceChanged event for UI reactivity
self.emit_device_changed(device_id, &info, false);
@@ -564,6 +686,9 @@ impl DeviceRegistry {
.await
.ok();
// Update device online status in library database
self.update_device_online_status(device_id, true).await;
// Emit ResourceChanged event for UI reactivity
self.emit_device_changed(device_id, &info, true);
}
@@ -601,6 +726,9 @@ impl DeviceRegistry {
.await
.ok();
// Update device online status in library database
self.update_device_online_status(device_id, false).await;
// Emit ResourceChanged event for UI reactivity
self.emit_device_changed(device_id, &info, false);
}

View File

@@ -85,11 +85,23 @@ function SpeedGraphVisualization({
// Add 10% headroom to max for better visualization
const yMax = maxRate * 1.1;
// Generate points for the line
const points = speedHistory.map((sample, index) => {
// Apply exponential smoothing to debounce rapid changes while retaining shape
const smoothingFactor = 0.3; // Lower = smoother (0.1-0.4 range works well)
const smoothedRates = speedHistory.reduce<number[]>((acc, sample, index) => {
if (index === 0) {
acc.push(sample.bytesPerSecond);
} else {
const smoothed = acc[index - 1] + smoothingFactor * (sample.bytesPerSecond - acc[index - 1]);
acc.push(smoothed);
}
return acc;
}, []);
// Generate points for the line using smoothed rates
const points = smoothedRates.map((smoothedRate, index) => {
const x = padding.left + (index / Math.max(speedHistory.length - 1, 1)) * graphWidth;
const y = padding.top + graphHeight - (sample.bytesPerSecond / yMax) * graphHeight;
return { x, y, rate: sample.bytesPerSecond };
const y = padding.top + graphHeight - (smoothedRate / yMax) * graphHeight;
return { x, y, rate: smoothedRate };
});
// Generate SVG path for smooth curve using quadratic bezier

View File

@@ -2,6 +2,8 @@ import { Pause, Play, X, CaretDown } from "@phosphor-icons/react";
import { motion } from "framer-motion";
import type { JobRenderer, JobRendererProps, JobDetailsRendererProps } from "./index";
import { CopyJobDetails } from "../components/CopyJobDetails";
import { useNormalizedQuery } from "../../../contexts/SpacedriveContext";
import type { Device } from "@sd/ts-client";
/**
* Map strategy name to display label (enables i18n in future)
@@ -11,13 +13,13 @@ function getStrategyLabel(strategyName: string | undefined, isMove: boolean): st
switch (strategyName) {
case "RemoteTransfer":
return isMove ? "Network move" : "Network copy";
return "Network";
case "LocalMove":
return "Atomic move";
return "Atomic";
case "FastCopy":
return "Fast copy";
return "Fast";
case "LocalStream":
return isMove ? "Streaming move" : "Streaming copy";
return "Streaming";
default:
return strategyName;
}
@@ -119,16 +121,38 @@ function FileCopyCardContent({
const strategyName = metadata?.strategy?.strategy_name;
const strategyLabel = getStrategyLabel(strategyName, job.action_context?.action_type === "files.move");
// Fetch devices to determine if destination is remote
const { data: devices } = useNormalizedQuery<any, Device[]>({
wireMethod: "query:devices.list",
input: { include_offline: true, include_details: false },
resourceType: "device",
});
// Determine if this is a move operation
const isMove = job.action_context?.action_type === "files.move";
// Check if this is a cross-device transfer from metadata
const isCrossDevice = metadata?.strategy?.is_cross_device === true;
// Find current device and infer destination device
const currentDevice = devices?.find(d => d.is_current);
// For cross-device transfers, the destination is the device that's NOT current
// (assuming only 2 devices in the transfer scenario)
const destinationDevice = isCrossDevice && currentDevice
? devices?.find(d => !d.is_current)
: null;
// Calculate title
const fileCount = generic?.completion?.total || 0;
const fileName = extractFirstFileName(job);
const title =
fileCount > 1
? `${isMove ? "Moving" : "Copying"} ${fileCount} items`
: `${isMove ? "Moving" : "Copying"} '${fileName}'`;
const baseTitle = fileCount > 1
? `${isMove ? "Moving" : "Copying"} ${fileCount} items`
: `${isMove ? "Moving" : "Copying"} '${fileName}'`;
const title = destinationDevice
? `${baseTitle} to ${destinationDevice.name}`
: baseTitle;
// Calculate rich subtext with progress, speed, and ETA
const completed = generic?.completion?.completed || 0;

View File

@@ -1,47 +1,54 @@
import { useState, useRef, useEffect } from "react";
import { motion } from "framer-motion";
import {
HardDrive,
Plus,
Database,
CaretLeft,
CaretRight,
} from "@phosphor-icons/react";
import Masonry from "react-masonry-css";
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 DatabaseIcon from "@sd/assets/icons/Database.png";
import DriveAmazonS3Icon from "@sd/assets/icons/Drive-AmazonS3.png";
import DriveGoogleDriveIcon from "@sd/assets/icons/Drive-GoogleDrive.png";
import DriveDropboxIcon from "@sd/assets/icons/Drive-Dropbox.png";
import LocationIcon from "@sd/assets/icons/Location.png";
import { TopBarButton } from "@sd/ui";
import {
useNormalizedQuery,
useLibraryMutation,
getDeviceIcon,
useCoreQuery,
} from "../../contexts/SpacedriveContext";
Cpu,
Database,
HardDrive,
Memory,
Plus
} 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';
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 LocationIcon from '@sd/assets/icons/Location.png';
import ServerIcon from '@sd/assets/icons/Server.png';
import type {
VolumeListOutput,
VolumeListQueryInput,
VolumeItem,
Device,
ListLibraryDevicesInput,
JobListItem,
ListLibraryDevicesInput,
Location,
LocationsListOutput,
LocationsListQueryInput,
Location,
} from "@sd/ts-client";
import { useJobs } from "../../components/JobManager/hooks/useJobs";
import { JobCard } from "../../components/JobManager/components/JobCard";
import clsx from "clsx";
VolumeItem,
VolumeListOutput,
VolumeListQueryInput
} from '@sd/ts-client';
import {TopBarButton} from '@sd/ui';
import clsx from 'clsx';
import {motion} from 'framer-motion';
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 {
getDeviceIcon,
useCoreQuery,
useLibraryMutation,
useNormalizedQuery
} from '../../contexts/SpacedriveContext';
// Temporary type extension until types are regenerated
type DeviceWithConnection = Device & {
connection_method?: 'Direct' | 'Relay' | 'Mixed' | null;
};
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
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]}`;
}
@@ -49,71 +56,71 @@ function formatBytes(bytes: number): string {
function getVolumeIcon(volumeType: any, name?: string): string {
// Convert volume type to string if it's an enum variant object
const volumeTypeStr =
typeof volumeType === "string"
typeof volumeType === 'string'
? volumeType
: volumeType?.Other || JSON.stringify(volumeType);
// Check for cloud providers by name
if (name?.includes("S3")) return DriveAmazonS3Icon;
if (name?.includes("Google")) return DriveGoogleDriveIcon;
if (name?.includes("Dropbox")) return DriveDropboxIcon;
if (name?.includes('S3')) return DriveAmazonS3Icon;
if (name?.includes('Google')) return DriveGoogleDriveIcon;
if (name?.includes('Dropbox')) return DriveDropboxIcon;
// By type
if (volumeTypeStr === "Cloud") return DriveIcon;
if (volumeTypeStr === "Network") return ServerIcon;
if (volumeTypeStr === "Virtual") return DatabaseIcon;
if (volumeTypeStr === 'Cloud') return DriveIcon;
if (volumeTypeStr === 'Network') return ServerIcon;
if (volumeTypeStr === 'Virtual') return DatabaseIcon;
return HDDIcon;
}
function getDiskTypeLabel(diskType: string): string {
return diskType === "SSD" ? "SSD" : diskType === "HDD" ? "HDD" : diskType;
return diskType === 'SSD' ? 'SSD' : diskType === 'HDD' ? 'HDD' : diskType;
}
interface DevicePanelProps {
onLocationSelect?: (location: Location | null) => void;
}
export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
export function DevicePanel({onLocationSelect}: DevicePanelProps = {}) {
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(
null,
null
);
// Fetch all volumes using normalized cache
const { data: volumesData, isLoading: volumesLoading } = useNormalizedQuery<
const {data: volumesData, isLoading: volumesLoading} = useNormalizedQuery<
VolumeListQueryInput,
VolumeListOutput
>({
wireMethod: "query:volumes.list",
input: { filter: "All" },
resourceType: "volume",
wireMethod: 'query:volumes.list',
input: {filter: 'All'},
resourceType: 'volume'
});
// Fetch all devices using normalized cache
const { data: devicesData, isLoading: devicesLoading } = useNormalizedQuery<
const {data: devicesData, isLoading: devicesLoading} = useNormalizedQuery<
ListLibraryDevicesInput,
Device[]
DeviceWithConnection[]
>({
wireMethod: "query:devices.list",
input: { include_offline: true, include_details: false },
resourceType: "device",
wireMethod: 'query:devices.list',
input: {include_offline: true, include_details: false},
resourceType: 'device'
});
// Fetch all locations using normalized cache
const { data: locationsData, isLoading: locationsLoading } =
const {data: locationsData, isLoading: locationsLoading} =
useNormalizedQuery<LocationsListQueryInput, LocationsListOutput>({
wireMethod: "query:locations.list",
wireMethod: 'query:locations.list',
input: null,
resourceType: "location",
resourceType: 'location'
});
// Get all jobs with real-time updates (local jobs)
const { jobs: localJobs } = useJobs();
const {jobs: localJobs} = useJobs();
// Get remote device jobs
// TODO: This should have its own hook like useJobs, this will not work reactively
const { data: remoteJobsData } = useCoreQuery({
type: "jobs.remote.all_devices",
input: {},
const {data: remoteJobsData} = useCoreQuery({
type: 'jobs.remote.all_devices',
input: {}
});
// Merge local and remote jobs
@@ -129,19 +136,19 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
status: remoteJob.status,
progress: remoteJob.progress || 0,
action_type: null,
action_context: null,
action_context: null
}))
: []),
: [])
] as JobListItem[];
if (volumesLoading || devicesLoading || locationsLoading) {
return (
<div className="bg-app-box border border-app-line rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-app-line">
<h2 className="text-base font-semibold text-ink">
<div className="bg-app-box border-app-line overflow-hidden rounded-xl border">
<div className="border-app-line border-b px-6 py-4">
<h2 className="text-ink text-base font-semibold">
Storage Volumes
</h2>
<p className="text-sm text-ink-dull mt-1">
<p className="text-ink-dull mt-1 text-sm">
Loading volumes...
</p>
</div>
@@ -155,7 +162,7 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
// Filter to only show user-visible volumes
const userVisibleVolumes = volumes.filter(
(volume) => volume.is_user_visible !== false,
(volume) => volume.is_user_visible !== false
);
// Group volumes by device_id
@@ -168,7 +175,7 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
acc[deviceId].push(volume);
return acc;
},
{} as Record<string, VolumeItem[]>,
{} as Record<string, VolumeItem[]>
);
// Group locations by device slug
@@ -176,8 +183,8 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
(acc, location) => {
// Extract device_slug from sd_path
if (
typeof location.sd_path === "object" &&
"Physical" in location.sd_path
typeof location.sd_path === 'object' &&
'Physical' in location.sd_path
) {
const deviceSlug = location.sd_path.Physical.device_slug;
if (!acc[deviceSlug]) {
@@ -187,7 +194,7 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
}
return acc;
},
{} as Record<string, Location[]>,
{} as Record<string, Location[]>
);
// Create device map for quick lookup
@@ -196,7 +203,7 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
acc[device.id] = device;
return acc;
},
{} as Record<string, Device>,
{} as Record<string, DeviceWithConnection>
);
// Group jobs by device_id
@@ -209,20 +216,20 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
acc[deviceId].push(job);
return acc;
},
{} as Record<string, JobListItem[]>,
{} as Record<string, JobListItem[]>
);
const breakpointColumns = {
default: 3,
1600: 2,
1000: 1,
1000: 1
};
return (
<div className="">
<Masonry
breakpointCols={breakpointColumns}
className="flex -ml-4 w-auto"
className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{devices.map((device) => {
@@ -252,11 +259,11 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
})}
{devices.length === 0 && (
<div className="bg-app-box border border-app-line rounded-xl overflow-hidden">
<div className="text-center py-12 text-ink-faint">
<HardDrive className="size-12 mx-auto mb-3 opacity-20" />
<div className="bg-app-box border-app-line overflow-hidden rounded-xl border">
<div className="text-ink-faint py-12 text-center">
<HardDrive className="mx-auto mb-3 size-12 opacity-20" />
<p className="text-sm">No devices detected</p>
<p className="text-xs mt-1">
<p className="mt-1 text-xs">
Pair a device to get started
</p>
</div>
@@ -267,8 +274,41 @@ export function DevicePanel({ onLocationSelect }: DevicePanelProps = {}) {
);
}
interface ConnectionBadgeProps {
method: 'Direct' | 'Relay' | 'Mixed';
}
function ConnectionBadge({method}: ConnectionBadgeProps) {
const colors = {
Direct: {
dot: 'bg-green-500',
text: 'text-green-500',
label: 'Local'
},
Relay: {
dot: 'bg-yellow-500',
text: 'text-yellow-500',
label: 'Relay'
},
Mixed: {
dot: 'bg-blue-500',
text: 'text-blue-500',
label: 'Mixed'
}
};
const {dot, text, label} = colors[method];
return (
<div className="flex items-center gap-1.5">
<div className={clsx('size-2 rounded-full', dot)} />
<span className={clsx('text-xs font-medium', text)}>{label}</span>
</div>
);
}
interface DeviceCardProps {
device?: Device;
device?: DeviceWithConnection;
volumes: VolumeItem[];
jobs: JobListItem[];
locations: Location[];
@@ -282,22 +322,21 @@ function DeviceCard({
jobs,
locations,
selectedLocationId,
onLocationSelect,
onLocationSelect
}: DeviceCardProps) {
const deviceName = device?.name || "Unknown Device";
const deviceName = device?.name || 'Unknown Device';
const deviceIconSrc = device ? getDeviceIcon(device) : null;
const { pause, resume, getSpeedHistory } = useJobs();
const {pause, resume, getSpeedHistory} = useJobs();
// Format hardware specs
const cpuInfo = device?.cpu_model
? `${device.cpu_model}${device.cpu_physical_cores ? ` <20> ${device.cpu_physical_cores}C` : ""}`
? `${device.cpu_model}${device.cpu_cores_physical ? ` <20> ${device.cpu_cores_physical}C` : ''}`
: null;
const ramInfo = device?.memory_total
? formatBytes(device.memory_total)
const ramInfo = device?.memory_total_bytes
? formatBytes(device.memory_total_bytes)
: null;
// Convert form_factor enum to string
const formFactor = device?.form_factor
? typeof device.form_factor === "string"
? typeof device.form_factor === 'string'
? device.form_factor
: (device.form_factor as any)?.Other ||
JSON.stringify(device.form_factor)
@@ -306,72 +345,82 @@ function DeviceCard({
// Filter active jobs
const activeJobs = jobs.filter(
(j) => j.status === "running" || j.status === "paused",
(j) => j.status === 'running' || j.status === 'paused'
);
return (
<div className="bg-app-darkBox border border-app-line overflow-hidden rounded-xl mb-4">
<div className="bg-app-darkBox border-app-line mb-4 overflow-hidden rounded-xl border">
{/* Device Header */}
<div className="px-6 py-4 bg-app-box border-b border-app-line">
<div className="bg-app-box border-app-line border-b px-6 py-4">
<div className="flex items-center gap-4">
{/* Left: Device icon and name */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-3">
{deviceIconSrc ? (
<img
src={deviceIconSrc}
alt={deviceName}
className="size-8 opacity-80 flex-shrink-0"
className="size-8 flex-shrink-0 opacity-80"
/>
) : (
<HardDrive
className="size-8 text-ink flex-shrink-0"
className="text-ink size-8 flex-shrink-0"
weight="duotone"
/>
)}
<div className="min-w-0">
<h3 className="text-base font-semibold text-ink truncate">
{deviceName}
</h3>
<p className="text-sm text-ink-dull">
{volumes.length}{" "}
{volumes.length === 1 ? "volume" : "volumes"}
{device?.is_online === false && " <20> Offline"}
<div className="flex items-center gap-2">
<h3 className="text-ink truncate text-base font-semibold">
{deviceName}
</h3>
{device?.connection_method && (
<ConnectionBadge
method={device.connection_method}
/>
)}
</div>
<p className="text-ink-dull text-sm">
{volumes.length}{' '}
{volumes.length === 1 ? 'volume' : 'volumes'}
{device?.is_online === false && ' <20> Offline'}
</p>
</div>
</div>
{/* Right: Hardware specs */}
<div className="flex items-center gap-3 text-xs text-ink-dull">
{manufacturer && formFactor && (
<div className="text-right">
<div className="font-medium text-ink">
{manufacturer}
</div>
<div>{formFactor}</div>
</div>
)}
<div className="flex flex-col gap-1.5">
{/* CPU Model */}
{cpuInfo && (
<div className="text-right">
<div
className="text-ink text-right text-xs font-medium"
title={cpuInfo}
>
{device?.cpu_model || 'CPU'}
</div>
)}
{/* Stats row */}
<div className="text-ink-dull flex items-center justify-end gap-3 text-[11px]">
{device?.cpu_cores_physical && (
<div
className="font-medium text-ink truncate max-w-[180px]"
title={cpuInfo}
className="flex items-center gap-1"
title={`${device.cpu_cores_physical} Cores / ${device.cpu_cores_logical} Threads`}
>
{device?.cpu_model || "CPU"}
<Cpu className="size-3.5 opacity-50" weight="duotone" />
<span>
{Math.max(device.cpu_cores_physical || 0, device.cpu_cores_logical || 0)}
</span>
</div>
<div>
{device?.cpu_physical_cores}C /{" "}
{device?.cpu_cores_logical}T
)}
{ramInfo && (
<div
className="flex items-center gap-1"
title={`${ramInfo} Total Memory`}
>
<Memory className="size-3.5 opacity-50" weight="duotone" />
<span>{ramInfo}</span>
</div>
</div>
)}
{ramInfo && (
<div className="text-right">
<div className="font-medium text-ink">
{ramInfo}
</div>
<div>RAM</div>
</div>
)}
)}
</div>
</div>
</div>
</div>
@@ -379,7 +428,7 @@ function DeviceCard({
<div>
{/* Active Jobs Section */}
{activeJobs.length > 0 && (
<div className="px-3 py-3 border-b border-app-line bg-app/50 space-y-2">
<div className="border-app-line bg-app/50 space-y-2 border-b px-3 py-3">
{activeJobs.map((job) => (
<JobCard
key={job.id}
@@ -402,7 +451,7 @@ function DeviceCard({
)}
{/* Volumes for this device */}
<div className="px-3 py-3 space-y-3">
<div className="space-y-3 px-3 py-3">
{volumes.length > 0 ? (
volumes.map((volume, idx) => (
<VolumeBar
@@ -414,7 +463,7 @@ function DeviceCard({
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="text-ink-faint">
<HardDrive className="size-8 mx-auto mb-2 opacity-20" />
<HardDrive className="mx-auto mb-2 size-8 opacity-20" />
<p className="text-xs">No volumes</p>
</div>
</div>
@@ -434,7 +483,7 @@ interface LocationsScrollerProps {
function LocationsScroller({
locations,
selectedLocationId,
onLocationSelect,
onLocationSelect
}: LocationsScrollerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
@@ -442,37 +491,37 @@ function LocationsScroller({
const updateScrollState = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
const {scrollLeft, scrollWidth, clientWidth} = scrollRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
};
useEffect(() => {
updateScrollState();
window.addEventListener("resize", updateScrollState);
return () => window.removeEventListener("resize", updateScrollState);
window.addEventListener('resize', updateScrollState);
return () => window.removeEventListener('resize', updateScrollState);
}, [locations]);
const scroll = (direction: "left" | "right") => {
const scroll = (direction: 'left' | 'right') => {
if (!scrollRef.current) return;
const scrollAmount = 200;
scrollRef.current.scrollBy({
left: direction === "left" ? -scrollAmount : scrollAmount,
behavior: "smooth",
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
};
return (
<div className="px-3 py-3 border-b border-app-line">
<div className="border-app-line border-b px-3 py-3">
<div className="relative">
{/* Left fade and button */}
{canScrollLeft && (
<>
<div className="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-app-darkBox to-transparent z-10 pointer-events-none" />
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20">
<div className="from-app-darkBox pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-12 bg-gradient-to-r to-transparent" />
<div className="absolute left-1 top-1/2 z-20 -translate-y-1/2">
<TopBarButton
icon={CaretLeft}
onClick={() => scroll("left")}
onClick={() => scroll('left')}
/>
</div>
</>
@@ -482,8 +531,8 @@ function LocationsScroller({
<div
ref={scrollRef}
onScroll={updateScrollState}
className="flex gap-2 overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: "none" }}
className="scrollbar-hide flex gap-2 overflow-x-auto"
style={{scrollbarWidth: 'none'}}
>
{locations.map((location) => {
const isSelected = selectedLocationId === location.id;
@@ -497,14 +546,14 @@ function LocationsScroller({
onLocationSelect?.(location);
}
}}
className="flex flex-col items-center gap-2 p-1 rounded-lg transition-all min-w-[80px] flex-shrink-0"
className="flex min-w-[80px] flex-shrink-0 flex-col items-center gap-2 rounded-lg p-1 transition-all"
>
<div
className={clsx(
"rounded-lg p-2",
'rounded-lg p-2',
isSelected
? "bg-app-box"
: "bg-transparent",
? 'bg-app-box'
: 'bg-transparent'
)}
>
<img
@@ -513,13 +562,13 @@ function LocationsScroller({
className="size-12 opacity-80"
/>
</div>
<div className="w-full flex flex-col items-center">
<div className="flex w-full flex-col items-center">
<div
className={clsx(
"text-xs truncate px-2 py-0.5 rounded-md inline-block max-w-full",
'inline-block max-w-full truncate rounded-md px-2 py-0.5 text-xs',
isSelected
? "bg-accent text-white"
: "text-ink",
? 'bg-accent text-white'
: 'text-ink'
)}
>
{location.name}
@@ -533,11 +582,11 @@ function LocationsScroller({
{/* Right fade and button */}
{canScrollRight && (
<>
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-app-darkBox to-transparent z-10 pointer-events-none" />
<div className="absolute right-1 top-1/2 -translate-y-1/2 z-20">
<div className="from-app-darkBox pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-12 bg-gradient-to-l to-transparent" />
<div className="absolute right-1 top-1/2 z-20 -translate-y-1/2">
<TopBarButton
icon={CaretRight}
onClick={() => scroll("right")}
onClick={() => scroll('right')}
/>
</div>
</>
@@ -552,23 +601,23 @@ interface VolumeBarProps {
index: number;
}
function VolumeBar({ volume, index }: VolumeBarProps) {
const trackVolume = useLibraryMutation("volumes.track");
const indexVolume = useLibraryMutation("volumes.index");
function VolumeBar({volume, index}: VolumeBarProps) {
const trackVolume = useLibraryMutation('volumes.track');
const indexVolume = useLibraryMutation('volumes.index');
// Get current device to check if this volume is local
const { data: currentDevice } = useCoreQuery({
type: "devices.current",
input: null,
const {data: currentDevice} = useCoreQuery({
type: 'devices.current',
input: null
});
const handleTrack = async () => {
try {
await trackVolume.mutateAsync({
fingerprint: volume.fingerprint,
fingerprint: volume.fingerprint
});
} catch (error) {
console.error("Failed to track volume:", error);
console.error('Failed to track volume:', error);
}
};
@@ -576,11 +625,11 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
try {
const result = await indexVolume.mutateAsync({
fingerprint: volume.fingerprint,
scope: "Recursive",
scope: 'Recursive'
});
console.log("Volume indexed:", result.message);
console.log('Volume indexed:', result.message);
} catch (error) {
console.error("Failed to index volume:", error);
console.error('Failed to index volume:', error);
}
};
@@ -601,32 +650,32 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
// Convert enum values to strings for safe rendering
const fileSystem = volume.file_system
? typeof volume.file_system === "string"
? typeof volume.file_system === 'string'
? volume.file_system
: (volume.file_system as any)?.Other ||
JSON.stringify(volume.file_system)
: "Unknown";
: 'Unknown';
const diskType = volume.disk_type
? typeof volume.disk_type === "string"
? typeof volume.disk_type === 'string'
? volume.disk_type
: (volume.disk_type as any)?.Other ||
JSON.stringify(volume.disk_type)
: "Unknown";
: 'Unknown';
const readSpeed = volume.read_speed_mbps;
const iconSrc = getVolumeIcon(volume.volume_type, volume.name);
const volumeTypeStr =
typeof volume.volume_type === "string"
typeof volume.volume_type === 'string'
? volume.volume_type
: (volume.volume_type as any)?.Other ||
JSON.stringify(volume.volume_type);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="rounded-lg bg-app-box border border-app-line/50 overflow-hidden"
initial={{opacity: 0, y: 10}}
animate={{opacity: 1, y: 0}}
transition={{delay: index * 0.05}}
className="bg-app-box border-app-line/50 overflow-hidden rounded-lg border"
>
{/* Top row: Info */}
<div className="flex items-center gap-3 px-3 py-2">
@@ -634,17 +683,17 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
<img
src={iconSrc}
alt={volumeTypeStr}
className="size-6 opacity-80 flex-shrink-0"
className="size-6 flex-shrink-0 opacity-80"
/>
{/* Name, actions, and badges */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-ink truncate text-sm">
<div className="mb-1 flex items-center gap-2">
<span className="text-ink truncate text-sm font-semibold">
{volume.display_name || volume.name}
</span>
{!volume.is_online && (
<span className="px-1.5 py-0.5 bg-app-box text-ink-faint text-[10px] rounded border border-app-line">
<span className="bg-app-box text-ink-faint border-app-line rounded border px-1.5 py-0.5 text-[10px]">
Offline
</span>
)}
@@ -652,13 +701,13 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
<button
onClick={handleTrack}
disabled={trackVolume.isPending}
className="px-1.5 py-0.5 bg-accent/10 hover:bg-accent/20 text-accent text-[10px] rounded border border-accent/20 hover:border-accent/30 transition-colors flex items-center gap-1 disabled:opacity-50"
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"}
? 'Tracking...'
: 'Track'}
</button>
)}
{currentDevice &&
@@ -666,7 +715,7 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
<button
onClick={handleIndex}
disabled={indexVolume.isPending}
className="px-1.5 py-0.5 bg-sidebar-box hover:bg-sidebar-selected text-sidebar-ink text-[10px] rounded border border-sidebar-line transition-colors flex items-center gap-1 disabled:opacity-50"
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
@@ -674,25 +723,25 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
weight="bold"
/>
{indexVolume.isPending
? "Indexing..."
: "Index"}
? 'Indexing...'
: 'Index'}
</button>
)}
</div>
{/* Badges under name */}
<div className="flex items-center gap-1.5 text-[10px] text-ink-dull flex-wrap">
<span className="px-1.5 py-0.5 bg-app-box rounded border border-app-line">
<div className="text-ink-dull flex flex-wrap items-center gap-1.5 text-[10px]">
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{fileSystem}
</span>
<span className="px-1.5 py-0.5 bg-app-box rounded border border-app-line">
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{getDiskTypeLabel(diskType)}
</span>
<span className="px-1.5 py-0.5 bg-app-box rounded border border-app-line">
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{volumeTypeStr}
</span>
{volume.total_file_count != null && (
<span className="px-1.5 py-0.5 bg-accent/10 rounded border border-accent/20 text-accent">
<span className="bg-accent/10 border-accent/20 text-accent rounded border px-1.5 py-0.5">
{volume.total_file_count.toLocaleString()} files
</span>
)}
@@ -700,11 +749,11 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
</div>
{/* Capacity info */}
<div className="text-right flex-shrink-0">
<div className="text-sm font-medium text-ink">
<div className="flex-shrink-0 text-right">
<div className="text-ink text-sm font-medium">
{formatBytes(totalCapacity)}
</div>
<div className="text-[10px] text-ink-dull">
<div className="text-ink-dull text-[10px]">
{formatBytes(availableBytes)} free
</div>
</div>
@@ -712,31 +761,31 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
{/* Bottom: Full-width capacity bar with padding */}
<div className="px-3 pb-3 pt-2">
<div className="h-8 bg-app rounded-md overflow-hidden border border-app-line">
<div className="h-full flex">
<div className="bg-app border-app-line h-8 overflow-hidden rounded-md border">
<div className="flex h-full">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${uniquePercent}%` }}
initial={{width: 0}}
animate={{width: `${uniquePercent}%`}}
transition={{
duration: 1,
ease: "easeOut",
delay: index * 0.05,
ease: 'easeOut',
delay: index * 0.05
}}
className="bg-accent border-r border-accent-deep"
className="bg-accent border-accent-deep border-r"
title={`Unique: ${formatBytes(uniqueBytes)} (${uniquePercent.toFixed(1)}%)`}
/>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${duplicatePercent}%` }}
initial={{width: 0}}
animate={{width: `${duplicatePercent}%`}}
transition={{
duration: 1,
ease: "easeOut",
delay: index * 0.05 + 0.2,
ease: 'easeOut',
delay: index * 0.05 + 0.2
}}
className="bg-accent/60"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(255,255,255,0.1) 4px, rgba(255,255,255,0.1) 8px)",
'repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(255,255,255,0.1) 4px, rgba(255,255,255,0.1) 8px)'
}}
title={`Duplicate: ${formatBytes(duplicateBytes)} (${duplicatePercent.toFixed(1)}%)`}
/>
@@ -745,4 +794,4 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
</div>
</motion.div>
);
}
}