Files
Arkendro/webui/src/pages/Machines/Machines.jsx
2025-09-09 22:23:01 +02:00

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>
);
};