diff --git a/server/src/controllers/snapshots.rs b/server/src/controllers/snapshots.rs index 5170962..6acfdc9 100644 --- a/server/src/controllers/snapshots.rs +++ b/server/src/controllers/snapshots.rs @@ -5,9 +5,18 @@ use crate::utils::{error::*, models::*, DbPool}; use serde::Serialize; use chrono::{DateTime, Utc}; +// Basic snapshot info for listing #[derive(Debug, Serialize)] -pub struct SnapshotInfo { - pub id: String, // Use UUID string instead of integer +pub struct SnapshotSummary { + pub id: String, + pub snapshot_hash: String, + pub created_at: String, +} + +// Detailed snapshot info with disk/partition data +#[derive(Debug, Serialize)] +pub struct SnapshotDetails { + pub id: String, pub snapshot_hash: String, pub created_at: String, pub disks: Vec, @@ -35,7 +44,7 @@ impl SnapshotsController { pool: &DbPool, machine_id: i64, user: &User, - ) -> AppResult> { + ) -> AppResult> { // Verify machine access let machine = sqlx::query!( "SELECT id, user_id FROM machines WHERE id = ? AND user_id = ?", @@ -53,7 +62,7 @@ impl SnapshotsController { let _machine = machine.unwrap(); let storage = Storage::new("./data"); - let mut snapshot_infos = Vec::new(); + let mut snapshot_summaries = Vec::new(); // List all snapshots for this machine from storage match storage.list_snapshots(machine_id).await { @@ -61,71 +70,29 @@ impl SnapshotsController { for snapshot_id in snapshot_ids { // Load snapshot reference to get hash and timestamp if let Ok(Some((snapshot_hash, created_at_timestamp))) = storage.load_snapshot_ref(machine_id, &snapshot_id).await { - // Load snapshot metadata - if let Ok(Some(snapshot_meta)) = storage.load_meta(MetaType::Snapshot, &snapshot_hash).await { - if let MetaObj::Snapshot(snapshot_obj) = snapshot_meta { - let mut disks = Vec::new(); + let created_at = DateTime::from_timestamp(created_at_timestamp as i64, 0) + .unwrap_or_else(|| Utc::now()) + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(); - for disk_hash in snapshot_obj.disk_hashes { - if let Ok(Some(disk_meta)) = storage.load_meta(MetaType::Disk, &disk_hash).await { - if let MetaObj::Disk(disk_obj) = disk_meta { - let mut partitions = Vec::new(); - - for partition_hash in disk_obj.partition_hashes { - if let Ok(Some(partition_meta)) = storage.load_meta(MetaType::Partition, &partition_hash).await { - if let MetaObj::Partition(partition_obj) = partition_meta { - let fs_type_str = match partition_obj.fs_type_code { - FsType::Ext => "ext", - FsType::Ntfs => "ntfs", - FsType::Fat32 => "fat32", - FsType::Unknown => "unknown", - }; - - partitions.push(PartitionInfo { - fs_type: fs_type_str.to_string(), - start_lba: partition_obj.start_lba, - end_lba: partition_obj.end_lba, - size_bytes: (partition_obj.end_lba - partition_obj.start_lba) * 512, - }); - } - } - } - - disks.push(DiskInfo { - serial: disk_obj.serial, - size_bytes: disk_obj.disk_size_bytes, - partitions, - }); - } - } - } - - // Convert timestamp to readable format - let created_at_str = DateTime::::from_timestamp(created_at_timestamp as i64, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - - snapshot_infos.push(SnapshotInfo { - id: snapshot_id, - snapshot_hash: hex::encode(snapshot_hash), - created_at: created_at_str, - disks, - }); - } - } + snapshot_summaries.push(SnapshotSummary { + id: snapshot_id, + snapshot_hash: hex::encode(snapshot_hash), + created_at, + }); } } - } + }, Err(_) => { // If no snapshots directory exists, return empty list return Ok(Vec::new()); } } - // Sort snapshots by creation time (newest first) - snapshot_infos.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + // Sort by creation time (newest first) + snapshot_summaries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - Ok(snapshot_infos) + Ok(snapshot_summaries) } pub async fn get_snapshot_details( @@ -133,7 +100,7 @@ impl SnapshotsController { machine_id: i64, snapshot_id: String, user: &User, - ) -> AppResult { + ) -> AppResult { // Verify machine access let machine = sqlx::query!( "SELECT id, user_id FROM machines WHERE id = ? AND user_id = ?", @@ -204,7 +171,7 @@ impl SnapshotsController { .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "Unknown".to_string()); - Ok(SnapshotInfo { + Ok(SnapshotDetails { id: snapshot_id, snapshot_hash: hex::encode(snapshot_hash), created_at: created_at_str, diff --git a/server/src/routes/snapshots.rs b/server/src/routes/snapshots.rs index adde2a3..4e0ea21 100644 --- a/server/src/routes/snapshots.rs +++ b/server/src/routes/snapshots.rs @@ -1,12 +1,12 @@ use axum::{extract::{Path, State}, Json}; -use crate::controllers::snapshots::{SnapshotsController, SnapshotInfo}; +use crate::controllers::snapshots::{SnapshotsController, SnapshotSummary, SnapshotDetails}; use crate::utils::{auth::AuthUser, error::AppResult, DbPool}; pub async fn get_machine_snapshots( State(pool): State, Path(machine_id): Path, auth_user: AuthUser, -) -> AppResult>> { +) -> AppResult>> { let snapshots = SnapshotsController::get_machine_snapshots( &pool, machine_id, @@ -20,7 +20,7 @@ pub async fn get_snapshot_details( State(pool): State, Path((machine_id, snapshot_id)): Path<(i64, String)>, auth_user: AuthUser, -) -> AppResult> { +) -> AppResult> { let snapshot = SnapshotsController::get_snapshot_details( &pool, machine_id, diff --git a/webui/src/pages/MachineDetails/MachineDetails.jsx b/webui/src/pages/MachineDetails/MachineDetails.jsx index 81a2efa..7a7b79c 100644 --- a/webui/src/pages/MachineDetails/MachineDetails.jsx +++ b/webui/src/pages/MachineDetails/MachineDetails.jsx @@ -18,7 +18,9 @@ import { Calendar, Hash, Database, - Devices + Devices, + Eye, + ArrowCircleLeft } from '@phosphor-icons/react'; import './styles.sass'; @@ -30,6 +32,8 @@ export const MachineDetails = () => { 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) { @@ -57,6 +61,25 @@ export const MachineDetails = () => { } }; + 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; @@ -68,21 +91,56 @@ export const MachineDetails = () => { const formatDate = (dateString) => { if (!dateString || dateString === 'Unknown') return 'Unknown'; try { - return new Date(dateString).toLocaleString(); + // 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': return 'success'; - case 'ntfs': return 'info'; - case 'fat32': return 'warning'; - default: return 'secondary'; + 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 (
@@ -127,59 +185,134 @@ export const MachineDetails = () => {
navigate('/machines')}> - - Back to Machines - + selectedSnapshot ? ( + + ) : ( + + ) } /> - {/* Machine Information */} - - -

Machine Information

-
- - - - - - Active - } /> - - -
+ {/* Machine Information - Only show when not viewing snapshot details */} + {!selectedSnapshot && ( + + +

Machine Information

+
+ + + + + + Active + } /> + + +
+ )} - {/* Snapshots */} - - -

Snapshots ({snapshots.length})

-
- - {snapshots.length === 0 ? ( - } - title="No Snapshots" - subtitle="This machine hasn't created any snapshots yet." - /> - ) : ( - - {snapshots.map((snapshot) => ( - + {/* Snapshots List or Details */} + {!selectedSnapshot ? ( + /* Snapshots List */ + + +

Snapshots ({snapshots.length})

+
+ + {snapshots.length === 0 ? ( + } + title="No Snapshots" + subtitle="This machine hasn't created any snapshots yet." + /> + ) : ( + + {snapshots.map((snapshot) => ( + + +
+
+
+ +

Snapshot

+
+ + + + {formatDate(snapshot.created_at)} +
+ } + /> + + + {truncateHash(snapshot.id, 24)} +
+ } + /> + + + {truncateHash(snapshot.snapshot_hash, 24)} +
+ } + /> + +
+
+ +
+ + + + ))} + + )} + + + ) : ( + /* Snapshot Details */ + + +

Snapshot {selectedSnapshot} Details

+
+ + {loadingDetails ? ( + + ) : snapshotDetails ? ( +
+ {/* Snapshot Metadata */} + -
-

- - Snapshot #{snapshot.id} -

- - {snapshot.disks.length} disk{snapshot.disks.length !== 1 ? 's' : ''} - -
+

Metadata

@@ -188,7 +321,7 @@ export const MachineDetails = () => { value={
- {formatDate(snapshot.created_at)} + {formatDate(snapshotDetails.created_at)}
} /> @@ -197,67 +330,84 @@ export const MachineDetails = () => { value={
- {snapshot.snapshot_hash.substring(0, 16)}... + {snapshotDetails.snapshot_hash}
} /> +
- - {/* Disks */} -
-
Disks
- - {snapshot.disks.map((disk, diskIndex) => ( - - - - - - - - - {/* Partitions */} - {disk.partitions.length > 0 && ( -
-
Partitions
- - {disk.partitions.map((partition, partIndex) => ( - - - - - {partition.fs_type.toUpperCase()} - - } - /> - - - - - - - ))} - -
- )} -
-
- ))} -
-
- ))} - - )} - - + + {/* Disks */} +
+

Disks ({snapshotDetails.disks.length})

+ + {snapshotDetails.disks.map((disk, diskIndex) => ( + + +
Disk {diskIndex + 1}
+
+ + + + + + + + {/* Partitions */} + {disk.partitions.length > 0 && ( +
+
Partitions
+ + {disk.partitions.map((partition, partIndex) => ( + + +
+ Partition {partIndex + 1} + + {partition.fs_type.toUpperCase()} + +
+
+ + + + + + + + +
+ ))} +
+
+ )} +
+
+ ))} +
+
+
+ ) : ( + } + title="No Details Available" + subtitle="Unable to load snapshot details." + /> + )} +
+
+ )} ); diff --git a/webui/src/pages/MachineDetails/styles.sass b/webui/src/pages/MachineDetails/styles.sass index fba7531..0bb1b3a 100644 --- a/webui/src/pages/MachineDetails/styles.sass +++ b/webui/src/pages/MachineDetails/styles.sass @@ -1,250 +1,232 @@ -// Variables are defined in main.sass root scope - +// Machine Details Page Styles .machine-details - .machine-header - display: flex - align-items: center - gap: 1rem - margin-bottom: 2rem - - .back-button - padding: 0.5rem - border-radius: var(--radius) - border: 1px solid var(--border) - background: var(--bg-alt) - color: var(--text) - cursor: pointer - transition: all 0.2s ease - - &:hover - background: var(--bg-elev) - border-color: var(--border-strong) - - .machine-title - flex: 1 - - h1 - font-size: 1.5rem - font-weight: 600 - color: var(--text) - margin-bottom: 0.25rem - - .machine-uuid - font-family: 'Courier New', monospace - font-size: 0.875rem - color: var(--text-dim) - background: var(--bg-elev) - padding: 0.25rem 0.5rem - border-radius: var(--radius-sm) - display: inline-block - -.snapshots-section - h2 - font-size: 1.25rem - font-weight: 600 - color: var(--text) - margin-bottom: 1rem - -.snapshots-grid - display: grid - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)) - gap: 1.5rem - -.snapshot-card - border: 1px solid var(--border) - border-radius: var(--radius-lg) - background: var(--bg-alt) - padding: 1.5rem - transition: all 0.2s ease - - &:hover - border-color: var(--border-strong) - box-shadow: 0 2px 8px rgba(31, 36, 41, 0.1) - - .snapshot-header - display: flex - justify-content: space-between - align-items: flex-start - margin-bottom: 1rem + // Snapshot Summary Cards (list view) + .snapshot-summary-card + transition: all 0.2s ease + cursor: pointer - .snapshot-info - h3 - font-size: 1rem - font-weight: 600 - color: var(--text) - margin-bottom: 0.25rem - - .snapshot-hash - font-family: 'Courier New', monospace - font-size: 0.75rem - color: var(--text-dim) - background: var(--bg-elev) - padding: 0.125rem 0.375rem - border-radius: var(--radius-sm) - - .snapshot-date - font-size: 0.875rem - color: var(--text-dim) - margin-top: 0.5rem + &:hover + border-color: var(--border-strong) + box-shadow: 0 4px 12px rgba(31, 36, 41, 0.1) + transform: translateY(-1px) - .disks-section - h4 - font-size: 0.875rem - font-weight: 600 - color: var(--text) - margin-bottom: 0.75rem - display: flex - align-items: center - gap: 0.5rem - - &::before - content: "💾" - font-size: 1rem - -.disk-list - display: flex - flex-direction: column - gap: 1rem - -.disk-item - background: var(--bg-elev) - border: 1px solid var(--border) - border-radius: var(--radius) - padding: 1rem - - .disk-header - display: flex - justify-content: space-between - align-items: center - margin-bottom: 0.75rem - - .disk-serial - font-family: 'Courier New', monospace - font-size: 0.875rem - font-weight: 600 - color: var(--text) - - .disk-size - font-size: 0.875rem - color: var(--text-dim) - font-weight: 500 - - .partitions-section - h5 - font-size: 0.75rem - font-weight: 600 - color: var(--text-dim) - text-transform: uppercase - letter-spacing: 0.05em - margin-bottom: 0.5rem - -.partition-list - display: flex - flex-direction: column - gap: 0.5rem - -.partition-item - background: var(--bg-alt) - border: 1px solid var(--border) - border-radius: var(--radius-sm) - padding: 0.75rem - - .partition-header - display: flex - justify-content: space-between - align-items: center - margin-bottom: 0.5rem - - .partition-fs - background: var(--accent) - color: white - font-size: 0.75rem - font-weight: 600 - padding: 0.125rem 0.5rem - border-radius: var(--radius-sm) - text-transform: uppercase - - .partition-size - font-size: 0.75rem - color: var(--text-dim) - font-weight: 500 - - .partition-details - display: grid - grid-template-columns: 1fr 1fr - gap: 0.5rem - font-size: 0.75rem - color: var(--text-dim) - - .detail-item + .snapshot-summary display: flex justify-content: space-between + align-items: flex-start + gap: 1.5rem - .label - font-weight: 500 + .snapshot-info + flex: 1 - .value - font-family: 'Courier New', monospace + .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 -.empty-snapshots - text-align: center - padding: 3rem 1rem - background: var(--bg-alt) - border: 2px dashed var(--border) - border-radius: var(--radius-lg) - - .empty-icon - font-size: 3rem - margin-bottom: 1rem - opacity: 0.5 + .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 - h3 - font-size: 1.125rem + &::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 - color: var(--text) - margin-bottom: 0.5rem + letter-spacing: 0.025em - p - color: var(--text-dim) - line-height: 1.5 - -.loading-section - text-align: center - padding: 2rem - - .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) - -.error-section - text-align: center - padding: 2rem - background: rgba(217, 48, 37, 0.1) - border: 1px solid rgba(217, 48, 37, 0.2) - border-radius: var(--radius-lg) - - .error-icon - font-size: 2rem - color: var(--danger) - margin-bottom: 1rem - - h3 - color: var(--danger) - font-size: 1.125rem - font-weight: 600 - margin-bottom: 0.5rem - - p - color: var(--text-dim) - line-height: 1.5 + &.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%)