Add working test

This commit is contained in:
2025-09-09 22:23:01 +02:00
parent 4e38b13faa
commit fa00747e80
27 changed files with 2373 additions and 46 deletions

View File

@@ -6,6 +6,7 @@ import Root from "@/common/layouts/Root.jsx";
import UserManagement from "@/pages/UserManagement";
import SystemSettings from "@/pages/SystemSettings";
import Machines from "@/pages/Machines";
import MachineDetails from "@/pages/MachineDetails";
import "@fontsource/plus-jakarta-sans/300.css";
import "@fontsource/plus-jakarta-sans/400.css";
import "@fontsource/plus-jakarta-sans/600.css";
@@ -24,6 +25,7 @@ const App = () => {
{path: "/", element: <Navigate to="/dashboard"/>},
{path: "/dashboard", element: <Placeholder title="Dashboard"/>},
{path: "/machines", element: <Machines/>},
{path: "/machines/:id", element: <MachineDetails/>},
{path: "/servers", element: <Placeholder title="Servers"/>},
{path: "/settings", element: <Placeholder title="Settings"/>},
{path: "/admin/users", element: <UserManagement/>},

View File

@@ -0,0 +1,266 @@
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
} 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);
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 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 {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
};
const getFsTypeColor = (fsType) => {
switch (fsType?.toLowerCase()) {
case 'ext': return 'success';
case 'ntfs': return 'info';
case 'fat32': return 'warning';
default: return 'secondary';
}
};
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={`Machine ID: ${machine.machine_id}`}
actions={
<Button variant="secondary" onClick={() => navigate('/machines')}>
<ArrowLeft size={16} />
Back to Machines
</Button>
}
/>
<Grid columns={1} gap="large">
{/* Machine Information */}
<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 */}
<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-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>
<DetailList>
<DetailItem
label="Created"
value={
<div className="snapshot-date">
<Calendar size={14} />
{formatDate(snapshot.created_at)}
</div>
}
/>
<DetailItem
label="Hash"
value={
<div className="snapshot-hash">
<Hash size={14} />
<code>{snapshot.snapshot_hash.substring(0, 16)}...</code>
</div>
}
/>
</DetailList>
{/* Disks */}
<div className="disks-section">
<h5><HardDrive size={16} /> Disks</h5>
<Grid columns={1} gap="small">
{snapshot.disks.map((disk, diskIndex) => (
<Card key={diskIndex} className="disk-card">
<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="small" minColumnWidth="250px">
{disk.partitions.map((partition, partIndex) => (
<Card key={partIndex} className="partition-card">
<CardBody>
<DetailList>
<DetailItem
label="Filesystem"
value={
<Badge variant={getFsTypeColor(partition.fs_type)}>
{partition.fs_type.toUpperCase()}
</Badge>
}
/>
<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()} />
</DetailList>
</CardBody>
</Card>
))}
</Grid>
</div>
)}
</CardBody>
</Card>
))}
</Grid>
</div>
</CardBody>
</Card>
))}
</Grid>
)}
</CardBody>
</Card>
</Grid>
</div>
);
};
export default MachineDetails;

View File

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

View File

@@ -0,0 +1,250 @@
// Variables are defined in main.sass root scope
.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-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
.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
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
color: var(--text)
margin-bottom: 0.5rem
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

View File

@@ -1,4 +1,5 @@
import React, {useState, useEffect, useContext} from 'react';
import {useNavigate} from 'react-router-dom';
import {UserContext} from '@/common/contexts/UserContext.jsx';
import {useToast} from '@/common/contexts/ToastContext.jsx';
import {getRequest, postRequest, deleteRequest} from '@/common/utils/RequestUtil.js';
@@ -28,6 +29,7 @@ import './styles.sass';
export const Machines = () => {
const {user: currentUser} = useContext(UserContext);
const toast = useToast();
const navigate = useNavigate();
const [machines, setMachines] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -179,6 +181,14 @@ export const Machines = () => {
}
};
const handleMachineClick = (machineId) => {
navigate(`/machines/${machineId}`);
};
const handleActionClick = (e) => {
e.stopPropagation(); // Prevent navigation when clicking action buttons
};
const handleInputChange = (e) => {
const {name, value} = e.target;
setFormData(prev => ({
@@ -220,7 +230,13 @@ export const Machines = () => {
<Grid minWidth="400px">
{machines.map(machine => (
<Card key={machine.id} hover className="machine-card">
<Card
key={machine.id}
hover
className="machine-card"
onClick={() => handleMachineClick(machine.id)}
style={{ cursor: 'pointer' }}
>
<CardHeader>
<div className="machine-card-header">
<div className="machine-icon">
@@ -233,7 +249,7 @@ export const Machines = () => {
<span className="uuid-text">{formatUuid(machine.uuid)}</span>
</div>
</div>
<div className="machine-actions">
<div className="machine-actions" onClick={handleActionClick}>
<Button
variant="subtle"
size="sm"