mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 13:55:40 -04:00
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:
4599
apps/tauri/packages/ts-client/src/generated/types.ts
Normal file
4599
apps/tauri/packages/ts-client/src/generated/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
4599
core/packages/ts-client/src/generated/types.ts
Normal file
4599
core/packages/ts-client/src/generated/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user