439 lines
16 KiB
JavaScript
439 lines
16 KiB
JavaScript
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';
|
|
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 navigate = useNavigate();
|
|
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 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 => ({
|
|
...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"
|
|
onClick={() => handleMachineClick(machine.id)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<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" onClick={handleActionClick}>
|
|
<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>
|
|
);
|
|
}; |