Add working test

This commit is contained in:
2025-09-09 22:42:16 +02:00
parent fa00747e80
commit e595fcbdac
4 changed files with 510 additions and 411 deletions

View File

@@ -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<DiskInfo>,
@@ -35,7 +44,7 @@ impl SnapshotsController {
pool: &DbPool,
machine_id: i64,
user: &User,
) -> AppResult<Vec<SnapshotInfo>> {
) -> AppResult<Vec<SnapshotSummary>> {
// 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::<Utc>::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 {
snapshot_summaries.push(SnapshotSummary {
id: snapshot_id,
snapshot_hash: hex::encode(snapshot_hash),
created_at: created_at_str,
disks,
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<SnapshotInfo> {
) -> AppResult<SnapshotDetails> {
// 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,

View File

@@ -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<DbPool>,
Path(machine_id): Path<i64>,
auth_user: AuthUser,
) -> AppResult<Json<Vec<SnapshotInfo>>> {
) -> AppResult<Json<Vec<SnapshotSummary>>> {
let snapshots = SnapshotsController::get_machine_snapshots(
&pool,
machine_id,
@@ -20,7 +20,7 @@ pub async fn get_snapshot_details(
State(pool): State<DbPool>,
Path((machine_id, snapshot_id)): Path<(i64, String)>,
auth_user: AuthUser,
) -> AppResult<Json<SnapshotInfo>> {
) -> AppResult<Json<SnapshotDetails>> {
let snapshot = SnapshotsController::get_snapshot_details(
&pool,
machine_id,

View File

@@ -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 (
<div className="machine-details">
@@ -127,17 +185,29 @@ export const MachineDetails = () => {
<div className="machine-details">
<PageHeader
title={machine.name}
subtitle={`Machine ID: ${machine.machine_id}`}
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 */}
{/* Machine Information - Only show when not viewing snapshot details */}
{!selectedSnapshot && (
<Card>
<CardHeader>
<h3><Devices size={20} /> Machine Information</h3>
@@ -153,8 +223,11 @@ export const MachineDetails = () => {
</DetailList>
</CardBody>
</Card>
)}
{/* Snapshots */}
{/* Snapshots List or Details */}
{!selectedSnapshot ? (
/* Snapshots List */
<Card>
<CardHeader>
<h3><Camera size={20} /> Snapshots ({snapshots.length})</h3>
@@ -169,19 +242,14 @@ export const MachineDetails = () => {
) : (
<Grid columns={1} gap="medium">
{snapshots.map((snapshot) => (
<Card key={snapshot.id} className="snapshot-card">
<CardHeader>
<div className="snapshot-header">
<h4>
<Camera size={16} />
Snapshot #{snapshot.id}
</h4>
<Badge variant="secondary">
{snapshot.disks.length} disk{snapshot.disks.length !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<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"
@@ -192,23 +260,97 @@ export const MachineDetails = () => {
</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>{snapshot.snapshot_hash.substring(0, 16)}...</code>
<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">
<h5><HardDrive size={16} /> Disks</h5>
<Grid columns={1} gap="small">
{snapshot.disks.map((disk, diskIndex) => (
<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'} />
@@ -223,22 +365,26 @@ export const MachineDetails = () => {
{disk.partitions.length > 0 && (
<div className="partitions-section">
<h6><Folder size={14} /> Partitions</h6>
<Grid columns="auto-fit" gap="small" minColumnWidth="250px">
<Grid columns="auto-fit" gap="1rem" minWidth="280px">
{disk.partitions.map((partition, partIndex) => (
<Card key={partIndex} className="partition-card">
<CardBody>
<DetailList>
<DetailItem
label="Filesystem"
value={
<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={partition.start_lba.toLocaleString()} />
<DetailItem label="End LBA" value={partition.end_lba.toLocaleString()} />
<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>
@@ -251,13 +397,17 @@ export const MachineDetails = () => {
))}
</Grid>
</div>
</CardBody>
</Card>
))}
</Grid>
</div>
) : (
<EmptyState
icon={<Camera size={48} weight="duotone" />}
title="No Details Available"
subtitle="Unable to load snapshot details."
/>
)}
</CardBody>
</Card>
)}
</Grid>
</div>
);

View File

@@ -1,216 +1,181 @@
// 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)
// Snapshot Summary Cards (list view)
.snapshot-summary-card
transition: all 0.2s ease
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)
box-shadow: 0 4px 12px rgba(31, 36, 41, 0.1)
transform: translateY(-1px)
.snapshot-header
.snapshot-summary
display: flex
justify-content: space-between
align-items: flex-start
margin-bottom: 1rem
gap: 1.5rem
.snapshot-info
h3
font-size: 1rem
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-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)
margin: 0
.snapshot-date
display: flex
align-items: center
gap: 0.5rem
font-size: 0.875rem
color: var(--text-dim)
margin-top: 0.5rem
.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: 0.875rem
font-size: 1.25rem
font-weight: 600
color: var(--text)
margin-bottom: 0.75rem
margin-bottom: 1.5rem
display: flex
align-items: center
gap: 0.5rem
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: "💾"
font-size: 1rem
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
.disk-list
display: flex
flex-direction: column
gap: 1rem
&:hover
border-color: var(--border-strong)
box-shadow: 0 6px 20px rgba(31, 36, 41, 0.15)
transform: translateY(-2px)
.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
&::before
opacity: 1
.partitions-section
h5
font-size: 0.75rem
margin-top: 2rem
h6
font-size: 1rem
font-weight: 600
color: var(--text-dim)
text-transform: uppercase
letter-spacing: 0.05em
margin-bottom: 0.5rem
.partition-list
color: var(--text)
margin-bottom: 1rem
display: flex
flex-direction: column
align-items: center
gap: 0.5rem
padding: 0.5rem 0
border-bottom: 1px solid var(--border)
.partition-item
background: var(--bg-alt)
.partition-card
border: 1px solid var(--border)
border-radius: var(--radius-sm)
padding: 0.75rem
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
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
display: flex
justify-content: space-between
.label
font-weight: 500
.value
font-family: 'Courier New', monospace
.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
h3
font-size: 1.125rem
span
font-size: 0.875rem
font-weight: 600
color: var(--text)
margin-bottom: 0.5rem
p
// 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)
line-height: 1.5
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: 2rem
padding: 3rem
.spinner
border: 3px solid var(--border)
@@ -227,24 +192,41 @@
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)
// Responsive design
@media (max-width: 768px)
.snapshot-summary
flex-direction: column
gap: 1rem
.error-icon
font-size: 2rem
color: var(--danger)
margin-bottom: 1rem
.snapshot-actions
flex-direction: row
align-self: stretch
h3
color: var(--danger)
.disk-card .partitions-section h6
font-size: 0.875rem
.disks-section h4
font-size: 1.125rem
font-weight: 600
margin-bottom: 0.5rem
p
color: var(--text-dim)
line-height: 1.5
// 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%)