Compare commits
3 Commits
e595fcbdac
...
08cf515d2a
Author | SHA1 | Date | |
---|---|---|---|
|
08cf515d2a | ||
|
7ffd64049a | ||
|
0a16e46372 |
365
server/src/controllers/files.rs
Normal file
365
server/src/controllers/files.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@@ -2,3 +2,4 @@ pub mod auth;
|
|||||||
pub mod machines;
|
pub mod machines;
|
||||||
pub mod snapshots;
|
pub mod snapshots;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod files;
|
||||||
|
@@ -8,7 +8,7 @@ use axum::{
|
|||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
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 std::path::Path;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
@@ -44,6 +44,10 @@ async fn main() -> Result<()> {
|
|||||||
.route("/machines/{id}", delete(machines::delete_machine))
|
.route("/machines/{id}", delete(machines::delete_machine))
|
||||||
.route("/machines/{id}/snapshots", get(snapshots::get_machine_snapshots))
|
.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}", 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())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|
||||||
|
77
server/src/routes/files.rs
Normal file
77
server/src/routes/files.rs
Normal 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))
|
||||||
|
}
|
@@ -1,7 +1,8 @@
|
|||||||
|
pub mod accounts;
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod machines;
|
pub mod machines;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod accounts;
|
|
||||||
pub mod snapshots;
|
pub mod snapshots;
|
||||||
|
pub mod files;
|
||||||
|
@@ -141,9 +141,9 @@ impl ConnectionHandler {
|
|||||||
// Read message header
|
// Read message header
|
||||||
let header = self.read_header().await?;
|
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 {
|
let payload = if header.payload_len > 0 {
|
||||||
self.read_payload(header.payload_len).await?
|
self.read_payload(header.cmd, header.payload_len).await?
|
||||||
} else {
|
} else {
|
||||||
Bytes::new()
|
Bytes::new()
|
||||||
};
|
};
|
||||||
@@ -184,9 +184,15 @@ impl ConnectionHandler {
|
|||||||
.context("Failed to parse message header")
|
.context("Failed to parse message header")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read message payload
|
/// Read message payload with appropriate size limit based on command type
|
||||||
async fn read_payload(&mut self, len: u32) -> Result<Bytes> {
|
async fn read_payload(&mut self, cmd: Command, len: u32) -> Result<Bytes> {
|
||||||
if len as usize > self.config.meta_size_limit {
|
// 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));
|
return Err(anyhow::anyhow!("Payload too large: {} bytes", len));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,8 +1,11 @@
|
|||||||
// Mock sync client for testing the Arkendro sync server
|
// Mock sync client for testing the Arkendro sync server
|
||||||
// This implements the binary protocol specified in PROTOCOL.md
|
// This implements the binary protocol specified in PROTOCOL.md
|
||||||
|
|
||||||
use std::io::{Read, Write, Result, Error, ErrorKind};
|
use std::io::{Read, Write, Result, Error, ErrorKind, BufReader, Seek};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Command codes from the protocol
|
/// Command codes from the protocol
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -335,6 +338,221 @@ impl SnapshotObj {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE: usize = 4 * 1024 * 1024 - 36; // 4 MiB minus protocol overhead (32 bytes hash + 4 bytes length)
|
||||||
|
|
||||||
|
/// Chunk reference for memory-efficient storage
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ChunkRef {
|
||||||
|
hash: [u8; 32],
|
||||||
|
file_path: PathBuf,
|
||||||
|
offset: u64,
|
||||||
|
size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File system scanner for recursive directory processing
|
||||||
|
struct FileSystemScanner {
|
||||||
|
root_path: PathBuf,
|
||||||
|
chunk_refs: Vec<ChunkRef>, // All chunks with file references
|
||||||
|
file_metadata: HashMap<PathBuf, ([u8; 32], FileObj)>, // path -> (meta_hash, metadata)
|
||||||
|
dir_metadata: HashMap<PathBuf, ([u8; 32], DirObj)>, // path -> (meta_hash, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemScanner {
|
||||||
|
fn new<P: AsRef<Path>>(root_path: P) -> Result<Self> {
|
||||||
|
let root_path = root_path.as_ref().to_path_buf();
|
||||||
|
if !root_path.exists() {
|
||||||
|
return Err(Error::new(ErrorKind::NotFound,
|
||||||
|
format!("Root directory does not exist: {}", root_path.display())));
|
||||||
|
}
|
||||||
|
if !root_path.is_dir() {
|
||||||
|
return Err(Error::new(ErrorKind::InvalidInput,
|
||||||
|
format!("Root path is not a directory: {}", root_path.display())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
root_path,
|
||||||
|
chunk_refs: Vec::new(),
|
||||||
|
file_metadata: HashMap::new(),
|
||||||
|
dir_metadata: HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan(&mut self) -> Result<()> {
|
||||||
|
println!("📁 Scanning directory: {}", self.root_path.display());
|
||||||
|
self.scan_directory(&self.root_path.clone())?;
|
||||||
|
println!("✓ Found {} files and {} directories", self.file_metadata.len(), self.dir_metadata.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_directory(&mut self, dir_path: &Path) -> Result<[u8; 32]> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
// Read directory contents
|
||||||
|
let dir_entries = fs::read_dir(dir_path)?;
|
||||||
|
let mut entry_list: Vec<_> = dir_entries.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
entry_list.sort_by_key(|entry| entry.file_name());
|
||||||
|
|
||||||
|
for entry in entry_list {
|
||||||
|
let entry_path = entry.path();
|
||||||
|
let entry_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Skip hidden files and common system files
|
||||||
|
if entry_name.starts_with('.') || entry_name == "Thumbs.db" || entry_name == "desktop.ini" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = entry.metadata()?;
|
||||||
|
|
||||||
|
if metadata.is_file() {
|
||||||
|
let target_meta_hash = self.scan_file(&entry_path)?;
|
||||||
|
entries.push(DirEntry {
|
||||||
|
entry_type: EntryType::File,
|
||||||
|
name: entry_name,
|
||||||
|
target_meta_hash,
|
||||||
|
});
|
||||||
|
} else if metadata.is_dir() {
|
||||||
|
let target_meta_hash = self.scan_directory(&entry_path)?;
|
||||||
|
entries.push(DirEntry {
|
||||||
|
entry_type: EntryType::Dir,
|
||||||
|
name: entry_name,
|
||||||
|
target_meta_hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Note: We skip symlinks for now as they require special handling
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory metadata
|
||||||
|
let dir_obj = DirObj::new(entries);
|
||||||
|
let dir_data = dir_obj.serialize();
|
||||||
|
let dir_hash = blake3_hash(&dir_data);
|
||||||
|
|
||||||
|
let relative_path = if dir_path == &self.root_path {
|
||||||
|
PathBuf::from("/")
|
||||||
|
} else {
|
||||||
|
dir_path.strip_prefix(&self.root_path)
|
||||||
|
.unwrap_or(dir_path)
|
||||||
|
.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.dir_metadata.insert(relative_path, (dir_hash, dir_obj));
|
||||||
|
Ok(dir_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_file(&mut self, file_path: &Path) -> Result<[u8; 32]> {
|
||||||
|
let metadata = fs::metadata(file_path)?;
|
||||||
|
let total_size = metadata.len();
|
||||||
|
|
||||||
|
let relative_path = file_path.strip_prefix(&self.root_path)
|
||||||
|
.unwrap_or(file_path)
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
// Use unified streaming method for all files - efficient for any size
|
||||||
|
println!(" Processing file ({} bytes): {}", total_size, file_path.display());
|
||||||
|
let chunk_hashes = self.hash_file_chunks(file_path, &relative_path)?;
|
||||||
|
|
||||||
|
// Create file metadata
|
||||||
|
let file_obj = FileObj::new(total_size, chunk_hashes);
|
||||||
|
let file_data = file_obj.serialize();
|
||||||
|
let file_meta_hash = blake3_hash(&file_data);
|
||||||
|
|
||||||
|
self.file_metadata.insert(relative_path, (file_meta_hash, file_obj));
|
||||||
|
|
||||||
|
Ok(file_meta_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_all_chunk_hashes(&self) -> Vec<[u8; 32]> {
|
||||||
|
let mut all_hashes = Vec::new();
|
||||||
|
|
||||||
|
// Add chunks from chunk references (all files use this now)
|
||||||
|
for chunk_ref in &self.chunk_refs {
|
||||||
|
all_hashes.push(chunk_ref.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
all_hashes.sort();
|
||||||
|
all_hashes.dedup();
|
||||||
|
|
||||||
|
all_hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash file chunks using efficient streaming I/O (works for any file size)
|
||||||
|
fn hash_file_chunks(&mut self, file_path: &Path, _relative_path: &Path) -> Result<Vec<[u8; 32]>> {
|
||||||
|
let file = File::open(file_path)?;
|
||||||
|
let mut reader = BufReader::with_capacity(CHUNK_SIZE * 2, file);
|
||||||
|
let mut chunk_hashes = Vec::new();
|
||||||
|
let mut chunk_idx = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut buffer = vec![0u8; CHUNK_SIZE];
|
||||||
|
let bytes_read = reader.read(&mut buffer)?;
|
||||||
|
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.truncate(bytes_read);
|
||||||
|
|
||||||
|
// Hash chunk
|
||||||
|
let chunk_hash = blake3_hash(&buffer);
|
||||||
|
|
||||||
|
// Store chunk reference
|
||||||
|
self.chunk_refs.push(ChunkRef {
|
||||||
|
hash: chunk_hash,
|
||||||
|
file_path: file_path.to_path_buf(),
|
||||||
|
offset: (chunk_idx * CHUNK_SIZE) as u64,
|
||||||
|
size: bytes_read,
|
||||||
|
});
|
||||||
|
|
||||||
|
chunk_hashes.push(chunk_hash);
|
||||||
|
chunk_idx += 1;
|
||||||
|
|
||||||
|
if chunk_idx % 100 == 0 {
|
||||||
|
println!(" Processed {} chunks...", chunk_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" Completed {} chunks", chunk_idx);
|
||||||
|
Ok(chunk_hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read chunk data on demand from file
|
||||||
|
fn read_chunk_data(&self, chunk_ref: &ChunkRef) -> Result<Vec<u8>> {
|
||||||
|
let mut file = File::open(&chunk_ref.file_path)?;
|
||||||
|
file.seek(std::io::SeekFrom::Start(chunk_ref.offset))?;
|
||||||
|
|
||||||
|
let mut buffer = vec![0u8; chunk_ref.size];
|
||||||
|
file.read_exact(&mut buffer)?;
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get chunk reference by hash
|
||||||
|
fn find_chunk_ref(&self, hash: &[u8; 32]) -> Option<&ChunkRef> {
|
||||||
|
self.chunk_refs.iter().find(|chunk_ref| chunk_ref.hash == *hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_all_metadata_items(&self) -> Vec<(MetaType, [u8; 32])> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
// Add file metadata
|
||||||
|
for (_, (meta_hash, _)) in &self.file_metadata {
|
||||||
|
items.push((MetaType::File, *meta_hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add directory metadata
|
||||||
|
for (_, (meta_hash, _)) in &self.dir_metadata {
|
||||||
|
items.push((MetaType::Dir, *meta_hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_root_dir_hash(&self) -> Option<[u8; 32]> {
|
||||||
|
self.dir_metadata.get(&PathBuf::from("/"))
|
||||||
|
.map(|(hash, _)| *hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Simple sync client for testing
|
/// Simple sync client for testing
|
||||||
struct SyncClient {
|
struct SyncClient {
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
@@ -461,9 +679,6 @@ impl SyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_chunk(&mut self, hash: &[u8; 32], data: &[u8]) -> Result<()> {
|
fn send_chunk(&mut self, hash: &[u8; 32], data: &[u8]) -> Result<()> {
|
||||||
println!("Sending chunk {} bytes...", data.len());
|
|
||||||
println!("Chunk hash: {}", hex::encode(hash));
|
|
||||||
|
|
||||||
// Verify hash matches data
|
// Verify hash matches data
|
||||||
let computed_hash = blake3_hash(data);
|
let computed_hash = blake3_hash(data);
|
||||||
if computed_hash != *hash {
|
if computed_hash != *hash {
|
||||||
@@ -480,7 +695,6 @@ impl SyncClient {
|
|||||||
let (cmd, payload) = self.receive_message()?;
|
let (cmd, payload) = self.receive_message()?;
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::ChunkOk => {
|
Command::ChunkOk => {
|
||||||
println!("✓ Chunk uploaded successfully");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Command::ChunkFail => {
|
Command::ChunkFail => {
|
||||||
@@ -543,9 +757,6 @@ impl SyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_metadata(&mut self, meta_type: MetaType, meta_hash: &[u8; 32], body: &[u8]) -> Result<()> {
|
fn send_metadata(&mut self, meta_type: MetaType, meta_hash: &[u8; 32], body: &[u8]) -> Result<()> {
|
||||||
println!("Sending {:?} metadata {} bytes...", meta_type, body.len());
|
|
||||||
println!("Metadata hash: {}", hex::encode(meta_hash));
|
|
||||||
|
|
||||||
// Verify hash matches body
|
// Verify hash matches body
|
||||||
let computed_hash = blake3_hash(body);
|
let computed_hash = blake3_hash(body);
|
||||||
if computed_hash != *meta_hash {
|
if computed_hash != *meta_hash {
|
||||||
@@ -563,7 +774,6 @@ impl SyncClient {
|
|||||||
let (cmd, payload) = self.receive_message()?;
|
let (cmd, payload) = self.receive_message()?;
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::MetaOk => {
|
Command::MetaOk => {
|
||||||
println!("✓ Metadata uploaded successfully");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Command::MetaFail => {
|
Command::MetaFail => {
|
||||||
@@ -579,9 +789,6 @@ impl SyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_snapshot(&mut self, snapshot_hash: &[u8; 32], snapshot_data: &[u8]) -> Result<()> {
|
fn send_snapshot(&mut self, snapshot_hash: &[u8; 32], snapshot_data: &[u8]) -> Result<()> {
|
||||||
println!("Sending snapshot {} bytes...", snapshot_data.len());
|
|
||||||
println!("Snapshot hash: {}", hex::encode(snapshot_hash));
|
|
||||||
|
|
||||||
// Verify hash matches data
|
// Verify hash matches data
|
||||||
let computed_hash = blake3_hash(snapshot_data);
|
let computed_hash = blake3_hash(snapshot_data);
|
||||||
if computed_hash != *snapshot_hash {
|
if computed_hash != *snapshot_hash {
|
||||||
@@ -610,14 +817,9 @@ impl SyncClient {
|
|||||||
let missing_chunks_count = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
|
let missing_chunks_count = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
|
||||||
let missing_metas_count = u32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]) as usize;
|
let missing_metas_count = u32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]) as usize;
|
||||||
|
|
||||||
let mut error_msg = format!("Server rejected snapshot: {} missing chunks, {} missing metadata items",
|
let error_msg = format!("Server rejected snapshot: {} missing chunks, {} missing metadata items",
|
||||||
missing_chunks_count, missing_metas_count);
|
missing_chunks_count, missing_metas_count);
|
||||||
|
|
||||||
// Optionally parse the actual missing items for more detailed error
|
|
||||||
if missing_chunks_count > 0 || missing_metas_count > 0 {
|
|
||||||
error_msg.push_str(" (run with chunk/metadata verification to see details)");
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(Error::new(ErrorKind::Other, error_msg))
|
Err(Error::new(ErrorKind::Other, error_msg))
|
||||||
}
|
}
|
||||||
_ => Err(Error::new(ErrorKind::InvalidData, "Expected SnapshotOk or SnapshotFail")),
|
_ => Err(Error::new(ErrorKind::InvalidData, "Expected SnapshotOk or SnapshotFail")),
|
||||||
@@ -635,40 +837,46 @@ fn blake3_hash(data: &[u8]) -> [u8; 32] {
|
|||||||
blake3::hash(data).into()
|
blake3::hash(data).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate some mock data for testing
|
fn main() -> Result<()> {
|
||||||
fn generate_mock_data() -> Vec<(Vec<u8>, [u8; 32])> {
|
println!("🚀 Arkendro Sync Client - High Performance Recursive Upload");
|
||||||
let mut data_chunks = Vec::new();
|
println!("==========================================================");
|
||||||
|
|
||||||
// Some test data chunks
|
// Check for root directory argument
|
||||||
let chunks = [
|
let root_dir = std::env::args().nth(1).unwrap_or_else(|| {
|
||||||
b"Hello, Arkendro sync server! This is test chunk data.".to_vec(),
|
println!("Usage: {} <root_directory>", std::env::args().next().unwrap());
|
||||||
b"Another test chunk with different content for variety.".to_vec(),
|
println!("Example: {} ./test_data", std::env::args().next().unwrap());
|
||||||
b"Binary data test: \x00\x01\x02\x03\xFF\xFE\xFD\xFC".to_vec(),
|
println!("Using default: ./root");
|
||||||
];
|
"./root".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
for chunk in chunks {
|
println!("📁 Root directory: {}", root_dir);
|
||||||
let hash = blake3_hash(&chunk);
|
|
||||||
data_chunks.push((chunk, hash));
|
// Create root directory if it doesn't exist (for testing)
|
||||||
|
if !Path::new(&root_dir).exists() {
|
||||||
|
println!("Creating test directory structure at {}", root_dir);
|
||||||
|
create_test_directory_structure(&root_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
data_chunks
|
// Scan the filesystem
|
||||||
}
|
let mut scanner = FileSystemScanner::new(&root_dir)?;
|
||||||
|
scanner.scan()?;
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
if scanner.file_metadata.is_empty() {
|
||||||
println!("🚀 Arkendro Sync Client Extended Test");
|
println!("⚠ No files found in directory. Nothing to upload.");
|
||||||
println!("====================================\n");
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to server
|
// Connect to server
|
||||||
let mut client = SyncClient::connect("127.0.0.1:8380")?;
|
let mut client = SyncClient::connect("127.0.0.1:8380")?;
|
||||||
println!("Connected to sync server\n");
|
println!("Connected to sync server");
|
||||||
|
|
||||||
// Test protocol flow
|
// Test protocol flow
|
||||||
client.hello()?;
|
client.hello()?;
|
||||||
|
|
||||||
// Try to authenticate with hardcoded machine ID (you'll need to create a machine first via the web interface)
|
// Try to authenticate with hardcoded machine ID
|
||||||
let machine_id = 1; // Hardcoded machine ID for testing
|
let machine_id = 1; // Hardcoded machine ID for testing
|
||||||
match client.authenticate("admin", "password123", machine_id) {
|
match client.authenticate("admin", "password123", machine_id) {
|
||||||
Ok(()) => println!("Authentication successful!\n"),
|
Ok(()) => println!("Authentication successful!"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Authentication failed: {}", e);
|
println!("Authentication failed: {}", e);
|
||||||
println!("Make sure you have:");
|
println!("Make sure you have:");
|
||||||
@@ -679,145 +887,94 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("📁 Creating test filesystem hierarchy...\n");
|
println!("🔗 Uploading file chunks...");
|
||||||
|
|
||||||
// Step 1: Create test file data chunks
|
|
||||||
let file1_data = b"Hello, this is the content of file1.txt in our test filesystem!";
|
|
||||||
let file2_data = b"This is file2.log with some different content for testing purposes.";
|
|
||||||
let file3_data = b"Binary data file: \x00\x01\x02\x03\xFF\xFE\xFD\xFC and some text after.";
|
|
||||||
|
|
||||||
let file1_hash = blake3_hash(file1_data);
|
|
||||||
let file2_hash = blake3_hash(file2_data);
|
|
||||||
let file3_hash = blake3_hash(file3_data);
|
|
||||||
|
|
||||||
// Upload chunks if needed
|
// Upload chunks if needed
|
||||||
println!("🔗 Uploading file chunks...");
|
let chunk_hashes = scanner.get_all_chunk_hashes();
|
||||||
let chunk_hashes = vec![file1_hash, file2_hash, file3_hash];
|
|
||||||
let missing_chunks = client.check_chunks(&chunk_hashes)?;
|
let missing_chunks = client.check_chunks(&chunk_hashes)?;
|
||||||
|
|
||||||
if !missing_chunks.is_empty() {
|
if !missing_chunks.is_empty() {
|
||||||
for &missing_hash in &missing_chunks {
|
println!(" Uploading {} missing chunks...", missing_chunks.len());
|
||||||
if missing_hash == file1_hash {
|
|
||||||
client.send_chunk(&file1_hash, file1_data)?;
|
for (i, &missing_hash) in missing_chunks.iter().enumerate() {
|
||||||
} else if missing_hash == file2_hash {
|
// Find the chunk data for this hash
|
||||||
client.send_chunk(&file2_hash, file2_data)?;
|
let mut found = false;
|
||||||
} else if missing_hash == file3_hash {
|
|
||||||
client.send_chunk(&file3_hash, file3_data)?;
|
// Check chunk references (streaming chunks)
|
||||||
|
if let Some(chunk_ref) = scanner.find_chunk_ref(&missing_hash) {
|
||||||
|
match scanner.read_chunk_data(chunk_ref) {
|
||||||
|
Ok(chunk_data) => {
|
||||||
|
println!(" [{}/{}] Uploading chunk: {} bytes from {}",
|
||||||
|
i + 1, missing_chunks.len(), chunk_data.len(),
|
||||||
|
chunk_ref.file_path.file_name().unwrap_or_default().to_string_lossy());
|
||||||
|
client.send_chunk(&missing_hash, &chunk_data)?;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!(" Error reading chunk: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return Err(Error::new(ErrorKind::Other,
|
||||||
|
format!("Could not find chunk data for hash: {}", hex::encode(missing_hash))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("✓ All chunks already exist on server");
|
println!("✓ All chunks already exist on server");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create file metadata objects
|
println!("📄 Uploading file metadata...");
|
||||||
println!("\n📄 Creating file metadata objects...");
|
|
||||||
let file1_obj = FileObj::new(file1_data.len() as u64, vec![file1_hash]);
|
|
||||||
let file2_obj = FileObj::new(file2_data.len() as u64, vec![file2_hash]);
|
|
||||||
let file3_obj = FileObj::new(file3_data.len() as u64, vec![file3_hash]);
|
|
||||||
|
|
||||||
let file1_meta_data = file1_obj.serialize();
|
|
||||||
let file2_meta_data = file2_obj.serialize();
|
|
||||||
let file3_meta_data = file3_obj.serialize();
|
|
||||||
|
|
||||||
let file1_meta_hash = blake3_hash(&file1_meta_data);
|
|
||||||
let file2_meta_hash = blake3_hash(&file2_meta_data);
|
|
||||||
let file3_meta_hash = blake3_hash(&file3_meta_data);
|
|
||||||
|
|
||||||
// Upload file metadata
|
// Upload file metadata
|
||||||
client.send_metadata(MetaType::File, &file1_meta_hash, &file1_meta_data)?;
|
for (_path, (meta_hash, file_obj)) in &scanner.file_metadata {
|
||||||
client.send_metadata(MetaType::File, &file2_meta_hash, &file2_meta_data)?;
|
let file_data = file_obj.serialize();
|
||||||
client.send_metadata(MetaType::File, &file3_meta_hash, &file3_meta_data)?;
|
client.send_metadata(MetaType::File, meta_hash, &file_data)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: Create directory structures
|
println!("📁 Uploading directory metadata...");
|
||||||
println!("\n📁 Creating directory structures...");
|
|
||||||
|
|
||||||
// Create /logs subdirectory with file2
|
// Upload directory metadata (in reverse order to upload children before parents)
|
||||||
let logs_dir_entries = vec![
|
let mut dir_entries: Vec<_> = scanner.dir_metadata.iter().collect();
|
||||||
DirEntry {
|
dir_entries.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count()));
|
||||||
entry_type: EntryType::File,
|
|
||||||
name: "app.log".to_string(),
|
|
||||||
target_meta_hash: file2_meta_hash,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let logs_dir_obj = DirObj::new(logs_dir_entries);
|
|
||||||
let logs_dir_data = logs_dir_obj.serialize();
|
|
||||||
let logs_dir_hash = blake3_hash(&logs_dir_data);
|
|
||||||
client.send_metadata(MetaType::Dir, &logs_dir_hash, &logs_dir_data)?;
|
|
||||||
|
|
||||||
// Create /data subdirectory with file3
|
for (_path, (meta_hash, dir_obj)) in dir_entries {
|
||||||
let data_dir_entries = vec![
|
let dir_data = dir_obj.serialize();
|
||||||
DirEntry {
|
client.send_metadata(MetaType::Dir, meta_hash, &dir_data)?;
|
||||||
entry_type: EntryType::File,
|
}
|
||||||
name: "binary.dat".to_string(),
|
|
||||||
target_meta_hash: file3_meta_hash,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let data_dir_obj = DirObj::new(data_dir_entries);
|
|
||||||
let data_dir_data = data_dir_obj.serialize();
|
|
||||||
let data_dir_hash = blake3_hash(&data_dir_data);
|
|
||||||
client.send_metadata(MetaType::Dir, &data_dir_hash, &data_dir_data)?;
|
|
||||||
|
|
||||||
// Create root directory with file1 and subdirectories
|
// Get root directory hash
|
||||||
let root_dir_entries = vec![
|
let root_dir_hash = scanner.get_root_dir_hash()
|
||||||
DirEntry {
|
.ok_or_else(|| Error::new(ErrorKind::Other, "No root directory found"))?;
|
||||||
entry_type: EntryType::File,
|
|
||||||
name: "readme.txt".to_string(),
|
|
||||||
target_meta_hash: file1_meta_hash,
|
|
||||||
},
|
|
||||||
DirEntry {
|
|
||||||
entry_type: EntryType::Dir,
|
|
||||||
name: "logs".to_string(),
|
|
||||||
target_meta_hash: logs_dir_hash,
|
|
||||||
},
|
|
||||||
DirEntry {
|
|
||||||
entry_type: EntryType::Dir,
|
|
||||||
name: "data".to_string(),
|
|
||||||
target_meta_hash: data_dir_hash,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let root_dir_obj = DirObj::new(root_dir_entries);
|
|
||||||
let root_dir_data = root_dir_obj.serialize();
|
|
||||||
let root_dir_hash = blake3_hash(&root_dir_data);
|
|
||||||
client.send_metadata(MetaType::Dir, &root_dir_hash, &root_dir_data)?;
|
|
||||||
|
|
||||||
// Step 4: Create partition
|
println!("💽 Creating partition metadata...");
|
||||||
println!("\n💽 Creating partition metadata...");
|
let partition_obj = PartitionObj::new("uploaded-data".to_string(), root_dir_hash);
|
||||||
let partition_obj = PartitionObj::new("test-partition".to_string(), root_dir_hash);
|
|
||||||
let partition_data = partition_obj.serialize();
|
let partition_data = partition_obj.serialize();
|
||||||
let partition_hash = blake3_hash(&partition_data);
|
let partition_hash = blake3_hash(&partition_data);
|
||||||
client.send_metadata(MetaType::Partition, &partition_hash, &partition_data)?;
|
client.send_metadata(MetaType::Partition, &partition_hash, &partition_data)?;
|
||||||
|
|
||||||
// Step 5: Create disk
|
println!("🖥️ Creating disk metadata...");
|
||||||
println!("\n🖥️ Creating disk metadata...");
|
let disk_obj = DiskObj::new("uploaded-disk".to_string(), vec![partition_hash]);
|
||||||
let disk_obj = DiskObj::new("test-disk-001".to_string(), vec![partition_hash]);
|
|
||||||
let disk_data = disk_obj.serialize();
|
let disk_data = disk_obj.serialize();
|
||||||
let disk_hash = blake3_hash(&disk_data);
|
let disk_hash = blake3_hash(&disk_data);
|
||||||
client.send_metadata(MetaType::Disk, &disk_hash, &disk_data)?;
|
client.send_metadata(MetaType::Disk, &disk_hash, &disk_data)?;
|
||||||
|
|
||||||
// Step 6: Create snapshot
|
println!("📸 Creating snapshot...");
|
||||||
println!("\n📸 Creating snapshot...");
|
|
||||||
let snapshot_obj = SnapshotObj::new(vec![disk_hash]);
|
let snapshot_obj = SnapshotObj::new(vec![disk_hash]);
|
||||||
let snapshot_data = snapshot_obj.serialize();
|
let snapshot_data = snapshot_obj.serialize();
|
||||||
let snapshot_hash = blake3_hash(&snapshot_data);
|
let snapshot_hash = blake3_hash(&snapshot_data);
|
||||||
|
|
||||||
// Upload snapshot using SendSnapshot command (not SendMeta)
|
|
||||||
client.send_snapshot(&snapshot_hash, &snapshot_data)?;
|
client.send_snapshot(&snapshot_hash, &snapshot_data)?;
|
||||||
|
|
||||||
// Step 7: Verify everything is stored
|
println!("🔍 Verifying stored objects...");
|
||||||
println!("\n🔍 Verifying stored objects...");
|
|
||||||
|
|
||||||
// Check all metadata objects
|
// Check all metadata objects
|
||||||
let all_metadata = vec![
|
let mut all_metadata = scanner.get_all_metadata_items();
|
||||||
(MetaType::File, file1_meta_hash),
|
all_metadata.push((MetaType::Partition, partition_hash));
|
||||||
(MetaType::File, file2_meta_hash),
|
all_metadata.push((MetaType::Disk, disk_hash));
|
||||||
(MetaType::File, file3_meta_hash),
|
all_metadata.push((MetaType::Snapshot, snapshot_hash));
|
||||||
(MetaType::Dir, logs_dir_hash),
|
|
||||||
(MetaType::Dir, data_dir_hash),
|
|
||||||
(MetaType::Dir, root_dir_hash),
|
|
||||||
(MetaType::Partition, partition_hash),
|
|
||||||
(MetaType::Disk, disk_hash),
|
|
||||||
(MetaType::Snapshot, snapshot_hash),
|
|
||||||
];
|
|
||||||
|
|
||||||
let missing_metadata = client.check_metadata(&all_metadata)?;
|
let missing_metadata = client.check_metadata(&all_metadata)?;
|
||||||
if missing_metadata.is_empty() {
|
if missing_metadata.is_empty() {
|
||||||
@@ -830,27 +987,65 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check all chunks
|
// Check all chunks
|
||||||
let all_chunks = vec![file1_hash, file2_hash, file3_hash];
|
let missing_chunks_final = client.check_chunks(&chunk_hashes)?;
|
||||||
let missing_chunks_final = client.check_chunks(&all_chunks)?;
|
|
||||||
if missing_chunks_final.is_empty() {
|
if missing_chunks_final.is_empty() {
|
||||||
println!("✓ All data chunks verified as stored");
|
println!("✓ All data chunks verified as stored");
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Warning: {} chunks still missing", missing_chunks_final.len());
|
println!("⚠ Warning: {} chunks still missing", missing_chunks_final.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("\n🎉 Complete filesystem hierarchy created!");
|
println!("🎉 Complete filesystem hierarchy uploaded!");
|
||||||
println!("📊 Summary:");
|
println!("📊 Summary:");
|
||||||
println!(" • 3 files (readme.txt, logs/app.log, data/binary.dat)");
|
|
||||||
println!(" • 3 directories (/, /logs, /data)");
|
// Count total chunks
|
||||||
println!(" • 1 partition (test-partition)");
|
let total_chunks = scanner.chunk_refs.len();
|
||||||
println!(" • 1 disk (test-disk-001)");
|
|
||||||
|
println!(" • {} files uploaded", scanner.file_metadata.len());
|
||||||
|
println!(" • {} data chunks uploaded", total_chunks);
|
||||||
|
println!(" • {} directories processed", scanner.dir_metadata.len());
|
||||||
|
println!(" • 1 partition (uploaded-data)");
|
||||||
|
println!(" • 1 disk (uploaded-disk)");
|
||||||
println!(" • 1 snapshot");
|
println!(" • 1 snapshot");
|
||||||
println!(" • Snapshot hash: {}", hex::encode(snapshot_hash));
|
println!(" • Snapshot hash: {}", hex::encode(snapshot_hash));
|
||||||
|
|
||||||
println!("\n✅ All tests completed successfully!");
|
println!("✅ Upload completed successfully!");
|
||||||
|
|
||||||
// Close connection
|
// Close connection
|
||||||
client.close()?;
|
client.close()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a test directory structure for demonstration purposes
|
||||||
|
fn create_test_directory_structure(root_dir: &str) -> Result<()> {
|
||||||
|
let root_path = Path::new(root_dir);
|
||||||
|
fs::create_dir_all(root_path)?;
|
||||||
|
|
||||||
|
// Create some test files
|
||||||
|
fs::write(root_path.join("readme.txt"),
|
||||||
|
"Welcome to the Arkendro sync test!\n\nThis directory contains test files for upload.")?;
|
||||||
|
|
||||||
|
fs::write(root_path.join("config.json"),
|
||||||
|
r#"{"name": "test", "version": "1.0", "settings": {"debug": true}}"#)?;
|
||||||
|
|
||||||
|
// Create subdirectories
|
||||||
|
let logs_dir = root_path.join("logs");
|
||||||
|
fs::create_dir_all(&logs_dir)?;
|
||||||
|
fs::write(logs_dir.join("app.log"),
|
||||||
|
"2024-01-01 10:00:00 INFO Application started\n2024-01-01 10:01:00 INFO Processing data\n")?;
|
||||||
|
fs::write(logs_dir.join("error.log"),
|
||||||
|
"2024-01-01 10:05:00 ERROR Connection timeout\n")?;
|
||||||
|
|
||||||
|
let data_dir = root_path.join("data");
|
||||||
|
fs::create_dir_all(&data_dir)?;
|
||||||
|
fs::write(data_dir.join("sample.csv"),
|
||||||
|
"id,name,value\n1,test,100\n2,demo,200\n")?;
|
||||||
|
|
||||||
|
let nested_dir = data_dir.join("nested");
|
||||||
|
fs::create_dir_all(&nested_dir)?;
|
||||||
|
fs::write(nested_dir.join("deep_file.txt"),
|
||||||
|
"This file is nested deeper in the directory structure.")?;
|
||||||
|
|
||||||
|
println!("✓ Created test directory structure with sample files");
|
||||||
|
Ok(())
|
||||||
|
}
|
@@ -5,8 +5,7 @@ import "@/common/styles/main.sass";
|
|||||||
import Root from "@/common/layouts/Root.jsx";
|
import Root from "@/common/layouts/Root.jsx";
|
||||||
import UserManagement from "@/pages/UserManagement";
|
import UserManagement from "@/pages/UserManagement";
|
||||||
import SystemSettings from "@/pages/SystemSettings";
|
import SystemSettings from "@/pages/SystemSettings";
|
||||||
import Machines from "@/pages/Machines";
|
import Machines, {MachineDetails} from "@/pages/Machines";
|
||||||
import MachineDetails from "@/pages/MachineDetails";
|
|
||||||
import "@fontsource/plus-jakarta-sans/300.css";
|
import "@fontsource/plus-jakarta-sans/300.css";
|
||||||
import "@fontsource/plus-jakarta-sans/400.css";
|
import "@fontsource/plus-jakarta-sans/400.css";
|
||||||
import "@fontsource/plus-jakarta-sans/600.css";
|
import "@fontsource/plus-jakarta-sans/600.css";
|
||||||
|
@@ -47,3 +47,7 @@
|
|||||||
&--user
|
&--user
|
||||||
background: var(--bg-elev)
|
background: var(--bg-elev)
|
||||||
color: var(--text-dim)
|
color: var(--text-dim)
|
||||||
|
|
||||||
|
&--subtle
|
||||||
|
background: var(--bg-elev)
|
||||||
|
color: var(--text-dim)
|
@@ -6,10 +6,19 @@ export const EmptyState = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
action,
|
action,
|
||||||
|
size = 'md',
|
||||||
|
variant = 'default',
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
|
const emptyStateClasses = [
|
||||||
|
'empty-state',
|
||||||
|
`empty-state--${size}`,
|
||||||
|
`empty-state--${variant}`,
|
||||||
|
className
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`empty-state ${className}`}>
|
<div className={emptyStateClasses}>
|
||||||
{icon && <div className="empty-state-icon">{icon}</div>}
|
{icon && <div className="empty-state-icon">{icon}</div>}
|
||||||
{title && <h3 className="empty-state-title">{title}</h3>}
|
{title && <h3 className="empty-state-title">{title}</h3>}
|
||||||
{description && <p className="empty-state-description">{description}</p>}
|
{description && <p className="empty-state-description">{description}</p>}
|
||||||
|
@@ -7,6 +7,57 @@
|
|||||||
align-items: center
|
align-items: center
|
||||||
gap: 1rem
|
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
|
.empty-state-icon
|
||||||
color: var(--text-dim)
|
color: var(--text-dim)
|
||||||
display: flex
|
display: flex
|
||||||
|
514
webui/src/common/components/FileBrowser/FileBrowser.jsx
Normal file
514
webui/src/common/components/FileBrowser/FileBrowser.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
356
webui/src/common/components/FileBrowser/file-browser.sass
Normal file
356
webui/src/common/components/FileBrowser/file-browser.sass
Normal 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
|
1
webui/src/common/components/FileBrowser/index.js
Normal file
1
webui/src/common/components/FileBrowser/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {FileBrowser} from './FileBrowser.jsx';
|
@@ -1,416 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { getRequest } from '@/common/utils/RequestUtil.js';
|
|
||||||
import { useToast } from '@/common/contexts/ToastContext.jsx';
|
|
||||||
import Card, { CardHeader, CardBody } from '@/common/components/Card';
|
|
||||||
import Grid from '@/common/components/Grid';
|
|
||||||
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 Badge from '@/common/components/Badge';
|
|
||||||
import Button from '@/common/components/Button';
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Camera,
|
|
||||||
HardDrive,
|
|
||||||
Folder,
|
|
||||||
Calendar,
|
|
||||||
Hash,
|
|
||||||
Database,
|
|
||||||
Devices,
|
|
||||||
Eye,
|
|
||||||
ArrowCircleLeft
|
|
||||||
} from '@phosphor-icons/react';
|
|
||||||
import './styles.sass';
|
|
||||||
|
|
||||||
export const MachineDetails = () => {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const toast = useToast();
|
|
||||||
const [machine, setMachine] = useState(null);
|
|
||||||
const [snapshots, setSnapshots] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
|
||||||
const [snapshotDetails, setSnapshotDetails] = useState(null);
|
|
||||||
const [loadingDetails, setLoadingDetails] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
fetchMachineData();
|
|
||||||
}
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const fetchMachineData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// Fetch machine info and snapshots in parallel
|
|
||||||
const [machineResponse, snapshotsResponse] = await Promise.all([
|
|
||||||
getRequest(`machines/${id}`),
|
|
||||||
getRequest(`machines/${id}/snapshots`)
|
|
||||||
]);
|
|
||||||
|
|
||||||
setMachine(machineResponse);
|
|
||||||
setSnapshots(snapshotsResponse);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch machine data:', error);
|
|
||||||
toast.error('Failed to load machine details');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSnapshotDetails = async (snapshotId) => {
|
|
||||||
try {
|
|
||||||
setLoadingDetails(true);
|
|
||||||
const details = await getRequest(`machines/${id}/snapshots/${snapshotId}`);
|
|
||||||
setSnapshotDetails(details);
|
|
||||||
setSelectedSnapshot(snapshotId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch snapshot details:', error);
|
|
||||||
toast.error('Failed to load snapshot details');
|
|
||||||
} finally {
|
|
||||||
setLoadingDetails(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const backToSnapshots = () => {
|
|
||||||
setSelectedSnapshot(null);
|
|
||||||
setSnapshotDetails(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatBytes = (bytes) => {
|
|
||||||
if (!bytes) 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 formatDate = (dateString) => {
|
|
||||||
if (!dateString || dateString === 'Unknown') return 'Unknown';
|
|
||||||
try {
|
|
||||||
// Handle both "2025-09-09 20:19:48" and "2025-09-09 20:19:48 UTC" formats
|
|
||||||
const cleanDate = dateString.replace(' UTC', '');
|
|
||||||
const date = new Date(cleanDate);
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return dateString; // Return original if parsing fails
|
|
||||||
}
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatLBA = (lba) => {
|
|
||||||
if (!lba && lba !== 0) return '0';
|
|
||||||
return lba.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFsTypeColor = (fsType) => {
|
|
||||||
switch (fsType?.toLowerCase()) {
|
|
||||||
case 'ext':
|
|
||||||
case 'ext4':
|
|
||||||
case 'ext3':
|
|
||||||
case 'ext2':
|
|
||||||
return 'success';
|
|
||||||
case 'ntfs':
|
|
||||||
return 'info';
|
|
||||||
case 'fat32':
|
|
||||||
case 'fat':
|
|
||||||
return 'warning';
|
|
||||||
case 'xfs':
|
|
||||||
return 'info';
|
|
||||||
case 'btrfs':
|
|
||||||
return 'success';
|
|
||||||
default:
|
|
||||||
return 'secondary';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const truncateHash = (hash, length = 16) => {
|
|
||||||
if (!hash) return 'Unknown';
|
|
||||||
return hash.length > length ? `${hash.substring(0, length)}...` : hash;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="machine-details">
|
|
||||||
<PageHeader
|
|
||||||
title="Loading..."
|
|
||||||
subtitle="Fetching machine details"
|
|
||||||
actions={
|
|
||||||
<Button variant="secondary" onClick={() => navigate('/machines')}>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back to Machines
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!machine) {
|
|
||||||
return (
|
|
||||||
<div className="machine-details">
|
|
||||||
<PageHeader
|
|
||||||
title="Machine Not Found"
|
|
||||||
subtitle="The requested machine could not be found"
|
|
||||||
actions={
|
|
||||||
<Button variant="secondary" onClick={() => navigate('/machines')}>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back to Machines
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<EmptyState
|
|
||||||
icon={<Devices size={48} weight="duotone" />}
|
|
||||||
title="Machine Not Found"
|
|
||||||
subtitle="This machine may have been deleted or you don't have access to it."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="machine-details">
|
|
||||||
<PageHeader
|
|
||||||
title={machine.name}
|
|
||||||
subtitle={
|
|
||||||
selectedSnapshot
|
|
||||||
? `Snapshot Details`
|
|
||||||
: `Machine ID: ${machine.machine_id}`
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
selectedSnapshot ? (
|
|
||||||
<Button variant="secondary" onClick={backToSnapshots}>
|
|
||||||
<ArrowCircleLeft size={16} />
|
|
||||||
Back to Snapshots
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="secondary" onClick={() => navigate('/machines')}>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back to Machines
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid columns={1} gap="large">
|
|
||||||
{/* Machine Information - Only show when not viewing snapshot details */}
|
|
||||||
{!selectedSnapshot && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3><Devices size={20} /> Machine Information</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<DetailList>
|
|
||||||
<DetailItem label="Name" value={machine.name} />
|
|
||||||
<DetailItem label="Machine ID" value={machine.machine_id} />
|
|
||||||
<DetailItem label="Created" value={formatDate(machine.created_at)} />
|
|
||||||
<DetailItem label="Status" value={
|
|
||||||
<Badge variant="success">Active</Badge>
|
|
||||||
} />
|
|
||||||
</DetailList>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snapshots List or Details */}
|
|
||||||
{!selectedSnapshot ? (
|
|
||||||
/* Snapshots List */
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3><Camera size={20} /> Snapshots ({snapshots.length})</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
{snapshots.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Camera size={48} weight="duotone" />}
|
|
||||||
title="No Snapshots"
|
|
||||||
subtitle="This machine hasn't created any snapshots yet."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Grid columns={1} gap="medium">
|
|
||||||
{snapshots.map((snapshot) => (
|
|
||||||
<Card key={snapshot.id} className="snapshot-summary-card">
|
|
||||||
<CardBody>
|
|
||||||
<div className="snapshot-summary">
|
|
||||||
<div className="snapshot-info">
|
|
||||||
<div className="snapshot-title">
|
|
||||||
<Camera size={18} />
|
|
||||||
<h4>Snapshot</h4>
|
|
||||||
</div>
|
|
||||||
<DetailList>
|
|
||||||
<DetailItem
|
|
||||||
label="Created"
|
|
||||||
value={
|
|
||||||
<div className="snapshot-date">
|
|
||||||
<Calendar size={14} />
|
|
||||||
{formatDate(snapshot.created_at)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
label="Snapshot ID"
|
|
||||||
value={
|
|
||||||
<div className="snapshot-hash">
|
|
||||||
<Hash size={14} />
|
|
||||||
<code>{truncateHash(snapshot.id, 24)}</code>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
label="Hash"
|
|
||||||
value={
|
|
||||||
<div className="snapshot-hash">
|
|
||||||
<Hash size={14} />
|
|
||||||
<code>{truncateHash(snapshot.snapshot_hash, 24)}</code>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DetailList>
|
|
||||||
</div>
|
|
||||||
<div className="snapshot-actions">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => fetchSnapshotDetails(snapshot.id)}
|
|
||||||
>
|
|
||||||
<Eye size={16} />
|
|
||||||
View Details
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
/* Snapshot Details */
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3><Camera size={20} /> Snapshot {selectedSnapshot} Details</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
{loadingDetails ? (
|
|
||||||
<LoadingSpinner />
|
|
||||||
) : snapshotDetails ? (
|
|
||||||
<div className="snapshot-details">
|
|
||||||
{/* Snapshot Metadata */}
|
|
||||||
<Card className="snapshot-metadata">
|
|
||||||
<CardHeader>
|
|
||||||
<h4><Database size={18} /> Metadata</h4>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<DetailList>
|
|
||||||
<DetailItem
|
|
||||||
label="Created"
|
|
||||||
value={
|
|
||||||
<div className="snapshot-date">
|
|
||||||
<Calendar size={14} />
|
|
||||||
{formatDate(snapshotDetails.created_at)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
label="Hash"
|
|
||||||
value={
|
|
||||||
<div className="snapshot-hash">
|
|
||||||
<Hash size={14} />
|
|
||||||
<code>{snapshotDetails.snapshot_hash}</code>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
label="Disks"
|
|
||||||
value={`${snapshotDetails.disks.length} disk${snapshotDetails.disks.length !== 1 ? 's' : ''}`}
|
|
||||||
/>
|
|
||||||
</DetailList>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Disks */}
|
|
||||||
<div className="disks-section">
|
|
||||||
<h4><HardDrive size={18} /> Disks ({snapshotDetails.disks.length})</h4>
|
|
||||||
<Grid columns={1} gap="medium">
|
|
||||||
{snapshotDetails.disks.map((disk, diskIndex) => (
|
|
||||||
<Card key={diskIndex} className="disk-card">
|
|
||||||
<CardHeader>
|
|
||||||
<h5><HardDrive size={16} /> Disk {diskIndex + 1}</h5>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<DetailList>
|
|
||||||
<DetailItem label="Serial" value={disk.serial || 'Unknown'} />
|
|
||||||
<DetailItem label="Size" value={formatBytes(disk.size_bytes)} />
|
|
||||||
<DetailItem
|
|
||||||
label="Partitions"
|
|
||||||
value={`${disk.partitions.length} partition${disk.partitions.length !== 1 ? 's' : ''}`}
|
|
||||||
/>
|
|
||||||
</DetailList>
|
|
||||||
|
|
||||||
{/* Partitions */}
|
|
||||||
{disk.partitions.length > 0 && (
|
|
||||||
<div className="partitions-section">
|
|
||||||
<h6><Folder size={14} /> Partitions</h6>
|
|
||||||
<Grid columns="auto-fit" gap="1rem" minWidth="280px">
|
|
||||||
{disk.partitions.map((partition, partIndex) => (
|
|
||||||
<Card key={partIndex} className="partition-card">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="partition-header">
|
|
||||||
<span>Partition {partIndex + 1}</span>
|
|
||||||
<Badge variant={getFsTypeColor(partition.fs_type)}>
|
|
||||||
{partition.fs_type.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<DetailList>
|
|
||||||
<DetailItem label="Size" value={formatBytes(partition.size_bytes)} />
|
|
||||||
<DetailItem label="Start LBA" value={formatLBA(partition.start_lba)} />
|
|
||||||
<DetailItem label="End LBA" value={formatLBA(partition.end_lba)} />
|
|
||||||
<DetailItem
|
|
||||||
label="Sectors"
|
|
||||||
value={formatLBA(partition.end_lba - partition.start_lba)}
|
|
||||||
/>
|
|
||||||
</DetailList>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Camera size={48} weight="duotone" />}
|
|
||||||
title="No Details Available"
|
|
||||||
subtitle="Unable to load snapshot details."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MachineDetails;
|
|
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './MachineDetails.jsx';
|
|
||||||
export { MachineDetails } from './MachineDetails.jsx';
|
|
@@ -1,232 +0,0 @@
|
|||||||
// Machine Details Page Styles
|
|
||||||
.machine-details
|
|
||||||
// Snapshot Summary Cards (list view)
|
|
||||||
.snapshot-summary-card
|
|
||||||
transition: all 0.2s ease
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
border-color: var(--border-strong)
|
|
||||||
box-shadow: 0 4px 12px rgba(31, 36, 41, 0.1)
|
|
||||||
transform: translateY(-1px)
|
|
||||||
|
|
||||||
.snapshot-summary
|
|
||||||
display: flex
|
|
||||||
justify-content: space-between
|
|
||||||
align-items: flex-start
|
|
||||||
gap: 1.5rem
|
|
||||||
|
|
||||||
.snapshot-info
|
|
||||||
flex: 1
|
|
||||||
|
|
||||||
.snapshot-title
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 0.75rem
|
|
||||||
margin-bottom: 1rem
|
|
||||||
|
|
||||||
h4
|
|
||||||
font-size: 1.125rem
|
|
||||||
font-weight: 600
|
|
||||||
color: var(--text)
|
|
||||||
margin: 0
|
|
||||||
|
|
||||||
.snapshot-date
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 0.5rem
|
|
||||||
font-size: 0.875rem
|
|
||||||
color: var(--text-dim)
|
|
||||||
|
|
||||||
.snapshot-hash
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 0.5rem
|
|
||||||
font-size: 0.875rem
|
|
||||||
|
|
||||||
code
|
|
||||||
background: var(--bg-elev)
|
|
||||||
padding: 0.25rem 0.5rem
|
|
||||||
border-radius: var(--radius-sm)
|
|
||||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace
|
|
||||||
color: var(--text-dim)
|
|
||||||
font-size: 0.8rem
|
|
||||||
|
|
||||||
.snapshot-actions
|
|
||||||
display: flex
|
|
||||||
flex-direction: column
|
|
||||||
gap: 0.5rem
|
|
||||||
|
|
||||||
// Snapshot Detail View
|
|
||||||
.snapshot-details
|
|
||||||
.snapshot-metadata
|
|
||||||
margin-bottom: 2rem
|
|
||||||
background: linear-gradient(135deg, var(--bg-alt) 0%, var(--bg-elev) 100%)
|
|
||||||
border: 1px solid var(--border)
|
|
||||||
|
|
||||||
.disks-section
|
|
||||||
h4
|
|
||||||
font-size: 1.25rem
|
|
||||||
font-weight: 600
|
|
||||||
color: var(--text)
|
|
||||||
margin-bottom: 1.5rem
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 0.75rem
|
|
||||||
padding-bottom: 0.5rem
|
|
||||||
border-bottom: 2px solid var(--border)
|
|
||||||
|
|
||||||
.disk-card
|
|
||||||
border: 1px solid var(--border)
|
|
||||||
background: linear-gradient(135deg, var(--bg-alt) 0%, var(--bg-elev) 100%)
|
|
||||||
transition: all 0.2s ease
|
|
||||||
position: relative
|
|
||||||
overflow: hidden
|
|
||||||
|
|
||||||
&::before
|
|
||||||
content: ''
|
|
||||||
position: absolute
|
|
||||||
top: 0
|
|
||||||
left: 0
|
|
||||||
right: 0
|
|
||||||
height: 3px
|
|
||||||
background: linear-gradient(90deg, var(--accent) 0%, var(--success) 100%)
|
|
||||||
opacity: 0
|
|
||||||
transition: opacity 0.2s ease
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
border-color: var(--border-strong)
|
|
||||||
box-shadow: 0 6px 20px rgba(31, 36, 41, 0.15)
|
|
||||||
transform: translateY(-2px)
|
|
||||||
|
|
||||||
&::before
|
|
||||||
opacity: 1
|
|
||||||
|
|
||||||
.partitions-section
|
|
||||||
margin-top: 2rem
|
|
||||||
|
|
||||||
h6
|
|
||||||
font-size: 1rem
|
|
||||||
font-weight: 600
|
|
||||||
color: var(--text)
|
|
||||||
margin-bottom: 1rem
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 0.5rem
|
|
||||||
padding: 0.5rem 0
|
|
||||||
border-bottom: 1px solid var(--border)
|
|
||||||
|
|
||||||
.partition-card
|
|
||||||
border: 1px solid var(--border)
|
|
||||||
background: var(--bg-elev)
|
|
||||||
transition: all 0.2s ease
|
|
||||||
position: relative
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
border-color: var(--border-strong)
|
|
||||||
box-shadow: 0 3px 10px rgba(31, 36, 41, 0.1)
|
|
||||||
transform: translateY(-1px)
|
|
||||||
|
|
||||||
.partition-header
|
|
||||||
display: flex
|
|
||||||
justify-content: space-between
|
|
||||||
align-items: center
|
|
||||||
|
|
||||||
span
|
|
||||||
font-size: 0.875rem
|
|
||||||
font-weight: 600
|
|
||||||
color: var(--text)
|
|
||||||
|
|
||||||
// Enhanced visual feedback
|
|
||||||
.snapshot-date, .snapshot-hash
|
|
||||||
transition: color 0.2s ease
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
color: var(--text)
|
|
||||||
|
|
||||||
// Better spacing for detail items
|
|
||||||
.detail-list
|
|
||||||
.detail-item
|
|
||||||
padding: 0.75rem 0
|
|
||||||
border-bottom: 1px solid var(--border)
|
|
||||||
|
|
||||||
&:last-child
|
|
||||||
border-bottom: none
|
|
||||||
|
|
||||||
.detail-label
|
|
||||||
font-weight: 500
|
|
||||||
color: var(--text-dim)
|
|
||||||
font-size: 0.875rem
|
|
||||||
text-transform: uppercase
|
|
||||||
letter-spacing: 0.05em
|
|
||||||
|
|
||||||
.detail-value
|
|
||||||
font-weight: 500
|
|
||||||
color: var(--text)
|
|
||||||
|
|
||||||
code
|
|
||||||
background: var(--bg-elev)
|
|
||||||
padding: 0.25rem 0.5rem
|
|
||||||
border-radius: var(--radius-sm)
|
|
||||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace
|
|
||||||
font-size: 0.8rem
|
|
||||||
border: 1px solid var(--border)
|
|
||||||
|
|
||||||
// Loading and error states
|
|
||||||
.loading-section
|
|
||||||
text-align: center
|
|
||||||
padding: 3rem
|
|
||||||
|
|
||||||
.spinner
|
|
||||||
border: 3px solid var(--border)
|
|
||||||
border-top: 3px solid var(--accent)
|
|
||||||
border-radius: 50%
|
|
||||||
width: 40px
|
|
||||||
height: 40px
|
|
||||||
animation: spin 1s linear infinite
|
|
||||||
margin: 0 auto 1rem
|
|
||||||
|
|
||||||
@keyframes spin
|
|
||||||
0%
|
|
||||||
transform: rotate(0deg)
|
|
||||||
100%
|
|
||||||
transform: rotate(360deg)
|
|
||||||
|
|
||||||
// Responsive design
|
|
||||||
@media (max-width: 768px)
|
|
||||||
.snapshot-summary
|
|
||||||
flex-direction: column
|
|
||||||
gap: 1rem
|
|
||||||
|
|
||||||
.snapshot-actions
|
|
||||||
flex-direction: row
|
|
||||||
align-self: stretch
|
|
||||||
|
|
||||||
.disk-card .partitions-section h6
|
|
||||||
font-size: 0.875rem
|
|
||||||
|
|
||||||
.disks-section h4
|
|
||||||
font-size: 1.125rem
|
|
||||||
|
|
||||||
// Visual hierarchy improvements
|
|
||||||
.card
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15)
|
|
||||||
|
|
||||||
.badge
|
|
||||||
font-weight: 600
|
|
||||||
letter-spacing: 0.025em
|
|
||||||
|
|
||||||
&.variant-success
|
|
||||||
background: linear-gradient(135deg, var(--success) 0%, #22c55e 100%)
|
|
||||||
|
|
||||||
&.variant-info
|
|
||||||
background: linear-gradient(135deg, var(--info) 0%, #3b82f6 100%)
|
|
||||||
|
|
||||||
&.variant-warning
|
|
||||||
background: linear-gradient(135deg, var(--warning) 0%, #f59e0b 100%)
|
|
||||||
|
|
||||||
&.variant-secondary
|
|
||||||
background: linear-gradient(135deg, var(--text-dim) 0%, #6b7280 100%)
|
|
419
webui/src/pages/Machines/MachineDetails.jsx
Normal file
419
webui/src/pages/Machines/MachineDetails.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@@ -28,8 +28,8 @@ import './styles.sass';
|
|||||||
|
|
||||||
export const Machines = () => {
|
export const Machines = () => {
|
||||||
const {user: currentUser} = useContext(UserContext);
|
const {user: currentUser} = useContext(UserContext);
|
||||||
const toast = useToast();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
const [machines, setMachines] = useState([]);
|
const [machines, setMachines] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
@@ -181,14 +181,6 @@ export const Machines = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMachineClick = (machineId) => {
|
|
||||||
navigate(`/machines/${machineId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActionClick = (e) => {
|
|
||||||
e.stopPropagation(); // Prevent navigation when clicking action buttons
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e) => {
|
||||||
const {name, value} = e.target;
|
const {name, value} = e.target;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -204,6 +196,10 @@ export const Machines = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMachineClick = (machineId) => {
|
||||||
|
navigate(`/machines/${machineId}`);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="content">
|
<div className="content">
|
||||||
@@ -235,7 +231,6 @@ export const Machines = () => {
|
|||||||
hover
|
hover
|
||||||
className="machine-card"
|
className="machine-card"
|
||||||
onClick={() => handleMachineClick(machine.id)}
|
onClick={() => handleMachineClick(machine.id)}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="machine-card-header">
|
<div className="machine-card-header">
|
||||||
@@ -249,12 +244,15 @@ export const Machines = () => {
|
|||||||
<span className="uuid-text">{formatUuid(machine.uuid)}</span>
|
<span className="uuid-text">{formatUuid(machine.uuid)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="machine-actions" onClick={handleActionClick}>
|
<div className="machine-actions">
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={<QrCodeIcon size={14}/>}
|
icon={<QrCodeIcon size={14}/>}
|
||||||
onClick={() => openProvisioningModal(machine)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openProvisioningModal(machine);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Code
|
Code
|
||||||
</Button>
|
</Button>
|
||||||
@@ -262,7 +260,10 @@ export const Machines = () => {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={<TrashIcon size={14}/>}
|
icon={<TrashIcon size={14}/>}
|
||||||
onClick={() => handleDelete(machine.id, machine.name)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(machine.id, machine.name);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
export {Machines as default} from './Machines.jsx';
|
export {Machines as default} from './Machines.jsx';
|
||||||
|
export {MachineDetails} from './MachineDetails.jsx';
|
||||||
|
303
webui/src/pages/Machines/machine-details.sass
Normal file
303
webui/src/pages/Machines/machine-details.sass
Normal 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
|
@@ -1,4 +1,11 @@
|
|||||||
.machine-card
|
.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
|
.machine-card-header
|
||||||
display: flex
|
display: flex
|
||||||
align-items: flex-start
|
align-items: flex-start
|
||||||
@@ -44,6 +51,7 @@
|
|||||||
display: flex
|
display: flex
|
||||||
gap: 0.5rem
|
gap: 0.5rem
|
||||||
flex-shrink: 0
|
flex-shrink: 0
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
.modal-description
|
.modal-description
|
||||||
color: var(--color-text-muted)
|
color: var(--color-text-muted)
|
||||||
|
Reference in New Issue
Block a user