Add working test
This commit is contained in:
@@ -5,9 +5,18 @@ use crate::utils::{error::*, models::*, DbPool};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
// Basic snapshot info for listing
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct SnapshotInfo {
|
pub struct SnapshotSummary {
|
||||||
pub id: String, // Use UUID string instead of integer
|
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 snapshot_hash: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub disks: Vec<DiskInfo>,
|
pub disks: Vec<DiskInfo>,
|
||||||
@@ -35,7 +44,7 @@ impl SnapshotsController {
|
|||||||
pool: &DbPool,
|
pool: &DbPool,
|
||||||
machine_id: i64,
|
machine_id: i64,
|
||||||
user: &User,
|
user: &User,
|
||||||
) -> AppResult<Vec<SnapshotInfo>> {
|
) -> AppResult<Vec<SnapshotSummary>> {
|
||||||
// Verify machine access
|
// Verify machine access
|
||||||
let machine = sqlx::query!(
|
let machine = sqlx::query!(
|
||||||
"SELECT id, user_id FROM machines WHERE id = ? AND user_id = ?",
|
"SELECT id, user_id FROM machines WHERE id = ? AND user_id = ?",
|
||||||
@@ -53,7 +62,7 @@ impl SnapshotsController {
|
|||||||
let _machine = machine.unwrap();
|
let _machine = machine.unwrap();
|
||||||
|
|
||||||
let storage = Storage::new("./data");
|
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
|
// List all snapshots for this machine from storage
|
||||||
match storage.list_snapshots(machine_id).await {
|
match storage.list_snapshots(machine_id).await {
|
||||||
@@ -61,71 +70,29 @@ impl SnapshotsController {
|
|||||||
for snapshot_id in snapshot_ids {
|
for snapshot_id in snapshot_ids {
|
||||||
// Load snapshot reference to get hash and timestamp
|
// 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 {
|
if let Ok(Some((snapshot_hash, created_at_timestamp))) = storage.load_snapshot_ref(machine_id, &snapshot_id).await {
|
||||||
// Load snapshot metadata
|
let created_at = DateTime::from_timestamp(created_at_timestamp as i64, 0)
|
||||||
if let Ok(Some(snapshot_meta)) = storage.load_meta(MetaType::Snapshot, &snapshot_hash).await {
|
.unwrap_or_else(|| Utc::now())
|
||||||
if let MetaObj::Snapshot(snapshot_obj) = snapshot_meta {
|
.format("%Y-%m-%d %H:%M:%S UTC")
|
||||||
let mut disks = Vec::new();
|
.to_string();
|
||||||
|
|
||||||
for disk_hash in snapshot_obj.disk_hashes {
|
snapshot_summaries.push(SnapshotSummary {
|
||||||
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 {
|
|
||||||
id: snapshot_id,
|
id: snapshot_id,
|
||||||
snapshot_hash: hex::encode(snapshot_hash),
|
snapshot_hash: hex::encode(snapshot_hash),
|
||||||
created_at: created_at_str,
|
created_at,
|
||||||
disks,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// If no snapshots directory exists, return empty list
|
// If no snapshots directory exists, return empty list
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort snapshots by creation time (newest first)
|
// Sort by creation time (newest first)
|
||||||
snapshot_infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
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(
|
pub async fn get_snapshot_details(
|
||||||
@@ -133,7 +100,7 @@ impl SnapshotsController {
|
|||||||
machine_id: i64,
|
machine_id: i64,
|
||||||
snapshot_id: String,
|
snapshot_id: String,
|
||||||
user: &User,
|
user: &User,
|
||||||
) -> AppResult<SnapshotInfo> {
|
) -> AppResult<SnapshotDetails> {
|
||||||
// Verify machine access
|
// Verify machine access
|
||||||
let machine = sqlx::query!(
|
let machine = sqlx::query!(
|
||||||
"SELECT id, user_id FROM machines WHERE id = ? AND user_id = ?",
|
"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())
|
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
Ok(SnapshotInfo {
|
Ok(SnapshotDetails {
|
||||||
id: snapshot_id,
|
id: snapshot_id,
|
||||||
snapshot_hash: hex::encode(snapshot_hash),
|
snapshot_hash: hex::encode(snapshot_hash),
|
||||||
created_at: created_at_str,
|
created_at: created_at_str,
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
use axum::{extract::{Path, State}, Json};
|
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};
|
use crate::utils::{auth::AuthUser, error::AppResult, DbPool};
|
||||||
|
|
||||||
pub async fn get_machine_snapshots(
|
pub async fn get_machine_snapshots(
|
||||||
State(pool): State<DbPool>,
|
State(pool): State<DbPool>,
|
||||||
Path(machine_id): Path<i64>,
|
Path(machine_id): Path<i64>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> AppResult<Json<Vec<SnapshotInfo>>> {
|
) -> AppResult<Json<Vec<SnapshotSummary>>> {
|
||||||
let snapshots = SnapshotsController::get_machine_snapshots(
|
let snapshots = SnapshotsController::get_machine_snapshots(
|
||||||
&pool,
|
&pool,
|
||||||
machine_id,
|
machine_id,
|
||||||
@@ -20,7 +20,7 @@ pub async fn get_snapshot_details(
|
|||||||
State(pool): State<DbPool>,
|
State(pool): State<DbPool>,
|
||||||
Path((machine_id, snapshot_id)): Path<(i64, String)>,
|
Path((machine_id, snapshot_id)): Path<(i64, String)>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> AppResult<Json<SnapshotInfo>> {
|
) -> AppResult<Json<SnapshotDetails>> {
|
||||||
let snapshot = SnapshotsController::get_snapshot_details(
|
let snapshot = SnapshotsController::get_snapshot_details(
|
||||||
&pool,
|
&pool,
|
||||||
machine_id,
|
machine_id,
|
||||||
|
@@ -18,7 +18,9 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Hash,
|
Hash,
|
||||||
Database,
|
Database,
|
||||||
Devices
|
Devices,
|
||||||
|
Eye,
|
||||||
|
ArrowCircleLeft
|
||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import './styles.sass';
|
import './styles.sass';
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ export const MachineDetails = () => {
|
|||||||
const [snapshots, setSnapshots] = useState([]);
|
const [snapshots, setSnapshots] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
||||||
|
const [snapshotDetails, setSnapshotDetails] = useState(null);
|
||||||
|
const [loadingDetails, setLoadingDetails] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
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) => {
|
const formatBytes = (bytes) => {
|
||||||
if (!bytes) return '0 B';
|
if (!bytes) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -68,21 +91,56 @@ export const MachineDetails = () => {
|
|||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString || dateString === 'Unknown') return 'Unknown';
|
if (!dateString || dateString === 'Unknown') return 'Unknown';
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return dateString;
|
return dateString;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatLBA = (lba) => {
|
||||||
|
if (!lba && lba !== 0) return '0';
|
||||||
|
return lba.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
const getFsTypeColor = (fsType) => {
|
const getFsTypeColor = (fsType) => {
|
||||||
switch (fsType?.toLowerCase()) {
|
switch (fsType?.toLowerCase()) {
|
||||||
case 'ext': return 'success';
|
case 'ext':
|
||||||
case 'ntfs': return 'info';
|
case 'ext4':
|
||||||
case 'fat32': return 'warning';
|
case 'ext3':
|
||||||
default: return 'secondary';
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="machine-details">
|
<div className="machine-details">
|
||||||
@@ -127,17 +185,29 @@ export const MachineDetails = () => {
|
|||||||
<div className="machine-details">
|
<div className="machine-details">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={machine.name}
|
title={machine.name}
|
||||||
subtitle={`Machine ID: ${machine.machine_id}`}
|
subtitle={
|
||||||
|
selectedSnapshot
|
||||||
|
? `Snapshot Details`
|
||||||
|
: `Machine ID: ${machine.machine_id}`
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
|
selectedSnapshot ? (
|
||||||
|
<Button variant="secondary" onClick={backToSnapshots}>
|
||||||
|
<ArrowCircleLeft size={16} />
|
||||||
|
Back to Snapshots
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button variant="secondary" onClick={() => navigate('/machines')}>
|
<Button variant="secondary" onClick={() => navigate('/machines')}>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Back to Machines
|
Back to Machines
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid columns={1} gap="large">
|
<Grid columns={1} gap="large">
|
||||||
{/* Machine Information */}
|
{/* Machine Information - Only show when not viewing snapshot details */}
|
||||||
|
{!selectedSnapshot && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3><Devices size={20} /> Machine Information</h3>
|
<h3><Devices size={20} /> Machine Information</h3>
|
||||||
@@ -153,8 +223,11 @@ export const MachineDetails = () => {
|
|||||||
</DetailList>
|
</DetailList>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Snapshots */}
|
{/* Snapshots List or Details */}
|
||||||
|
{!selectedSnapshot ? (
|
||||||
|
/* Snapshots List */
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3><Camera size={20} /> Snapshots ({snapshots.length})</h3>
|
<h3><Camera size={20} /> Snapshots ({snapshots.length})</h3>
|
||||||
@@ -169,19 +242,14 @@ export const MachineDetails = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Grid columns={1} gap="medium">
|
<Grid columns={1} gap="medium">
|
||||||
{snapshots.map((snapshot) => (
|
{snapshots.map((snapshot) => (
|
||||||
<Card key={snapshot.id} className="snapshot-card">
|
<Card key={snapshot.id} className="snapshot-summary-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>
|
|
||||||
<CardBody>
|
<CardBody>
|
||||||
|
<div className="snapshot-summary">
|
||||||
|
<div className="snapshot-info">
|
||||||
|
<div className="snapshot-title">
|
||||||
|
<Camera size={18} />
|
||||||
|
<h4>Snapshot</h4>
|
||||||
|
</div>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
<DetailItem
|
<DetailItem
|
||||||
label="Created"
|
label="Created"
|
||||||
@@ -192,23 +260,97 @@ export const MachineDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Snapshot ID"
|
||||||
|
value={
|
||||||
|
<div className="snapshot-hash">
|
||||||
|
<Hash size={14} />
|
||||||
|
<code>{truncateHash(snapshot.id, 24)}</code>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DetailItem
|
<DetailItem
|
||||||
label="Hash"
|
label="Hash"
|
||||||
value={
|
value={
|
||||||
<div className="snapshot-hash">
|
<div className="snapshot-hash">
|
||||||
<Hash size={14} />
|
<Hash size={14} />
|
||||||
<code>{snapshot.snapshot_hash.substring(0, 16)}...</code>
|
<code>{truncateHash(snapshot.snapshot_hash, 24)}</code>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</DetailList>
|
</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 */}
|
{/* Disks */}
|
||||||
<div className="disks-section">
|
<div className="disks-section">
|
||||||
<h5><HardDrive size={16} /> Disks</h5>
|
<h4><HardDrive size={18} /> Disks ({snapshotDetails.disks.length})</h4>
|
||||||
<Grid columns={1} gap="small">
|
<Grid columns={1} gap="medium">
|
||||||
{snapshot.disks.map((disk, diskIndex) => (
|
{snapshotDetails.disks.map((disk, diskIndex) => (
|
||||||
<Card key={diskIndex} className="disk-card">
|
<Card key={diskIndex} className="disk-card">
|
||||||
|
<CardHeader>
|
||||||
|
<h5><HardDrive size={16} /> Disk {diskIndex + 1}</h5>
|
||||||
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
<DetailItem label="Serial" value={disk.serial || 'Unknown'} />
|
<DetailItem label="Serial" value={disk.serial || 'Unknown'} />
|
||||||
@@ -223,22 +365,26 @@ export const MachineDetails = () => {
|
|||||||
{disk.partitions.length > 0 && (
|
{disk.partitions.length > 0 && (
|
||||||
<div className="partitions-section">
|
<div className="partitions-section">
|
||||||
<h6><Folder size={14} /> Partitions</h6>
|
<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) => (
|
{disk.partitions.map((partition, partIndex) => (
|
||||||
<Card key={partIndex} className="partition-card">
|
<Card key={partIndex} className="partition-card">
|
||||||
<CardBody>
|
<CardHeader>
|
||||||
<DetailList>
|
<div className="partition-header">
|
||||||
<DetailItem
|
<span>Partition {partIndex + 1}</span>
|
||||||
label="Filesystem"
|
|
||||||
value={
|
|
||||||
<Badge variant={getFsTypeColor(partition.fs_type)}>
|
<Badge variant={getFsTypeColor(partition.fs_type)}>
|
||||||
{partition.fs_type.toUpperCase()}
|
{partition.fs_type.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
</div>
|
||||||
/>
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<DetailList>
|
||||||
<DetailItem label="Size" value={formatBytes(partition.size_bytes)} />
|
<DetailItem label="Size" value={formatBytes(partition.size_bytes)} />
|
||||||
<DetailItem label="Start LBA" value={partition.start_lba.toLocaleString()} />
|
<DetailItem label="Start LBA" value={formatLBA(partition.start_lba)} />
|
||||||
<DetailItem label="End LBA" value={partition.end_lba.toLocaleString()} />
|
<DetailItem label="End LBA" value={formatLBA(partition.end_lba)} />
|
||||||
|
<DetailItem
|
||||||
|
label="Sectors"
|
||||||
|
value={formatLBA(partition.end_lba - partition.start_lba)}
|
||||||
|
/>
|
||||||
</DetailList>
|
</DetailList>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -251,13 +397,17 @@ export const MachineDetails = () => {
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</div>
|
||||||
</Card>
|
) : (
|
||||||
))}
|
<EmptyState
|
||||||
</Grid>
|
icon={<Camera size={48} weight="duotone" />}
|
||||||
|
title="No Details Available"
|
||||||
|
subtitle="Unable to load snapshot details."
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -1,216 +1,181 @@
|
|||||||
// Variables are defined in main.sass root scope
|
// Machine Details Page Styles
|
||||||
|
|
||||||
.machine-details
|
.machine-details
|
||||||
.machine-header
|
// Snapshot Summary Cards (list view)
|
||||||
display: flex
|
.snapshot-summary-card
|
||||||
align-items: center
|
transition: all 0.2s ease
|
||||||
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
|
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
|
&:hover
|
||||||
border-color: var(--border-strong)
|
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
|
display: flex
|
||||||
justify-content: space-between
|
justify-content: space-between
|
||||||
align-items: flex-start
|
align-items: flex-start
|
||||||
margin-bottom: 1rem
|
gap: 1.5rem
|
||||||
|
|
||||||
.snapshot-info
|
.snapshot-info
|
||||||
h3
|
flex: 1
|
||||||
font-size: 1rem
|
|
||||||
|
.snapshot-title
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.75rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
h4
|
||||||
|
font-size: 1.125rem
|
||||||
font-weight: 600
|
font-weight: 600
|
||||||
color: var(--text)
|
color: var(--text)
|
||||||
margin-bottom: 0.25rem
|
margin: 0
|
||||||
|
|
||||||
.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
|
.snapshot-date
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
font-size: 0.875rem
|
font-size: 0.875rem
|
||||||
color: var(--text-dim)
|
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
|
.disks-section
|
||||||
h4
|
h4
|
||||||
font-size: 0.875rem
|
font-size: 1.25rem
|
||||||
font-weight: 600
|
font-weight: 600
|
||||||
color: var(--text)
|
color: var(--text)
|
||||||
margin-bottom: 0.75rem
|
margin-bottom: 1.5rem
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center
|
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
|
&::before
|
||||||
content: "💾"
|
content: ''
|
||||||
font-size: 1rem
|
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
|
&:hover
|
||||||
display: flex
|
border-color: var(--border-strong)
|
||||||
flex-direction: column
|
box-shadow: 0 6px 20px rgba(31, 36, 41, 0.15)
|
||||||
gap: 1rem
|
transform: translateY(-2px)
|
||||||
|
|
||||||
.disk-item
|
&::before
|
||||||
background: var(--bg-elev)
|
opacity: 1
|
||||||
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
|
.partitions-section
|
||||||
h5
|
margin-top: 2rem
|
||||||
font-size: 0.75rem
|
|
||||||
|
h6
|
||||||
|
font-size: 1rem
|
||||||
font-weight: 600
|
font-weight: 600
|
||||||
color: var(--text-dim)
|
color: var(--text)
|
||||||
text-transform: uppercase
|
margin-bottom: 1rem
|
||||||
letter-spacing: 0.05em
|
|
||||||
margin-bottom: 0.5rem
|
|
||||||
|
|
||||||
.partition-list
|
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
align-items: center
|
||||||
gap: 0.5rem
|
gap: 0.5rem
|
||||||
|
padding: 0.5rem 0
|
||||||
|
border-bottom: 1px solid var(--border)
|
||||||
|
|
||||||
.partition-item
|
.partition-card
|
||||||
background: var(--bg-alt)
|
|
||||||
border: 1px solid var(--border)
|
border: 1px solid var(--border)
|
||||||
border-radius: var(--radius-sm)
|
background: var(--bg-elev)
|
||||||
padding: 0.75rem
|
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
|
.partition-header
|
||||||
display: flex
|
display: flex
|
||||||
justify-content: space-between
|
justify-content: space-between
|
||||||
align-items: center
|
align-items: center
|
||||||
margin-bottom: 0.5rem
|
|
||||||
|
|
||||||
.partition-fs
|
span
|
||||||
background: var(--accent)
|
font-size: 0.875rem
|
||||||
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
|
|
||||||
font-weight: 600
|
font-weight: 600
|
||||||
color: var(--text)
|
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)
|
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
|
.loading-section
|
||||||
text-align: center
|
text-align: center
|
||||||
padding: 2rem
|
padding: 3rem
|
||||||
|
|
||||||
.spinner
|
.spinner
|
||||||
border: 3px solid var(--border)
|
border: 3px solid var(--border)
|
||||||
@@ -227,24 +192,41 @@
|
|||||||
100%
|
100%
|
||||||
transform: rotate(360deg)
|
transform: rotate(360deg)
|
||||||
|
|
||||||
.error-section
|
// Responsive design
|
||||||
text-align: center
|
@media (max-width: 768px)
|
||||||
padding: 2rem
|
.snapshot-summary
|
||||||
background: rgba(217, 48, 37, 0.1)
|
flex-direction: column
|
||||||
border: 1px solid rgba(217, 48, 37, 0.2)
|
gap: 1rem
|
||||||
border-radius: var(--radius-lg)
|
|
||||||
|
|
||||||
.error-icon
|
.snapshot-actions
|
||||||
font-size: 2rem
|
flex-direction: row
|
||||||
color: var(--danger)
|
align-self: stretch
|
||||||
margin-bottom: 1rem
|
|
||||||
|
|
||||||
h3
|
.disk-card .partitions-section h6
|
||||||
color: var(--danger)
|
font-size: 0.875rem
|
||||||
|
|
||||||
|
.disks-section h4
|
||||||
font-size: 1.125rem
|
font-size: 1.125rem
|
||||||
font-weight: 600
|
|
||||||
margin-bottom: 0.5rem
|
|
||||||
|
|
||||||
p
|
// Visual hierarchy improvements
|
||||||
color: var(--text-dim)
|
.card
|
||||||
line-height: 1.5
|
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%)
|
||||||
|
Reference in New Issue
Block a user