Implement UI for machines & system settings
This commit is contained in:
423
webui/src/pages/Machines/Machines.jsx
Normal file
423
webui/src/pages/Machines/Machines.jsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, {useState, useEffect, useContext} from 'react';
|
||||
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
||||
import {useToast} from '@/common/contexts/ToastContext.jsx';
|
||||
import {getRequest, postRequest, deleteRequest} from '@/common/utils/RequestUtil.js';
|
||||
import Button from '@/common/components/Button';
|
||||
import Input from '@/common/components/Input';
|
||||
import Modal, {ModalActions} from '@/common/components/Modal';
|
||||
import Card, {CardHeader, CardBody} from '@/common/components/Card';
|
||||
import Badge from '@/common/components/Badge';
|
||||
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 {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
ComputerTowerIcon,
|
||||
CalendarIcon,
|
||||
IdentificationCardIcon,
|
||||
UserIcon,
|
||||
QrCodeIcon,
|
||||
CopyIcon,
|
||||
ClockIcon
|
||||
} from '@phosphor-icons/react';
|
||||
import './styles.sass';
|
||||
|
||||
export const Machines = () => {
|
||||
const {user: currentUser} = useContext(UserContext);
|
||||
const toast = useToast();
|
||||
const [machines, setMachines] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showProvisioningModal, setShowProvisioningModal] = useState(false);
|
||||
const [selectedMachine, setSelectedMachine] = useState(null);
|
||||
const [provisioningCode, setProvisioningCode] = useState(null);
|
||||
const [formData, setFormData] = useState({name: ''});
|
||||
const [formErrors, setFormErrors] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMachines();
|
||||
}, []);
|
||||
|
||||
const fetchMachines = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getRequest('machines');
|
||||
setMachines(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch machines:', error);
|
||||
toast.error('Failed to load machines. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatUuid = (uuid) => {
|
||||
// Show first 8 characters of UUID for display
|
||||
return uuid.substring(0, 8).toUpperCase();
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setFormData({name: ''});
|
||||
setFormErrors({});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({name: ''});
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
const openProvisioningModal = (machine) => {
|
||||
setSelectedMachine(machine);
|
||||
setProvisioningCode(null);
|
||||
setShowProvisioningModal(true);
|
||||
};
|
||||
|
||||
const closeProvisioningModal = () => {
|
||||
setShowProvisioningModal(false);
|
||||
setSelectedMachine(null);
|
||||
setProvisioningCode(null);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
errors.name = 'Machine name is required';
|
||||
} else if (formData.name.length < 3) {
|
||||
errors.name = 'Machine name must be at least 3 characters';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await postRequest('machines/register', {
|
||||
name: formData.name.trim()
|
||||
});
|
||||
|
||||
toast.success(`Machine "${formData.name}" registered successfully`);
|
||||
closeCreateModal();
|
||||
fetchMachines();
|
||||
} catch (error) {
|
||||
console.error('Failed to register machine:', error);
|
||||
const errorMessage = error.error || 'Failed to register machine. Please try again.';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateProvisioningCode = async () => {
|
||||
if (!selectedMachine) return;
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await postRequest('machines/provisioning-code', {
|
||||
machine_id: selectedMachine.id
|
||||
});
|
||||
|
||||
setProvisioningCode(response);
|
||||
toast.success('Provisioning code generated successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate provisioning code:', error);
|
||||
const errorMessage = error.error || 'Failed to generate provisioning code. Please try again.';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = async (code) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
toast.success('Provisioning code copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (machineId, machineName) => {
|
||||
if (!window.confirm(`Are you sure you want to delete machine "${machineName}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteRequest(`machines/${machineId}`);
|
||||
toast.success(`Machine "${machineName}" deleted successfully`);
|
||||
fetchMachines();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete machine:', error);
|
||||
toast.error('Failed to delete machine. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const {name, value} = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
if (formErrors[name]) {
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content">
|
||||
<LoadingSpinner centered text="Loading machines..."/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content">
|
||||
<PageHeader
|
||||
title="Machines"
|
||||
subtitle="Manage your registered machines and generate provisioning codes"
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<PlusIcon size={16}/>}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
Register Machine
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid minWidth="400px">
|
||||
{machines.map(machine => (
|
||||
<Card key={machine.id} hover className="machine-card">
|
||||
<CardHeader>
|
||||
<div className="machine-card-header">
|
||||
<div className="machine-icon">
|
||||
<ComputerTowerIcon size={24} weight="duotone"/>
|
||||
</div>
|
||||
<div className="machine-info">
|
||||
<h3 className="machine-name">{machine.name}</h3>
|
||||
<div className="machine-uuid">
|
||||
<IdentificationCardIcon size={14}/>
|
||||
<span className="uuid-text">{formatUuid(machine.uuid)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="machine-actions">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
icon={<QrCodeIcon size={14}/>}
|
||||
onClick={() => openProvisioningModal(machine)}
|
||||
>
|
||||
Code
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={<TrashIcon size={14}/>}
|
||||
onClick={() => handleDelete(machine.id, machine.name)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
<DetailItem icon={<UserIcon size={16}/>}>
|
||||
Owner: {currentUser?.role === 'admin' ? `User ${machine.user_id}` : 'You'}
|
||||
</DetailItem>
|
||||
<DetailItem icon={<CalendarIcon size={16}/>}>
|
||||
Registered: {formatDate(machine.created_at)}
|
||||
</DetailItem>
|
||||
</DetailList>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{machines.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<ComputerTowerIcon size={48} weight="duotone"/>}
|
||||
title="No machines registered"
|
||||
description="Register your first machine to get started with backup management"
|
||||
action={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<PlusIcon size={16}/>}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
Register Machine
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Machine Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={closeCreateModal}
|
||||
title="Register New Machine"
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleCreateSubmit} className="modal-form">
|
||||
<p className="modal-description">
|
||||
Register a new machine to enable backup management. This will create a machine entry
|
||||
that you can later generate provisioning codes for.
|
||||
</p>
|
||||
|
||||
<Input
|
||||
label="Machine Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
error={formErrors.name}
|
||||
placeholder="Enter a descriptive name for your machine"
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ModalActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={closeCreateModal}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
Register Machine
|
||||
</Button>
|
||||
</ModalActions>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Provisioning Code Modal */}
|
||||
<Modal
|
||||
isOpen={showProvisioningModal}
|
||||
onClose={closeProvisioningModal}
|
||||
title="Generate Provisioning Code"
|
||||
size="md"
|
||||
>
|
||||
<div className="provisioning-modal">
|
||||
<p className="modal-description">
|
||||
Generate a provisioning code for <strong>{selectedMachine?.name}</strong>.
|
||||
This code can be used to register a client machine with the backup system.
|
||||
</p>
|
||||
|
||||
{!provisioningCode ? (
|
||||
<div className="provisioning-generate">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<QrCodeIcon size={16}/>}
|
||||
onClick={handleGenerateProvisioningCode}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
className="generate-button"
|
||||
>
|
||||
Generate Provisioning Code
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="provisioning-result">
|
||||
<div className="code-section">
|
||||
<label className="code-label">Provisioning Code</label>
|
||||
<div className="code-display">
|
||||
<Input
|
||||
value={provisioningCode.code}
|
||||
readOnly
|
||||
className="code-input"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
icon={<CopyIcon size={16}/>}
|
||||
onClick={() => handleCopyCode(provisioningCode.code)}
|
||||
className="copy-button"
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="code-info">
|
||||
<DetailList>
|
||||
<DetailItem icon={<ClockIcon size={16}/>}>
|
||||
Expires: {formatDate(provisioningCode.expires_at)}
|
||||
</DetailItem>
|
||||
<DetailItem icon={<IdentificationCardIcon size={16}/>}>
|
||||
Raw Code: {provisioningCode.raw_code}
|
||||
</DetailItem>
|
||||
</DetailList>
|
||||
</div>
|
||||
|
||||
<div className="code-notice">
|
||||
<Badge variant="warning">
|
||||
This code expires in 1 hour
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModalActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={closeProvisioningModal}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
{provisioningCode && (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<QrCodeIcon size={16}/>}
|
||||
onClick={handleGenerateProvisioningCode}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
Generate New Code
|
||||
</Button>
|
||||
)}
|
||||
</ModalActions>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user