From 7ffd64049ad92fa2d4c06d6786444325d5cf1e4c Mon Sep 17 00:00:00 2001 From: Mathias Wagner Date: Wed, 10 Sep 2025 13:47:19 +0200 Subject: [PATCH] Implement file browser & web ui components --- server/src/controllers/files.rs | 365 +++++++++++++ server/src/controllers/mod.rs | 1 + server/src/main.rs | 6 +- server/src/routes/files.rs | 77 +++ server/src/routes/mod.rs | 3 +- server/src/sync/server.rs | 16 +- webui/src/App.jsx | 3 +- webui/src/common/components/Badge/styles.sass | 4 + .../components/EmptyState/EmptyState.jsx | 11 +- .../common/components/EmptyState/styles.sass | 51 ++ .../components/FileBrowser/FileBrowser.jsx | 514 ++++++++++++++++++ .../components/FileBrowser/file-browser.sass | 356 ++++++++++++ .../common/components/FileBrowser/index.js | 1 + webui/src/pages/Machines/MachineDetails.jsx | 419 ++++++++++++++ webui/src/pages/Machines/Machines.jsx | 23 +- webui/src/pages/Machines/index.js | 1 + webui/src/pages/Machines/machine-details.sass | 303 +++++++++++ webui/src/pages/Machines/styles.sass | 8 + 18 files changed, 2150 insertions(+), 12 deletions(-) create mode 100644 server/src/controllers/files.rs create mode 100644 server/src/routes/files.rs create mode 100644 webui/src/common/components/FileBrowser/FileBrowser.jsx create mode 100644 webui/src/common/components/FileBrowser/file-browser.sass create mode 100644 webui/src/common/components/FileBrowser/index.js create mode 100644 webui/src/pages/Machines/MachineDetails.jsx create mode 100644 webui/src/pages/Machines/machine-details.sass diff --git a/server/src/controllers/files.rs b/server/src/controllers/files.rs new file mode 100644 index 0000000..f45a2e8 --- /dev/null +++ b/server/src/controllers/files.rs @@ -0,0 +1,365 @@ +use crate::sync::storage::Storage; +use crate::sync::meta::{MetaObj, EntryType}; +use crate::sync::protocol::MetaType; +use crate::utils::{error::*, models::*, DbPool}; +use serde::Serialize; +use axum::response::Response; +use axum::body::Body; +use axum::http::{HeaderMap, HeaderValue}; + +#[derive(Debug, Serialize)] +pub struct FileSystemEntry { + pub name: String, + pub entry_type: String, // "file", "dir", "symlink" + pub size_bytes: Option, + pub meta_hash: String, +} + +#[derive(Debug, Serialize)] +pub struct DirectoryListing { + pub path: String, + pub entries: Vec, + pub parent_hash: Option, +} + +#[derive(Debug, Serialize)] +pub struct FileMetadata { + pub name: String, + pub size_bytes: u64, + pub mime_type: String, + pub meta_hash: String, +} + +pub struct FilesController; + +impl FilesController { + /// List directory contents for a partition + pub async fn list_partition_root( + pool: &DbPool, + machine_id: i64, + snapshot_id: String, + partition_index: usize, + user: &User, + ) -> AppResult { + // Verify machine access + Self::verify_machine_access(pool, machine_id, user).await?; + + let storage = Storage::new("./data"); + + // Get partition hash from snapshot + let partition_hash = Self::get_partition_hash(&storage, machine_id, &snapshot_id, partition_index).await?; + + // Load partition metadata to get root directory hash + let partition_meta = storage.load_meta(MetaType::Partition, &partition_hash).await + .map_err(|_| AppError::NotFoundError("Partition metadata not found".to_string()))? + .ok_or_else(|| AppError::NotFoundError("Partition metadata not found".to_string()))?; + + if let MetaObj::Partition(partition_obj) = partition_meta { + Self::list_directory_by_hash(&storage, &partition_obj.root_dir_hash, "/".to_string()).await + } else { + Err(AppError::ValidationError("Invalid partition metadata".to_string())) + } + } + + /// List directory contents by directory hash + pub async fn list_directory( + pool: &DbPool, + machine_id: i64, + snapshot_id: String, + partition_index: usize, + dir_hash: String, + user: &User, + ) -> AppResult { + // Verify machine access + Self::verify_machine_access(pool, machine_id, user).await?; + + let storage = Storage::new("./data"); + + // Decode directory hash + let hash_bytes = hex::decode(&dir_hash) + .map_err(|_| AppError::ValidationError("Invalid directory hash format".to_string()))?; + + if hash_bytes.len() != 32 { + return Err(AppError::ValidationError("Directory hash must be 32 bytes".to_string())); + } + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&hash_bytes); + + Self::list_directory_by_hash(&storage, &hash, dir_hash).await + } + + /// Download a file by file hash with filename + pub async fn download_file( + pool: &DbPool, + machine_id: i64, + _snapshot_id: String, + _partition_index: usize, + file_hash: String, + filename: Option, + user: &User, + ) -> AppResult> { + // Verify machine access + Self::verify_machine_access(pool, machine_id, user).await?; + + let storage = Storage::new("./data"); + + // Decode file hash + let hash_bytes = hex::decode(&file_hash) + .map_err(|_| AppError::ValidationError("Invalid file hash format".to_string()))?; + + if hash_bytes.len() != 32 { + return Err(AppError::ValidationError("File hash must be 32 bytes".to_string())); + } + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&hash_bytes); + + // Load file metadata + let file_meta = storage.load_meta(MetaType::File, &hash).await + .map_err(|_| AppError::NotFoundError("File metadata not found".to_string()))? + .ok_or_else(|| AppError::NotFoundError("File metadata not found".to_string()))?; + + if let MetaObj::File(file_obj) = file_meta { + // Reconstruct file content from chunks + let mut file_content = Vec::new(); + + for chunk_hash in &file_obj.chunk_hashes { + let chunk_data = storage.load_chunk(chunk_hash).await + .map_err(|_| AppError::NotFoundError(format!("Chunk {} not found", hex::encode(chunk_hash))))? + .ok_or_else(|| AppError::NotFoundError(format!("Chunk {} not found", hex::encode(chunk_hash))))?; + + file_content.extend_from_slice(&chunk_data); + } + + // Use provided filename or generate a generic one + let filename = filename.unwrap_or_else(|| format!("file_{}.bin", &file_hash[..8])); + + // Determine MIME type from file content + let mime_type = Self::detect_mime_type(&filename, &file_content); + + // Create response headers + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_str(&mime_type).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")) + ); + headers.insert( + "content-disposition", + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", filename)) + .unwrap_or_else(|_| HeaderValue::from_static("attachment")) + ); + headers.insert( + "content-length", + HeaderValue::from_str(&file_content.len().to_string()).unwrap() + ); + + let mut response = Response::new(Body::from(file_content)); + *response.headers_mut() = headers; + + Ok(response) + } else { + Err(AppError::ValidationError("Invalid file metadata".to_string())) + } + } + + /// Get file metadata without downloading content + pub async fn get_file_metadata( + pool: &DbPool, + machine_id: i64, + snapshot_id: String, + partition_index: usize, + file_hash: String, + user: &User, + ) -> AppResult { + // Verify machine access + Self::verify_machine_access(pool, machine_id, user).await?; + + let storage = Storage::new("./data"); + + // Decode file hash + let hash_bytes = hex::decode(&file_hash) + .map_err(|_| AppError::ValidationError("Invalid file hash format".to_string()))?; + + if hash_bytes.len() != 32 { + return Err(AppError::ValidationError("File hash must be 32 bytes".to_string())); + } + + let mut hash = [0u8; 32]; + hash.copy_from_slice(&hash_bytes); + + // Load file metadata + let file_meta = storage.load_meta(MetaType::File, &hash).await + .map_err(|_| AppError::NotFoundError("File metadata not found".to_string()))? + .ok_or_else(|| AppError::NotFoundError("File metadata not found".to_string()))?; + + if let MetaObj::File(file_obj) = file_meta { + let filename = format!("file_{}.bin", &file_hash[..8]); + let mime_type = Self::detect_mime_type(&filename, &[]); + + Ok(FileMetadata { + name: filename, + size_bytes: file_obj.size, + mime_type, + meta_hash: file_hash, + }) + } else { + Err(AppError::ValidationError("Invalid file metadata".to_string())) + } + } + + // Helper methods + + async fn verify_machine_access(pool: &DbPool, machine_id: i64, user: &User) -> AppResult<()> { + let machine = sqlx::query!( + "SELECT id, user_id FROM machines WHERE id = ? AND user_id = ?", + machine_id, + user.id + ) + .fetch_optional(pool) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + if machine.is_none() { + return Err(AppError::NotFoundError("Machine not found or access denied".to_string())); + } + + Ok(()) + } + + async fn get_partition_hash( + storage: &Storage, + machine_id: i64, + snapshot_id: &str, + partition_index: usize, + ) -> AppResult<[u8; 32]> { + // Load snapshot reference to get hash + let (snapshot_hash, _) = storage.load_snapshot_ref(machine_id, snapshot_id).await + .map_err(|_| AppError::NotFoundError("Snapshot not found".to_string()))? + .ok_or_else(|| AppError::NotFoundError("Snapshot not found".to_string()))?; + + // Load snapshot metadata + let snapshot_meta = storage.load_meta(MetaType::Snapshot, &snapshot_hash).await + .map_err(|_| AppError::NotFoundError("Snapshot metadata not found".to_string()))? + .ok_or_else(|| AppError::NotFoundError("Snapshot metadata not found".to_string()))?; + + if let MetaObj::Snapshot(snapshot_obj) = snapshot_meta { + // Get first disk (assuming single disk for now) + if snapshot_obj.disk_hashes.is_empty() { + return Err(AppError::NotFoundError("No disks in snapshot".to_string())); + } + + let disk_hash = snapshot_obj.disk_hashes[0]; + + // Load disk metadata + let disk_meta = storage.load_meta(MetaType::Disk, &disk_hash).await + .map_err(|_| AppError::NotFoundError("Disk metadata not found".to_string()))? + .ok_or_else(|| AppError::NotFoundError("Disk metadata not found".to_string()))?; + + if let MetaObj::Disk(disk_obj) = disk_meta { + if partition_index >= disk_obj.partition_hashes.len() { + return Err(AppError::NotFoundError("Partition index out of range".to_string())); + } + + Ok(disk_obj.partition_hashes[partition_index]) + } else { + Err(AppError::ValidationError("Invalid disk metadata".to_string())) + } + } else { + Err(AppError::ValidationError("Invalid snapshot metadata".to_string())) + } + } + + async fn list_directory_by_hash( + storage: &Storage, + dir_hash: &[u8; 32], + path: String, + ) -> AppResult { + // Load directory metadata + let dir_meta = storage.load_meta(MetaType::Dir, dir_hash).await + .map_err(|_| AppError::NotFoundError("Directory metadata not found".to_string()))? + .ok_or_else(|| AppError::NotFoundError("Directory metadata not found".to_string()))?; + + if let MetaObj::Dir(dir_obj) = dir_meta { + let mut entries = Vec::new(); + + for entry in dir_obj.entries { + let entry_type_str = match entry.entry_type { + EntryType::File => "file", + EntryType::Dir => "dir", + EntryType::Symlink => "symlink", + }; + + let size_bytes = if entry.entry_type == EntryType::File { + // Load file metadata to get size + if let Ok(Some(MetaObj::File(file_obj))) = storage.load_meta(MetaType::File, &entry.target_meta_hash).await { + Some(file_obj.size) + } else { + None + } + } else { + None + }; + + entries.push(FileSystemEntry { + name: entry.name, + entry_type: entry_type_str.to_string(), + size_bytes, + meta_hash: hex::encode(entry.target_meta_hash), + }); + } + + // Sort entries: directories first, then files, both alphabetically + entries.sort_by(|a, b| { + match (a.entry_type.as_str(), b.entry_type.as_str()) { + ("dir", "file") => std::cmp::Ordering::Less, + ("file", "dir") => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + } + }); + + Ok(DirectoryListing { + path, + entries, + parent_hash: None, // TODO: Implement parent tracking if needed + }) + } else { + Err(AppError::ValidationError("Invalid directory metadata".to_string())) + } + } + + fn detect_mime_type(filename: &str, _content: &[u8]) -> String { + // Simple MIME type detection based on file extension + let extension = std::path::Path::new(filename) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("") + .to_lowercase(); + + match extension.as_str() { + "txt" | "md" | "readme" => "text/plain", + "html" | "htm" => "text/html", + "css" => "text/css", + "js" => "application/javascript", + "json" => "application/json", + "xml" => "application/xml", + "pdf" => "application/pdf", + "zip" => "application/zip", + "tar" => "application/x-tar", + "gz" => "application/gzip", + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "mp4" => "video/mp4", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "exe" => "application/x-msdownload", + "dll" => "application/x-msdownload", + "so" => "application/x-sharedlib", + "deb" => "application/vnd.debian.binary-package", + "rpm" => "application/x-rpm", + _ => "application/octet-stream", + }.to_string() + } +} \ No newline at end of file diff --git a/server/src/controllers/mod.rs b/server/src/controllers/mod.rs index 9b29ccc..96052fa 100644 --- a/server/src/controllers/mod.rs +++ b/server/src/controllers/mod.rs @@ -2,3 +2,4 @@ pub mod auth; pub mod machines; pub mod snapshots; pub mod users; +pub mod files; diff --git a/server/src/main.rs b/server/src/main.rs index c4f45c5..5510bb2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -8,7 +8,7 @@ use axum::{ routing::{delete, get, post, put}, Router, }; -use routes::{accounts, admin, auth, config, machines, setup, snapshots}; +use routes::{accounts, admin, auth, config, machines, setup, snapshots, files}; use std::path::Path; use tokio::signal; use tower_http::{ @@ -44,6 +44,10 @@ async fn main() -> Result<()> { .route("/machines/{id}", delete(machines::delete_machine)) .route("/machines/{id}/snapshots", get(snapshots::get_machine_snapshots)) .route("/machines/{machine_id}/snapshots/{snapshot_id}", get(snapshots::get_snapshot_details)) + .route("/machines/{machine_id}/snapshots/{snapshot_id}/partitions/{partition_index}/files", get(files::list_partition_root)) + .route("/machines/{machine_id}/snapshots/{snapshot_id}/partitions/{partition_index}/files/{dir_hash}", get(files::list_directory)) + .route("/machines/{machine_id}/snapshots/{snapshot_id}/partitions/{partition_index}/download/{file_hash}", get(files::download_file)) + .route("/machines/{machine_id}/snapshots/{snapshot_id}/partitions/{partition_index}/metadata/{file_hash}", get(files::get_file_metadata)) .layer(CorsLayer::permissive()) .with_state(pool); diff --git a/server/src/routes/files.rs b/server/src/routes/files.rs new file mode 100644 index 0000000..c9313bc --- /dev/null +++ b/server/src/routes/files.rs @@ -0,0 +1,77 @@ +use axum::{extract::{Path, Query, State}, Json, response::Response}; +use axum::body::Body; +use serde::Deserialize; +use crate::controllers::files::{FilesController, DirectoryListing, FileMetadata}; +use crate::utils::{auth::AuthUser, error::AppResult, DbPool}; + +#[derive(Deserialize)] +pub struct DownloadQuery { + filename: Option, +} + +pub async fn list_partition_root( + State(pool): State, + Path((machine_id, snapshot_id, partition_index)): Path<(i64, String, usize)>, + auth_user: AuthUser, +) -> AppResult> { + let listing = FilesController::list_partition_root( + &pool, + machine_id, + snapshot_id, + partition_index, + &auth_user.user, + ).await?; + + Ok(Json(listing)) +} + +pub async fn list_directory( + State(pool): State, + Path((machine_id, snapshot_id, partition_index, dir_hash)): Path<(i64, String, usize, String)>, + auth_user: AuthUser, +) -> AppResult> { + let listing = FilesController::list_directory( + &pool, + machine_id, + snapshot_id, + partition_index, + dir_hash, + &auth_user.user, + ).await?; + + Ok(Json(listing)) +} + +pub async fn download_file( + State(pool): State, + Path((machine_id, snapshot_id, partition_index, file_hash)): Path<(i64, String, usize, String)>, + Query(query): Query, + auth_user: AuthUser, +) -> AppResult> { + FilesController::download_file( + &pool, + machine_id, + snapshot_id, + partition_index, + file_hash, + query.filename, + &auth_user.user, + ).await +} + +pub async fn get_file_metadata( + State(pool): State, + Path((machine_id, snapshot_id, partition_index, file_hash)): Path<(i64, String, usize, String)>, + auth_user: AuthUser, +) -> AppResult> { + let metadata = FilesController::get_file_metadata( + &pool, + machine_id, + snapshot_id, + partition_index, + file_hash, + &auth_user.user, + ).await?; + + Ok(Json(metadata)) +} \ No newline at end of file diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 4f8737e..7c19126 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,7 +1,8 @@ +pub mod accounts; pub mod admin; pub mod auth; pub mod config; pub mod machines; pub mod setup; -pub mod accounts; pub mod snapshots; +pub mod files; diff --git a/server/src/sync/server.rs b/server/src/sync/server.rs index ca732e4..ab80529 100644 --- a/server/src/sync/server.rs +++ b/server/src/sync/server.rs @@ -141,9 +141,9 @@ impl ConnectionHandler { // Read message header let header = self.read_header().await?; - // Read payload + // Read payload with appropriate size limit based on command type let payload = if header.payload_len > 0 { - self.read_payload(header.payload_len).await? + self.read_payload(header.cmd, header.payload_len).await? } else { Bytes::new() }; @@ -184,9 +184,15 @@ impl ConnectionHandler { .context("Failed to parse message header") } - /// Read message payload - async fn read_payload(&mut self, len: u32) -> Result { - if len as usize > self.config.meta_size_limit { + /// Read message payload with appropriate size limit based on command type + async fn read_payload(&mut self, cmd: Command, len: u32) -> Result { + // Use different size limits based on command type + let size_limit = match cmd { + Command::SendChunk => self.config.chunk_size_limit, + _ => self.config.meta_size_limit, + }; + + if len as usize > size_limit { return Err(anyhow::anyhow!("Payload too large: {} bytes", len)); } diff --git a/webui/src/App.jsx b/webui/src/App.jsx index bb56281..c526ebf 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -5,7 +5,7 @@ import "@/common/styles/main.sass"; import Root from "@/common/layouts/Root.jsx"; import UserManagement from "@/pages/UserManagement"; import SystemSettings from "@/pages/SystemSettings"; -import Machines from "@/pages/Machines"; +import Machines, {MachineDetails} from "@/pages/Machines"; import "@fontsource/plus-jakarta-sans/300.css"; import "@fontsource/plus-jakarta-sans/400.css"; import "@fontsource/plus-jakarta-sans/600.css"; @@ -24,6 +24,7 @@ const App = () => { {path: "/", element: }, {path: "/dashboard", element: }, {path: "/machines", element: }, + {path: "/machines/:id", element: }, {path: "/servers", element: }, {path: "/settings", element: }, {path: "/admin/users", element: }, diff --git a/webui/src/common/components/Badge/styles.sass b/webui/src/common/components/Badge/styles.sass index 134c09f..d202463 100644 --- a/webui/src/common/components/Badge/styles.sass +++ b/webui/src/common/components/Badge/styles.sass @@ -45,5 +45,9 @@ color: #1976d2 &--user + background: var(--bg-elev) + color: var(--text-dim) + + &--subtle background: var(--bg-elev) color: var(--text-dim) \ No newline at end of file diff --git a/webui/src/common/components/EmptyState/EmptyState.jsx b/webui/src/common/components/EmptyState/EmptyState.jsx index 4ff967d..4e4bf4f 100644 --- a/webui/src/common/components/EmptyState/EmptyState.jsx +++ b/webui/src/common/components/EmptyState/EmptyState.jsx @@ -6,10 +6,19 @@ export const EmptyState = ({ title, description, action, + size = 'md', + variant = 'default', className = '' }) => { + const emptyStateClasses = [ + 'empty-state', + `empty-state--${size}`, + `empty-state--${variant}`, + className + ].filter(Boolean).join(' '); + return ( -
+
{icon &&
{icon}
} {title &&

{title}

} {description &&

{description}

} diff --git a/webui/src/common/components/EmptyState/styles.sass b/webui/src/common/components/EmptyState/styles.sass index a183db4..2bf93db 100644 --- a/webui/src/common/components/EmptyState/styles.sass +++ b/webui/src/common/components/EmptyState/styles.sass @@ -7,6 +7,57 @@ align-items: center gap: 1rem + &--sm + padding: 1.5rem 1rem + gap: 0.5rem + + .empty-state-icon svg + width: 32px + height: 32px + + .empty-state-title + font-size: 1rem + + .empty-state-description + font-size: 0.875rem + + &--md + padding: 3rem 2rem + gap: 1rem + + .empty-state-icon svg + width: 48px + height: 48px + + .empty-state-title + font-size: 1.2rem + + .empty-state-description + font-size: 0.95rem + + &--lg + padding: 4rem 3rem + gap: 1.5rem + + .empty-state-icon svg + width: 64px + height: 64px + + .empty-state-title + font-size: 1.5rem + + .empty-state-description + font-size: 1rem + + &--subtle + opacity: 0.8 + + .empty-state-icon + color: var(--text-dim) + + .empty-state-title + color: var(--text-dim) + .empty-state-icon color: var(--text-dim) display: flex diff --git a/webui/src/common/components/FileBrowser/FileBrowser.jsx b/webui/src/common/components/FileBrowser/FileBrowser.jsx new file mode 100644 index 0000000..7a8535e --- /dev/null +++ b/webui/src/common/components/FileBrowser/FileBrowser.jsx @@ -0,0 +1,514 @@ +import React, {useState, useEffect} from 'react'; +import {useToast} from '@/common/contexts/ToastContext.jsx'; +import {getRequest} from '@/common/utils/RequestUtil.js'; +import Button from '@/common/components/Button'; +import Modal, {ModalActions} from '@/common/components/Modal'; +import Card, {CardHeader, CardBody} from '@/common/components/Card'; +import LoadingSpinner from '@/common/components/LoadingSpinner'; +import EmptyState from '@/common/components/EmptyState'; +import { + FolderIcon, + FileIcon, + ArrowLeftIcon, + DownloadIcon, + LinkIcon, + XIcon, + FolderOpenIcon, + HouseIcon, + FileTextIcon, + FilePdfIcon, + FileZipIcon, + FileImageIcon, + FileVideoIcon, + FileAudioIcon, + FileCodeIcon, + GearIcon, + Database +} from '@phosphor-icons/react'; +import './file-browser.sass'; + +export const FileBrowser = ({ + isOpen, + onClose, + machineId, + snapshotId, + partitionIndex, + partitionInfo +}) => { + const toast = useToast(); + const [currentPath, setCurrentPath] = useState([]); + const [currentDirHash, setCurrentDirHash] = useState(null); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [breadcrumbs, setBreadcrumbs] = useState([{name: 'Root', hash: null}]); + + useEffect(() => { + if (isOpen && machineId && snapshotId && partitionIndex !== undefined) { + loadPartitionRoot(); + } + }, [isOpen, machineId, snapshotId, partitionIndex]); + + const loadPartitionRoot = async () => { + try { + setLoading(true); + const response = await getRequest( + `machines/${machineId}/snapshots/${snapshotId}/partitions/${partitionIndex}/files` + ); + setEntries(response.entries || []); + setCurrentPath([]); + setCurrentDirHash(null); + setBreadcrumbs([{name: 'Root', hash: null}]); + } catch (error) { + console.error('Failed to load partition root:', error); + toast.error('Failed to load files. Please try again.'); + } finally { + setLoading(false); + } + }; + + const loadDirectory = async (dirHash, dirName) => { + try { + setLoading(true); + const response = await getRequest( + `machines/${machineId}/snapshots/${snapshotId}/partitions/${partitionIndex}/files/${dirHash}` + ); + setEntries(response.entries || []); + setCurrentDirHash(dirHash); + + // Update path and breadcrumbs + const newPath = [...currentPath, dirName]; + setCurrentPath(newPath); + + const newBreadcrumbs = [ + {name: 'Root', hash: null}, + ...newPath.map((name, index) => ({ + name, + hash: index === newPath.length - 1 ? dirHash : null // Only store hash for current dir + })) + ]; + setBreadcrumbs(newBreadcrumbs); + } catch (error) { + console.error('Failed to load directory:', error); + toast.error('Failed to load directory. Please try again.'); + } finally { + setLoading(false); + } + }; + + const navigateToBreadcrumb = async (index) => { + if (index === 0) { + // Navigate to root + await loadPartitionRoot(); + } else { + // For now, we can only navigate back to root since we don't store intermediate hashes + // In a full implementation, you'd need to track the full path with hashes + toast.info('Navigation to intermediate directories is not implemented yet. Use the back button or go to root.'); + } + }; + + const goBack = async () => { + if (currentPath.length === 0) { + return; // Already at root + } + + if (currentPath.length === 1) { + // Go back to root + await loadPartitionRoot(); + } else { + // For now, just go to root. Full implementation would require tracking parent hashes + await loadPartitionRoot(); + toast.info('Navigated back to root. Full directory navigation will be enhanced in future updates.'); + } + }; + + const handleEntryClick = async (entry) => { + if (entry.entry_type === 'dir') { + await loadDirectory(entry.meta_hash, entry.name); + } else if (entry.entry_type === 'file') { + await downloadFile(entry.meta_hash, entry.name); + } + }; + + const downloadFile = async (fileHash, fileName) => { + try { + toast.info(`Starting download of ${fileName}...`); + + // Get auth token + const token = localStorage.getItem('sessionToken'); + if (!token) { + toast.error('Authentication required. Please log in again.'); + return; + } + + // Make authenticated request to download file + const downloadUrl = `/api/machines/${machineId}/snapshots/${snapshotId}/partitions/${partitionIndex}/download/${fileHash}?filename=${encodeURIComponent(fileName)}`; + + const response = await fetch(downloadUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + if (response.status === 401) { + toast.error('Authentication failed. Please log in again.'); + return; + } + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + + // Get the file as a blob + const blob = await response.blob(); + + // Create a temporary URL for the blob + const blobUrl = window.URL.createObjectURL(blob); + + // Create a temporary anchor element + const link = document.createElement('a'); + link.href = blobUrl; + link.download = fileName; + link.style.display = 'none'; + + // Add to DOM, click, and remove + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up the blob URL + window.URL.revokeObjectURL(blobUrl); + + toast.success(`Downloaded ${fileName}`); + } catch (error) { + console.error('Failed to download file:', error); + toast.error(`Failed to download file: ${error.message}`); + } + }; + + const formatFileSize = (bytes) => { + if (!bytes) return 'Unknown size'; + + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const getFileIcon = (entry) => { + if (entry.entry_type === 'dir') { + return ; + } else if (entry.entry_type === 'symlink') { + return ; + } else { + // Get file extension + const extension = entry.name.split('.').pop()?.toLowerCase() || ''; + + // Return appropriate icon based on extension + switch (extension) { + case 'txt': + case 'md': + case 'readme': + case 'log': + return ; + case 'pdf': + return ; + case 'zip': + case 'rar': + case 'tar': + case 'gz': + case '7z': + return ; + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'bmp': + case 'svg': + case 'webp': + return ; + case 'mp4': + case 'avi': + case 'mov': + case 'wmv': + case 'flv': + case 'webm': + return ; + case 'mp3': + case 'wav': + case 'flac': + case 'aac': + case 'ogg': + return ; + case 'js': + case 'ts': + case 'jsx': + case 'tsx': + case 'html': + case 'css': + case 'scss': + case 'sass': + case 'json': + case 'xml': + case 'py': + case 'java': + case 'cpp': + case 'c': + case 'h': + case 'rs': + case 'go': + case 'php': + case 'rb': + case 'sh': + case 'sql': + return ; + case 'exe': + case 'msi': + case 'deb': + case 'rpm': + case 'dmg': + case 'app': + return ; + case 'db': + case 'sqlite': + case 'mysql': + return ; + default: + return ; + } + } + }; + + const getEntryTypeColor = (entry) => { + if (entry.entry_type === 'dir') { + return '#4589ff'; // Blue for directories + } else if (entry.entry_type === 'symlink') { + return '#a855f7'; // Purple for symlinks + } else { + // Color code by file extension + const extension = entry.name.split('.').pop()?.toLowerCase() || ''; + + switch (extension) { + case 'txt': + case 'md': + case 'readme': + case 'log': + return '#16a34a'; // Green for text files + case 'pdf': + return '#dc2626'; // Red for PDFs + case 'zip': + case 'rar': + case 'tar': + case 'gz': + case '7z': + return '#f59e0b'; // Orange for archives + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'bmp': + case 'svg': + case 'webp': + return '#ec4899'; // Pink for images + case 'mp4': + case 'avi': + case 'mov': + case 'wmv': + case 'flv': + case 'webm': + return '#8b5cf6'; // Purple for videos + case 'mp3': + case 'wav': + case 'flac': + case 'aac': + case 'ogg': + return '#06b6d4'; // Cyan for audio + case 'js': + case 'ts': + case 'jsx': + case 'tsx': + case 'html': + case 'css': + case 'scss': + case 'sass': + case 'json': + case 'xml': + case 'py': + case 'java': + case 'cpp': + case 'c': + case 'h': + case 'rs': + case 'go': + case 'php': + case 'rb': + case 'sh': + case 'sql': + return '#10b981'; // Emerald for code files + case 'exe': + case 'msi': + case 'deb': + case 'rpm': + case 'dmg': + case 'app': + return '#6b7280'; // Gray for executables + case 'db': + case 'sqlite': + case 'mysql': + return '#0ea5e9'; // Blue for databases + default: + return '#6b7280'; // Default gray + } + } + }; + + if (!isOpen) return null; + + return ( + +
+ {/* Navigation Bar */} +
+
+ + +
+ + {/* Breadcrumbs */} +
+ {breadcrumbs.map((crumb, index) => ( + + {index > 0 && /} + + + ))} +
+
+ + {/* File List */} +
+ {loading ? ( +
+ +
+ ) : entries.length === 0 ? ( + } + title="Empty directory" + description="This directory doesn't contain any files or subdirectories" + variant="subtle" + size="sm" + /> + ) : ( + <> + {/* File List Header */} +
+
+ Name +
+
+ Type +
+
+ Size +
+
+ +
+ {entries.map((entry, index) => ( +
handleEntryClick(entry)} + > +
+
+ {getFileIcon(entry)} +
+
+
+ {entry.name} +
+
+
+ +
+ + {entry.entry_type === 'dir' ? 'Folder' : + entry.entry_type === 'symlink' ? 'Link' : + entry.name.includes('.') ? entry.name.split('.').pop()?.toUpperCase() + ' File' : 'File'} + +
+ +
+ {entry.size_bytes ? ( + + {formatFileSize(entry.size_bytes)} + + ) : ( + + )} +
+ + {entry.entry_type === 'file' && ( +
+
+ )} +
+ ))} +
+ + )} +
+
+ + + + +
+ ); +}; \ No newline at end of file diff --git a/webui/src/common/components/FileBrowser/file-browser.sass b/webui/src/common/components/FileBrowser/file-browser.sass new file mode 100644 index 0000000..4dcdb3c --- /dev/null +++ b/webui/src/common/components/FileBrowser/file-browser.sass @@ -0,0 +1,356 @@ +// File Browser Styles - Modern File Manager Design +.file-browser-modal + .modal-dialog + max-width: 95vw + width: 1200px + height: 85vh + display: flex + flex-direction: column + + .modal-content + display: flex + flex-direction: column + height: 100% + overflow: hidden + + .modal-body + flex: 1 + padding: 0 + display: flex + flex-direction: column + overflow: hidden + +.file-browser + display: flex + flex-direction: column + height: 100% + background: var(--bg-alt) + border-radius: var(--radius) + overflow: hidden + +// Navigation Bar - File Explorer Style +.file-browser-nav + display: flex + align-items: center + justify-content: space-between + padding: 0.75rem 1rem + border-bottom: 1px solid var(--border) + background: linear-gradient(to bottom, var(--bg-alt), var(--bg-elev)) + gap: 1rem + min-height: 60px + +.nav-controls + display: flex + gap: 0.5rem + flex-shrink: 0 + +.breadcrumbs + display: flex + align-items: center + gap: 0.25rem + flex: 1 + min-width: 0 + overflow-x: auto + padding: 0.5rem + background: var(--bg) + border: 1px solid var(--border) + border-radius: var(--radius-sm) + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace + font-size: 0.875rem + +.breadcrumb + background: none + border: none + color: var(--accent) + cursor: pointer + font-size: 0.875rem + padding: 0.25rem 0.5rem + border-radius: var(--radius-sm) + transition: all 0.2s ease + white-space: nowrap + font-weight: 500 + + &:hover:not(:disabled) + background: rgba(15, 98, 254, 0.1) + color: var(--accent) + + &:disabled + color: var(--text-dim) + cursor: default + + &--current + color: var(--text) + font-weight: 600 + cursor: default + background: rgba(15, 98, 254, 0.05) + + &:hover + background: rgba(15, 98, 254, 0.05) + +.breadcrumb-separator + color: var(--text-dim) + font-size: 0.875rem + margin: 0 0.25rem + font-weight: 400 + +// Content Area +.file-browser-content + flex: 1 + display: flex + flex-direction: column + overflow: hidden + background: var(--bg) + +.file-browser-loading + flex: 1 + display: flex + align-items: center + justify-content: center + padding: 3rem + +// File List - Table-like layout with header +.file-list-header + display: flex + background: var(--bg-elev) + border-bottom: 1px solid var(--border) + padding: 0.75rem 1rem + font-size: 0.75rem + font-weight: 600 + color: var(--text-dim) + text-transform: uppercase + letter-spacing: 0.025em + position: sticky + top: 0 + z-index: 10 + + &-item + display: flex + align-items: center + + &:nth-child(1) + flex: 1 + min-width: 0 + padding-right: 1rem + + &:nth-child(2) + width: 120px + padding-right: 1rem + + &:nth-child(3) + width: 100px + text-align: right + +.file-list + flex: 1 + overflow-y: auto + padding: 0 + background: var(--bg) + +.file-entry + display: flex + align-items: center + padding: 0.75rem 1rem + border-bottom: 1px solid rgba(223, 227, 232, 0.3) + cursor: pointer + transition: all 0.15s ease + user-select: none + position: relative + + &:hover + background: linear-gradient(90deg, rgba(15, 98, 254, 0.03), rgba(15, 98, 254, 0.01)) + border-left: 3px solid rgba(15, 98, 254, 0.3) + + &:active + background: rgba(15, 98, 254, 0.08) + + &--dir + &:hover + background: linear-gradient(90deg, rgba(15, 98, 254, 0.05), rgba(15, 98, 254, 0.02)) + border-left: 3px solid var(--accent) + + .file-entry-icon + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)) + + // Main content area (icon + name) + &-main + display: flex + align-items: center + flex: 1 + min-width: 0 + padding-right: 1rem + + &-icon + display: flex + align-items: center + justify-content: center + flex-shrink: 0 + width: 24px + height: 24px + margin-right: 0.75rem + transition: transform 0.2s ease + + .file-entry:hover & + transform: scale(1.05) + + &-info + flex: 1 + min-width: 0 + + &-name + font-weight: 500 + color: var(--text) + word-break: break-word + line-height: 1.3 + font-size: 0.875rem + + .file-entry--dir & + font-weight: 600 + + // Type column + &-type-cell + width: 120px + padding-right: 1rem + flex-shrink: 0 + + &-type + text-transform: uppercase + font-weight: 600 + letter-spacing: 0.025em + font-size: 0.7rem + color: var(--text-dim) + + // Size column + &-size-cell + width: 100px + text-align: right + flex-shrink: 0 + + &-size + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace + font-size: 0.7rem + color: var(--text-dim) + background: var(--bg-elev) + padding: 0.125rem 0.375rem + border-radius: var(--radius-sm) + + &-size-empty + font-size: 0.7rem + color: var(--text-dim) + opacity: 0.5 + + &-actions + display: flex + gap: 0.25rem + flex-shrink: 0 + opacity: 0 + transition: opacity 0.2s ease + transform: translateX(8px) + margin-left: 0.5rem + + &:hover &-actions + opacity: 1 + transform: translateX(0) + +// Empty state styling +.file-browser-content .empty-state + margin: 3rem auto + max-width: 400px + +// Header improvements +.file-browser-nav .nav-controls button + border: 1px solid var(--border) + background: var(--bg) + transition: all 0.2s ease + + &:hover:not(:disabled) + background: var(--bg-elev) + border-color: var(--accent) + transform: translateY(-1px) + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) + + &:disabled + opacity: 0.5 + cursor: not-allowed + +// Scrollbar styling +.file-list + scrollbar-width: thin + scrollbar-color: var(--border) transparent + + &::-webkit-scrollbar + width: 8px + + &::-webkit-scrollbar-track + background: transparent + + &::-webkit-scrollbar-thumb + background: var(--border) + border-radius: 4px + + &:hover + background: var(--border-strong) + +// File type specific styling +.file-entry--dir + .file-entry-name + color: var(--text) + + .file-entry-type + color: #4589ff + +.file-entry--file + .file-entry-name + color: var(--text) + +.file-entry--symlink + .file-entry-name + color: var(--text-dim) + font-style: italic + + .file-entry-type + color: #a855f7 + +// Loading state +.file-browser-loading .loading-spinner + color: var(--accent) + +// Modal actions +.file-browser-modal .modal-actions + padding: 1rem + border-top: 1px solid var(--border) + background: var(--bg-elev) + +// Responsive design +@media (max-width: 768px) + .file-browser-modal .modal-dialog + max-width: 98vw + width: 98vw + height: 90vh + + .file-browser-nav + flex-direction: column + align-items: stretch + gap: 0.75rem + padding: 1rem + + .breadcrumbs + order: -1 + font-size: 0.8rem + + .file-entry + grid-template-columns: auto 1fr + gap: 0.75rem + padding: 1rem 0.75rem + + .file-entry-actions + grid-column: 1 / -1 + justify-self: end + margin-top: 0.5rem + opacity: 1 + transform: none + + .file-entry-name + font-size: 0.875rem + + .file-entry-details + font-size: 0.75rem \ No newline at end of file diff --git a/webui/src/common/components/FileBrowser/index.js b/webui/src/common/components/FileBrowser/index.js new file mode 100644 index 0000000..f3cc2a4 --- /dev/null +++ b/webui/src/common/components/FileBrowser/index.js @@ -0,0 +1 @@ +export {FileBrowser} from './FileBrowser.jsx'; \ No newline at end of file diff --git a/webui/src/pages/Machines/MachineDetails.jsx b/webui/src/pages/Machines/MachineDetails.jsx new file mode 100644 index 0000000..294ee8f --- /dev/null +++ b/webui/src/pages/Machines/MachineDetails.jsx @@ -0,0 +1,419 @@ +import React, {useState, useEffect, useContext} from 'react'; +import {useParams, useNavigate} from 'react-router-dom'; +import {UserContext} from '@/common/contexts/UserContext.jsx'; +import {useToast} from '@/common/contexts/ToastContext.jsx'; +import {getRequest} from '@/common/utils/RequestUtil.js'; +import Button from '@/common/components/Button'; +import Card, {CardHeader, CardBody} from '@/common/components/Card'; +import Badge from '@/common/components/Badge'; +import LoadingSpinner from '@/common/components/LoadingSpinner'; +import EmptyState from '@/common/components/EmptyState'; +import PageHeader from '@/common/components/PageHeader'; +import DetailItem, {DetailList} from '@/common/components/DetailItem'; +import {FileBrowser} from '@/common/components/FileBrowser'; +import { + ArrowLeftIcon, + ComputerTowerIcon, + CameraIcon, + HardDriveIcon, + CalendarIcon, + IdentificationCardIcon, + DatabaseIcon, + FolderIcon, + CaretDownIcon, + CaretRightIcon, + CircleIcon, + FolderOpenIcon +} from '@phosphor-icons/react'; +import './machine-details.sass'; + +export const MachineDetails = () => { + const {id} = useParams(); + const navigate = useNavigate(); + const {user: currentUser} = useContext(UserContext); + const toast = useToast(); + + const [machine, setMachine] = useState(null); + const [snapshots, setSnapshots] = useState([]); + const [selectedSnapshot, setSelectedSnapshot] = useState(null); + const [snapshotDetails, setSnapshotDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [snapshotsLoading, setSnapshotsLoading] = useState(false); + const [detailsLoading, setDetailsLoading] = useState(false); + const [expandedDisks, setExpandedDisks] = useState(new Set()); + const [fileBrowserOpen, setFileBrowserOpen] = useState(false); + const [selectedPartition, setSelectedPartition] = useState(null); + + useEffect(() => { + fetchMachine(); + fetchSnapshots(); + }, [id]); + + const fetchMachine = async () => { + try { + const response = await getRequest(`machines/${id}`); + setMachine(response); + } catch (error) { + console.error('Failed to fetch machine:', error); + toast.error('Failed to load machine details. Please try again.'); + navigate('/machines'); + } + }; + + const fetchSnapshots = async () => { + try { + setSnapshotsLoading(true); + const response = await getRequest(`machines/${id}/snapshots`); + setSnapshots(response); + } catch (error) { + console.error('Failed to fetch snapshots:', error); + toast.error('Failed to load snapshots. Please try again.'); + } finally { + setSnapshotsLoading(false); + setLoading(false); + } + }; + + const fetchSnapshotDetails = async (snapshotId) => { + try { + setDetailsLoading(true); + const response = await getRequest(`machines/${id}/snapshots/${snapshotId}`); + setSnapshotDetails(response); + } catch (error) { + console.error('Failed to fetch snapshot details:', error); + toast.error('Failed to load snapshot details. Please try again.'); + } finally { + setDetailsLoading(false); + } + }; + + const handleSnapshotClick = (snapshot) => { + if (selectedSnapshot?.id === snapshot.id) { + setSelectedSnapshot(null); + setSnapshotDetails(null); + } else { + setSelectedSnapshot(snapshot); + setSnapshotDetails(null); + setExpandedDisks(new Set()); + fetchSnapshotDetails(snapshot.id); + } + }; + + const toggleDiskExpansion = (diskSerial) => { + const newExpanded = new Set(expandedDisks); + if (newExpanded.has(diskSerial)) { + newExpanded.delete(diskSerial); + } else { + newExpanded.add(diskSerial); + } + setExpandedDisks(newExpanded); + }; + + const formatDate = (dateString) => { + return new Date(dateString + ' UTC').toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const formatBytes = (bytes) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatUuid = (uuid) => { + return uuid.substring(0, 8).toUpperCase(); + }; + + const getFsTypeColor = (fsType) => { + switch (fsType.toLowerCase()) { + case 'ntfs': return 'primary'; + case 'ext': return 'success'; + case 'fat32': return 'warning'; + default: return 'subtle'; + } + }; + + const openFileBrowser = (partition, diskIndex, partitionIndex) => { + setSelectedPartition({ + ...partition, + diskIndex, + partitionIndex, + globalPartitionIndex: diskIndex * 10 + partitionIndex // Simple calculation for now + }); + setFileBrowserOpen(true); + }; + + const closeFileBrowser = () => { + setFileBrowserOpen(false); + setSelectedPartition(null); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!machine) { + return ( +
+ } + title="Machine not found" + description="The requested machine could not be found or you don't have access to it" + action={ + + } + /> +
+ ); + } + + return ( +
+ } + onClick={() => navigate('/machines')} + > + Back to Machines + + } + /> + + {/* Machine Information */} + + +
+
+ +
+
+

{machine.name}

+
+ + {machine.uuid} +
+
+
+
+ + + }> + Registered: {formatDate(machine.created_at)} + + }> + Total Snapshots: {snapshots.length} + + + +
+ + {/* Snapshots Section */} +
+
+

+ + Snapshots +

+ {snapshotsLoading && } +
+ + {snapshots.length === 0 && !snapshotsLoading ? ( + } + title="No snapshots found" + description="This machine doesn't have any snapshots yet. Snapshots will appear here once they are created." + variant="subtle" + /> + ) : ( +
+ {snapshots.map(snapshot => ( +
+ handleSnapshotClick(snapshot)} + > + +
+
+ +
+
+
+ Snapshot {snapshot.id.substring(0, 8)} +
+
+ {formatDate(snapshot.created_at)} +
+
+
+ Hash: + {snapshot.snapshot_hash.substring(0, 12)} +
+
+
+
+ + {/* Snapshot Details */} + {selectedSnapshot?.id === snapshot.id && ( + + + {detailsLoading ? ( + + ) : snapshotDetails ? ( +
+
+

+ + Disks and Partitions +

+ + {snapshotDetails.disks.length} disk{snapshotDetails.disks.length !== 1 ? 's' : ''} + +
+ + {snapshotDetails.disks.length === 0 ? ( + } + title="No disk data" + description="This snapshot doesn't contain any disk information" + variant="subtle" + size="sm" + /> + ) : ( +
+ {snapshotDetails.disks.map((disk, index) => ( +
+
toggleDiskExpansion(disk.serial)} + > +
+ {expandedDisks.has(disk.serial) ? + : + + } +
+
+ +
+
+
+ Disk {index + 1} + {disk.serial && ( + + {disk.serial} + + )} +
+
+ {formatBytes(disk.size_bytes)} • {disk.partitions.length} partition{disk.partitions.length !== 1 ? 's' : ''} +
+
+
+ + {expandedDisks.has(disk.serial) && ( +
+ {disk.partitions.length === 0 ? ( +
+ + No partitions found +
+ ) : ( + disk.partitions.map((partition, partIndex) => ( +
+
+ +
+
+
+ + Partition {partIndex + 1} + +
+ + {partition.fs_type.toUpperCase()} + + +
+
+
+ {formatBytes(partition.size_bytes)} + + LBA {partition.start_lba} - {partition.end_lba} +
+
+
+ )) + )} +
+ )} +
+ ))} +
+ )} +
+ ) : ( + } + title="Failed to load details" + description="Could not load the snapshot details" + variant="subtle" + size="sm" + /> + )} +
+
+ )} +
+ ))} +
+ )} +
+ + {/* File Browser Modal */} + +
+ ); +}; \ No newline at end of file diff --git a/webui/src/pages/Machines/Machines.jsx b/webui/src/pages/Machines/Machines.jsx index c05366a..0f7d584 100644 --- a/webui/src/pages/Machines/Machines.jsx +++ b/webui/src/pages/Machines/Machines.jsx @@ -1,4 +1,5 @@ import React, {useState, useEffect, useContext} from 'react'; +import {useNavigate} from 'react-router-dom'; import {UserContext} from '@/common/contexts/UserContext.jsx'; import {useToast} from '@/common/contexts/ToastContext.jsx'; import {getRequest, postRequest, deleteRequest} from '@/common/utils/RequestUtil.js'; @@ -27,6 +28,7 @@ import './styles.sass'; export const Machines = () => { const {user: currentUser} = useContext(UserContext); + const navigate = useNavigate(); const toast = useToast(); const [machines, setMachines] = useState([]); const [loading, setLoading] = useState(true); @@ -194,6 +196,10 @@ export const Machines = () => { } }; + const handleMachineClick = (machineId) => { + navigate(`/machines/${machineId}`); + }; + if (loading) { return (
@@ -220,7 +226,12 @@ export const Machines = () => { {machines.map(machine => ( - + handleMachineClick(machine.id)} + >
@@ -238,7 +249,10 @@ export const Machines = () => { variant="subtle" size="sm" icon={} - onClick={() => openProvisioningModal(machine)} + onClick={(e) => { + e.stopPropagation(); + openProvisioningModal(machine); + }} > Code @@ -246,7 +260,10 @@ export const Machines = () => { variant="danger" size="sm" icon={} - onClick={() => handleDelete(machine.id, machine.name)} + onClick={(e) => { + e.stopPropagation(); + handleDelete(machine.id, machine.name); + }} > Delete diff --git a/webui/src/pages/Machines/index.js b/webui/src/pages/Machines/index.js index bdcae87..e27d818 100644 --- a/webui/src/pages/Machines/index.js +++ b/webui/src/pages/Machines/index.js @@ -1 +1,2 @@ export {Machines as default} from './Machines.jsx'; +export {MachineDetails} from './MachineDetails.jsx'; diff --git a/webui/src/pages/Machines/machine-details.sass b/webui/src/pages/Machines/machine-details.sass new file mode 100644 index 0000000..2cb7f8a --- /dev/null +++ b/webui/src/pages/Machines/machine-details.sass @@ -0,0 +1,303 @@ +// Machine Details Styles +.machine-overview + .machine-header + display: flex + align-items: center + gap: 1rem + + .machine-icon + display: flex + align-items: center + justify-content: center + width: 3rem + height: 3rem + background: rgba(15, 98, 254, 0.1) + border-radius: var(--radius) + color: var(--accent) + flex-shrink: 0 + + .machine-meta + flex: 1 + + .machine-title + font-size: 1.25rem + font-weight: 600 + color: var(--text) + margin: 0 0 0.5rem 0 + + .machine-subtitle + display: flex + align-items: center + gap: 0.25rem + font-size: 0.875rem + color: var(--text-dim) + + .uuid-text + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace + background: var(--bg-elev) + padding: 0.125rem 0.375rem + border-radius: var(--radius-sm) + font-size: 0.75rem + +// Snapshots Section +.snapshots-section + display: flex + flex-direction: column + gap: 1rem + +.section-header + display: flex + align-items: center + justify-content: space-between + +.section-title + display: flex + align-items: center + gap: 0.5rem + font-size: 1.125rem + font-weight: 600 + color: var(--text) + margin: 0 + +.snapshots-list + display: flex + flex-direction: column + gap: 0.5rem + +.snapshot-container + display: flex + flex-direction: column + gap: 0.5rem + +.snapshot-card + cursor: pointer + transition: all 0.2s ease + border: 2px solid transparent + + &:hover + border-color: rgba(15, 98, 254, 0.3) + + &--selected + border-color: var(--accent) + background: rgba(15, 98, 254, 0.04) + +.snapshot-header + display: flex + align-items: center + gap: 1rem + width: 100% + +.snapshot-icon + display: flex + align-items: center + justify-content: center + width: 2.5rem + height: 2.5rem + background: rgba(15, 98, 254, 0.1) + border-radius: var(--radius) + color: var(--accent) + flex-shrink: 0 + +.snapshot-info + flex: 1 + min-width: 0 + +.snapshot-id + font-weight: 600 + color: var(--text) + font-size: 0.875rem + +.snapshot-date + font-size: 0.75rem + color: var(--text-dim) + margin-top: 0.125rem + +.snapshot-hash + display: flex + flex-direction: column + align-items: flex-end + gap: 0.125rem + flex-shrink: 0 + +.hash-label + font-size: 0.75rem + color: var(--text-dim) + +.hash-value + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace + font-size: 0.75rem + background: var(--bg-elev) + padding: 0.125rem 0.375rem + border-radius: var(--radius-sm) + color: var(--text) + +// Snapshot Details +.snapshot-details + margin-left: 1rem + border-left: 3px solid rgba(15, 98, 254, 0.3) + background: var(--bg-elev) + +.details-content + display: flex + flex-direction: column + gap: 1rem + +.details-header + display: flex + align-items: center + justify-content: space-between + +.details-title + display: flex + align-items: center + gap: 0.5rem + font-size: 1rem + font-weight: 600 + color: var(--text) + margin: 0 + +// Disks List +.disks-list + display: flex + flex-direction: column + gap: 0.75rem + +.disk-item + border: 1px solid var(--border) + border-radius: var(--radius) + overflow: hidden + background: var(--bg-alt) + +.disk-header + display: flex + align-items: center + gap: 0.75rem + padding: 0.75rem 1rem + cursor: pointer + transition: background-color 0.2s ease + + &:hover + background: var(--bg-elev) + +.disk-toggle + display: flex + align-items: center + justify-content: center + color: var(--text-dim) + flex-shrink: 0 + +.disk-icon + display: flex + align-items: center + justify-content: center + width: 2rem + height: 2rem + background: rgba(0, 67, 206, 0.1) + border-radius: var(--radius-sm) + color: #0043ce + flex-shrink: 0 + +.disk-info + flex: 1 + min-width: 0 + +.disk-title + display: flex + align-items: center + gap: 0.5rem + font-weight: 600 + color: var(--text) + font-size: 0.875rem + +.disk-serial + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace + font-size: 0.75rem + color: var(--text-dim) + background: var(--bg-elev) + padding: 0.125rem 0.25rem + border-radius: var(--radius-sm) + +.disk-size + font-size: 0.75rem + color: var(--text-dim) + margin-top: 0.125rem + +// Partitions List +.partitions-list + background: var(--bg-elev) + border-top: 1px solid var(--border) + +.no-partitions + display: flex + align-items: center + justify-content: center + gap: 0.5rem + padding: 1rem + color: var(--text-dim) + font-size: 0.875rem + +.partition-item + display: flex + align-items: flex-start + gap: 0.75rem + padding: 0.75rem 1rem + border-bottom: 1px solid rgba(223, 227, 232, 0.5) + + &:last-child + border-bottom: none + +.partition-indicator + display: flex + align-items: center + padding-top: 0.375rem + color: var(--accent) + flex-shrink: 0 + +.partition-info + flex: 1 + min-width: 0 + +.partition-header + display: flex + align-items: center + justify-content: space-between + gap: 0.5rem + margin-bottom: 0.25rem + +.partition-badges + display: flex + align-items: center + gap: 0.5rem + +.partition-title + font-weight: 500 + color: var(--text) + font-size: 0.875rem + +.partition-details + display: flex + align-items: center + gap: 0.5rem + font-size: 0.75rem + color: var(--text-dim) + +// Responsive design +@media (max-width: 768px) + .snapshot-header + flex-direction: column + align-items: flex-start + gap: 0.5rem + + .snapshot-hash + align-items: flex-start + + .disk-header + flex-wrap: wrap + + .partition-header + flex-direction: column + align-items: flex-start + gap: 0.25rem + + .partition-details + flex-wrap: wrap \ No newline at end of file diff --git a/webui/src/pages/Machines/styles.sass b/webui/src/pages/Machines/styles.sass index ba31046..a387770 100644 --- a/webui/src/pages/Machines/styles.sass +++ b/webui/src/pages/Machines/styles.sass @@ -1,4 +1,11 @@ .machine-card + cursor: pointer + transition: all 0.2s ease + + &:hover + transform: translateY(-2px) + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1) + .machine-card-header display: flex align-items: flex-start @@ -44,6 +51,7 @@ display: flex gap: 0.5rem flex-shrink: 0 + flex-wrap: wrap .modal-description color: var(--color-text-muted)