Implement file browser & web ui components

This commit is contained in:
Mathias Wagner
2025-09-10 13:47:19 +02:00
parent 0a16e46372
commit 7ffd64049a
18 changed files with 2150 additions and 12 deletions

View File

@@ -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<u64>,
pub meta_hash: String,
}
#[derive(Debug, Serialize)]
pub struct DirectoryListing {
pub path: String,
pub entries: Vec<FileSystemEntry>,
pub parent_hash: Option<String>,
}
#[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<DirectoryListing> {
// 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<DirectoryListing> {
// 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<String>,
user: &User,
) -> AppResult<Response<Body>> {
// 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<FileMetadata> {
// 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<DirectoryListing> {
// 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()
}
}

View File

@@ -2,3 +2,4 @@ pub mod auth;
pub mod machines;
pub mod snapshots;
pub mod users;
pub mod files;

View File

@@ -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);

View File

@@ -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<String>,
}
pub async fn list_partition_root(
State(pool): State<DbPool>,
Path((machine_id, snapshot_id, partition_index)): Path<(i64, String, usize)>,
auth_user: AuthUser,
) -> AppResult<Json<DirectoryListing>> {
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<DbPool>,
Path((machine_id, snapshot_id, partition_index, dir_hash)): Path<(i64, String, usize, String)>,
auth_user: AuthUser,
) -> AppResult<Json<DirectoryListing>> {
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<DbPool>,
Path((machine_id, snapshot_id, partition_index, file_hash)): Path<(i64, String, usize, String)>,
Query(query): Query<DownloadQuery>,
auth_user: AuthUser,
) -> AppResult<Response<Body>> {
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<DbPool>,
Path((machine_id, snapshot_id, partition_index, file_hash)): Path<(i64, String, usize, String)>,
auth_user: AuthUser,
) -> AppResult<Json<FileMetadata>> {
let metadata = FilesController::get_file_metadata(
&pool,
machine_id,
snapshot_id,
partition_index,
file_hash,
&auth_user.user,
).await?;
Ok(Json(metadata))
}

View File

@@ -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;

View File

@@ -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<Bytes> {
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<Bytes> {
// 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));
}

View File

@@ -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: <Navigate to="/dashboard"/>},
{path: "/dashboard", element: <Placeholder title="Dashboard"/>},
{path: "/machines", element: <Machines/>},
{path: "/machines/:id", element: <MachineDetails/>},
{path: "/servers", element: <Placeholder title="Servers"/>},
{path: "/settings", element: <Placeholder title="Settings"/>},
{path: "/admin/users", element: <UserManagement/>},

View File

@@ -45,5 +45,9 @@
color: #1976d2
&--user
background: var(--bg-elev)
color: var(--text-dim)
&--subtle
background: var(--bg-elev)
color: var(--text-dim)

View File

@@ -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 (
<div className={`empty-state ${className}`}>
<div className={emptyStateClasses}>
{icon && <div className="empty-state-icon">{icon}</div>}
{title && <h3 className="empty-state-title">{title}</h3>}
{description && <p className="empty-state-description">{description}</p>}

View File

@@ -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

View File

@@ -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 <FolderIcon size={20} weight="duotone"/>;
} else if (entry.entry_type === 'symlink') {
return <LinkIcon size={20} weight="duotone"/>;
} 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 <FileTextIcon size={20} weight="duotone"/>;
case 'pdf':
return <FilePdfIcon size={20} weight="duotone"/>;
case 'zip':
case 'rar':
case 'tar':
case 'gz':
case '7z':
return <FileZipIcon size={20} weight="duotone"/>;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'bmp':
case 'svg':
case 'webp':
return <FileImageIcon size={20} weight="duotone"/>;
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
case 'flv':
case 'webm':
return <FileVideoIcon size={20} weight="duotone"/>;
case 'mp3':
case 'wav':
case 'flac':
case 'aac':
case 'ogg':
return <FileAudioIcon size={20} weight="duotone"/>;
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 <FileCodeIcon size={20} weight="duotone"/>;
case 'exe':
case 'msi':
case 'deb':
case 'rpm':
case 'dmg':
case 'app':
return <GearIcon size={20} weight="duotone"/>;
case 'db':
case 'sqlite':
case 'mysql':
return <Database size={20} weight="duotone"/>;
default:
return <FileIcon size={20} weight="duotone"/>;
}
}
};
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`File Browser - ${partitionInfo?.fs_type?.toUpperCase() || 'Partition'} Partition`}
size="xl"
className="file-browser-modal"
>
<div className="file-browser">
{/* Navigation Bar */}
<div className="file-browser-nav">
<div className="nav-controls">
<Button
variant="subtle"
size="sm"
icon={<ArrowLeftIcon size={16}/>}
onClick={goBack}
disabled={currentPath.length === 0 || loading}
>
Back
</Button>
<Button
variant="subtle"
size="sm"
icon={<HouseIcon size={16}/>}
onClick={loadPartitionRoot}
disabled={currentPath.length === 0 || loading}
>
Root
</Button>
</div>
{/* Breadcrumbs */}
<div className="breadcrumbs">
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={index}>
{index > 0 && <span className="breadcrumb-separator">/</span>}
<button
className={`breadcrumb ${index === breadcrumbs.length - 1 ? 'breadcrumb--current' : ''}`}
onClick={() => navigateToBreadcrumb(index)}
disabled={index === breadcrumbs.length - 1 || loading}
>
{crumb.name}
</button>
</React.Fragment>
))}
</div>
</div>
{/* File List */}
<div className="file-browser-content">
{loading ? (
<div className="file-browser-loading">
<LoadingSpinner text="Loading directory contents..."/>
</div>
) : entries.length === 0 ? (
<EmptyState
icon={<FolderOpenIcon size={48} weight="duotone"/>}
title="Empty directory"
description="This directory doesn't contain any files or subdirectories"
variant="subtle"
size="sm"
/>
) : (
<>
{/* File List Header */}
<div className="file-list-header">
<div className="file-list-header-item">
<span>Name</span>
</div>
<div className="file-list-header-item">
<span>Type</span>
</div>
<div className="file-list-header-item">
<span>Size</span>
</div>
</div>
<div className="file-list">
{entries.map((entry, index) => (
<div
key={index}
className={`file-entry file-entry--${entry.entry_type}`}
onClick={() => handleEntryClick(entry)}
>
<div className="file-entry-main">
<div className="file-entry-icon" style={{color: getEntryTypeColor(entry)}}>
{getFileIcon(entry)}
</div>
<div className="file-entry-info">
<div className="file-entry-name">
{entry.name}
</div>
</div>
</div>
<div className="file-entry-type-cell">
<span className="file-entry-type">
{entry.entry_type === 'dir' ? 'Folder' :
entry.entry_type === 'symlink' ? 'Link' :
entry.name.includes('.') ? entry.name.split('.').pop()?.toUpperCase() + ' File' : 'File'}
</span>
</div>
<div className="file-entry-size-cell">
{entry.size_bytes ? (
<span className="file-entry-size">
{formatFileSize(entry.size_bytes)}
</span>
) : (
<span className="file-entry-size-empty"></span>
)}
</div>
{entry.entry_type === 'file' && (
<div className="file-entry-actions">
<Button
variant="subtle"
size="sm"
icon={<DownloadIcon size={14}/>}
onClick={(e) => {
e.stopPropagation();
downloadFile(entry.meta_hash, entry.name);
}}
title="Download file"
/>
</div>
)}
</div>
))}
</div>
</>
)}
</div>
</div>
<ModalActions>
<Button
variant="subtle"
onClick={onClose}
icon={<XIcon size={16}/>}
>
Close
</Button>
</ModalActions>
</Modal>
);
};

View File

@@ -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

View File

@@ -0,0 +1 @@
export {FileBrowser} from './FileBrowser.jsx';

View File

@@ -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 (
<div className="content">
<LoadingSpinner centered text="Loading machine details..."/>
</div>
);
}
if (!machine) {
return (
<div className="content">
<EmptyState
icon={<ComputerTowerIcon size={48} weight="duotone"/>}
title="Machine not found"
description="The requested machine could not be found or you don't have access to it"
action={
<Button
variant="primary"
icon={<ArrowLeftIcon size={16}/>}
onClick={() => navigate('/machines')}
>
Back to Machines
</Button>
}
/>
</div>
);
}
return (
<div className="content">
<PageHeader
title={machine.name}
subtitle={`Machine details and snapshots • ${formatUuid(machine.uuid)}`}
actions={
<Button
variant="subtle"
icon={<ArrowLeftIcon size={16}/>}
onClick={() => navigate('/machines')}
>
Back to Machines
</Button>
}
/>
{/* Machine Information */}
<Card className="machine-overview">
<CardHeader>
<div className="machine-header">
<div className="machine-icon">
<ComputerTowerIcon size={24} weight="duotone"/>
</div>
<div className="machine-meta">
<h3 className="machine-title">{machine.name}</h3>
<div className="machine-subtitle">
<IdentificationCardIcon size={14}/>
<span className="uuid-text">{machine.uuid}</span>
</div>
</div>
</div>
</CardHeader>
<CardBody>
<DetailList>
<DetailItem icon={<CalendarIcon size={16}/>}>
Registered: {formatDate(machine.created_at)}
</DetailItem>
<DetailItem icon={<CameraIcon size={16}/>}>
Total Snapshots: {snapshots.length}
</DetailItem>
</DetailList>
</CardBody>
</Card>
{/* Snapshots Section */}
<div className="snapshots-section">
<div className="section-header">
<h2 className="section-title">
<CameraIcon size={20} weight="duotone"/>
Snapshots
</h2>
{snapshotsLoading && <LoadingSpinner size="sm" text="Loading snapshots..."/>}
</div>
{snapshots.length === 0 && !snapshotsLoading ? (
<EmptyState
icon={<CameraIcon size={48} weight="duotone"/>}
title="No snapshots found"
description="This machine doesn't have any snapshots yet. Snapshots will appear here once they are created."
variant="subtle"
/>
) : (
<div className="snapshots-list">
{snapshots.map(snapshot => (
<div key={snapshot.id} className="snapshot-container">
<Card
hover
className={`snapshot-card ${selectedSnapshot?.id === snapshot.id ? 'snapshot-card--selected' : ''}`}
onClick={() => handleSnapshotClick(snapshot)}
>
<CardHeader>
<div className="snapshot-header">
<div className="snapshot-icon">
<CameraIcon size={20} weight="duotone"/>
</div>
<div className="snapshot-info">
<div className="snapshot-id">
Snapshot {snapshot.id.substring(0, 8)}
</div>
<div className="snapshot-date">
{formatDate(snapshot.created_at)}
</div>
</div>
<div className="snapshot-hash">
<span className="hash-label">Hash:</span>
<span className="hash-value">{snapshot.snapshot_hash.substring(0, 12)}</span>
</div>
</div>
</CardHeader>
</Card>
{/* Snapshot Details */}
{selectedSnapshot?.id === snapshot.id && (
<Card className="snapshot-details">
<CardBody>
{detailsLoading ? (
<LoadingSpinner text="Loading snapshot details..."/>
) : snapshotDetails ? (
<div className="details-content">
<div className="details-header">
<h4 className="details-title">
<DatabaseIcon size={18} weight="duotone"/>
Disks and Partitions
</h4>
<Badge variant="subtle">
{snapshotDetails.disks.length} disk{snapshotDetails.disks.length !== 1 ? 's' : ''}
</Badge>
</div>
{snapshotDetails.disks.length === 0 ? (
<EmptyState
icon={<HardDriveIcon size={32} weight="duotone"/>}
title="No disk data"
description="This snapshot doesn't contain any disk information"
variant="subtle"
size="sm"
/>
) : (
<div className="disks-list">
{snapshotDetails.disks.map((disk, index) => (
<div key={disk.serial} className="disk-item">
<div
className="disk-header"
onClick={() => toggleDiskExpansion(disk.serial)}
>
<div className="disk-toggle">
{expandedDisks.has(disk.serial) ?
<CaretDownIcon size={16}/> :
<CaretRightIcon size={16}/>
}
</div>
<div className="disk-icon">
<HardDriveIcon size={18} weight="duotone"/>
</div>
<div className="disk-info">
<div className="disk-title">
Disk {index + 1}
{disk.serial && (
<span className="disk-serial">
{disk.serial}
</span>
)}
</div>
<div className="disk-size">
{formatBytes(disk.size_bytes)} {disk.partitions.length} partition{disk.partitions.length !== 1 ? 's' : ''}
</div>
</div>
</div>
{expandedDisks.has(disk.serial) && (
<div className="partitions-list">
{disk.partitions.length === 0 ? (
<div className="no-partitions">
<FolderIcon size={16} weight="duotone"/>
<span>No partitions found</span>
</div>
) : (
disk.partitions.map((partition, partIndex) => (
<div key={partIndex} className="partition-item">
<div className="partition-indicator">
<CircleIcon size={8} weight="fill"/>
</div>
<div className="partition-info">
<div className="partition-header">
<span className="partition-title">
Partition {partIndex + 1}
</span>
<div className="partition-badges">
<Badge
variant={getFsTypeColor(partition.fs_type)}
size="sm"
>
{partition.fs_type.toUpperCase()}
</Badge>
<Button
variant="subtle"
size="sm"
icon={<FolderOpenIcon size={14}/>}
onClick={(e) => {
e.stopPropagation();
openFileBrowser(partition, index, partIndex);
}}
>
Browse Files
</Button>
</div>
</div>
<div className="partition-details">
<span>{formatBytes(partition.size_bytes)}</span>
<span></span>
<span>LBA {partition.start_lba} - {partition.end_lba}</span>
</div>
</div>
</div>
))
)}
</div>
)}
</div>
))}
</div>
)}
</div>
) : (
<EmptyState
icon={<DatabaseIcon size={32} weight="duotone"/>}
title="Failed to load details"
description="Could not load the snapshot details"
variant="subtle"
size="sm"
/>
)}
</CardBody>
</Card>
)}
</div>
))}
</div>
)}
</div>
{/* File Browser Modal */}
<FileBrowser
isOpen={fileBrowserOpen}
onClose={closeFileBrowser}
machineId={id}
snapshotId={selectedSnapshot?.id}
partitionIndex={selectedPartition?.globalPartitionIndex}
partitionInfo={selectedPartition}
/>
</div>
);
};

View File

@@ -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 (
<div className="content">
@@ -220,7 +226,12 @@ export const Machines = () => {
<Grid minWidth="400px">
{machines.map(machine => (
<Card key={machine.id} hover className="machine-card">
<Card
key={machine.id}
hover
className="machine-card"
onClick={() => handleMachineClick(machine.id)}
>
<CardHeader>
<div className="machine-card-header">
<div className="machine-icon">
@@ -238,7 +249,10 @@ export const Machines = () => {
variant="subtle"
size="sm"
icon={<QrCodeIcon size={14}/>}
onClick={() => openProvisioningModal(machine)}
onClick={(e) => {
e.stopPropagation();
openProvisioningModal(machine);
}}
>
Code
</Button>
@@ -246,7 +260,10 @@ export const Machines = () => {
variant="danger"
size="sm"
icon={<TrashIcon size={14}/>}
onClick={() => handleDelete(machine.id, machine.name)}
onClick={(e) => {
e.stopPropagation();
handleDelete(machine.id, machine.name);
}}
>
Delete
</Button>

View File

@@ -1 +1,2 @@
export {Machines as default} from './Machines.jsx';
export {MachineDetails} from './MachineDetails.jsx';

View File

@@ -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

View File

@@ -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)