Add working test
This commit is contained in:
@@ -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/>},
|
||||
|
266
webui/src/pages/MachineDetails/MachineDetails.jsx
Normal file
266
webui/src/pages/MachineDetails/MachineDetails.jsx
Normal 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;
|
2
webui/src/pages/MachineDetails/index.js
Normal file
2
webui/src/pages/MachineDetails/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './MachineDetails.jsx';
|
||||
export { MachineDetails } from './MachineDetails.jsx';
|
250
webui/src/pages/MachineDetails/styles.sass
Normal file
250
webui/src/pages/MachineDetails/styles.sass
Normal 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
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user