From 8b1a9be8c29e2cb8644a2a03162e48aa7197eaf6 Mon Sep 17 00:00:00 2001 From: Mathias Wagner Date: Tue, 9 Sep 2025 19:08:59 +0200 Subject: [PATCH] Implement UI for machines & system settings --- webui/src/App.jsx | 4 + .../src/common/components/Sidebar/Sidebar.jsx | 4 +- webui/src/common/layouts/Root.jsx | 4 + webui/src/pages/Machines/Machines.jsx | 423 ++++++++++++++++++ webui/src/pages/Machines/index.js | 1 + webui/src/pages/Machines/styles.sass | 107 +++++ .../pages/SystemSettings/SystemSettings.jsx | 263 +++++++++++ webui/src/pages/SystemSettings/index.js | 1 + webui/src/pages/SystemSettings/styles.sass | 91 ++++ 9 files changed, 897 insertions(+), 1 deletion(-) create mode 100644 webui/src/pages/Machines/Machines.jsx create mode 100644 webui/src/pages/Machines/index.js create mode 100644 webui/src/pages/Machines/styles.sass create mode 100644 webui/src/pages/SystemSettings/SystemSettings.jsx create mode 100644 webui/src/pages/SystemSettings/index.js create mode 100644 webui/src/pages/SystemSettings/styles.sass diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 07e3794..bb56281 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -4,6 +4,8 @@ import {ToastProvider} from '@/common/contexts/ToastContext.jsx'; import "@/common/styles/main.sass"; import Root from "@/common/layouts/Root.jsx"; import UserManagement from "@/pages/UserManagement"; +import SystemSettings from "@/pages/SystemSettings"; +import Machines from "@/pages/Machines"; import "@fontsource/plus-jakarta-sans/300.css"; import "@fontsource/plus-jakarta-sans/400.css"; import "@fontsource/plus-jakarta-sans/600.css"; @@ -21,9 +23,11 @@ const App = () => { children: [ {path: "/", element: }, {path: "/dashboard", element: }, + {path: "/machines", element: }, {path: "/servers", element: }, {path: "/settings", element: }, {path: "/admin/users", element: }, + {path: "/admin/settings", element: }, ], }, ]); diff --git a/webui/src/common/components/Sidebar/Sidebar.jsx b/webui/src/common/components/Sidebar/Sidebar.jsx index 3f77d81..2c41f76 100644 --- a/webui/src/common/components/Sidebar/Sidebar.jsx +++ b/webui/src/common/components/Sidebar/Sidebar.jsx @@ -1,17 +1,19 @@ import React, { useContext } from 'react'; import { NavLink } from 'react-router-dom'; -import { HouseIcon, GearSixIcon, SquaresFourIcon, CubeIcon, UsersIcon } from '@phosphor-icons/react'; +import { HouseIcon, GearSixIcon, SquaresFourIcon, CubeIcon, UsersIcon, SlidersIcon, ComputerTowerIcon } from '@phosphor-icons/react'; import { UserContext } from '@/common/contexts/UserContext.jsx'; import './styles.sass'; const navItems = [ { to: '/dashboard', label: 'Dashboard', icon: }, + { to: '/machines', label: 'Machines', icon: }, { to: '/servers', label: 'Servers', icon: }, { to: '/settings', label: 'Settings', icon: }, ]; const adminNavItems = [ { to: '/admin/users', label: 'User Management', icon: }, + { to: '/admin/settings', label: 'System Settings', icon: }, ]; export const Sidebar = () => { diff --git a/webui/src/common/layouts/Root.jsx b/webui/src/common/layouts/Root.jsx index cfc367d..026a009 100644 --- a/webui/src/common/layouts/Root.jsx +++ b/webui/src/common/layouts/Root.jsx @@ -7,12 +7,16 @@ const getPageTitle = (pathname) => { switch (pathname) { case '/dashboard': return 'Dashboard'; + case '/machines': + return 'Machines'; case '/servers': return 'Servers'; case '/settings': return 'Settings'; case '/admin/users': return 'User Management'; + case '/admin/settings': + return 'System Settings'; default: return 'Dashboard'; } diff --git a/webui/src/pages/Machines/Machines.jsx b/webui/src/pages/Machines/Machines.jsx new file mode 100644 index 0000000..c05366a --- /dev/null +++ b/webui/src/pages/Machines/Machines.jsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+ } + onClick={openCreateModal} + > + Register Machine + + } + /> + + + {machines.map(machine => ( + + +
+
+ +
+
+

{machine.name}

+
+ + {formatUuid(machine.uuid)} +
+
+
+ + +
+
+
+ + + + }> + Owner: {currentUser?.role === 'admin' ? `User ${machine.user_id}` : 'You'} + + }> + Registered: {formatDate(machine.created_at)} + + + +
+ ))} +
+ + {machines.length === 0 && ( + } + title="No machines registered" + description="Register your first machine to get started with backup management" + action={ + + } + /> + )} + + {/* Create Machine Modal */} + +
+

+ Register a new machine to enable backup management. This will create a machine entry + that you can later generate provisioning codes for. +

+ + + + + + + +
+
+ + {/* Provisioning Code Modal */} + +
+

+ Generate a provisioning code for {selectedMachine?.name}. + This code can be used to register a client machine with the backup system. +

+ + {!provisioningCode ? ( +
+ +
+ ) : ( +
+
+ +
+ + +
+
+ +
+ + }> + Expires: {formatDate(provisioningCode.expires_at)} + + }> + Raw Code: {provisioningCode.raw_code} + + +
+ +
+ + This code expires in 1 hour + +
+
+ )} + + + + {provisioningCode && ( + + )} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/webui/src/pages/Machines/index.js b/webui/src/pages/Machines/index.js new file mode 100644 index 0000000..bdcae87 --- /dev/null +++ b/webui/src/pages/Machines/index.js @@ -0,0 +1 @@ +export {Machines as default} from './Machines.jsx'; diff --git a/webui/src/pages/Machines/styles.sass b/webui/src/pages/Machines/styles.sass new file mode 100644 index 0000000..ba31046 --- /dev/null +++ b/webui/src/pages/Machines/styles.sass @@ -0,0 +1,107 @@ +.machine-card + .machine-card-header + display: flex + align-items: flex-start + gap: 1rem + + .machine-icon + display: flex + align-items: center + justify-content: center + width: 3rem + height: 3rem + background: var(--color-primary-light) + border-radius: var(--border-radius) + color: var(--color-primary) + flex-shrink: 0 + + .machine-info + flex: 1 + min-width: 0 + + .machine-name + font-size: 1.125rem + font-weight: 600 + color: var(--color-text) + margin: 0 0 0.5rem 0 + word-break: break-word + + .machine-uuid + display: flex + align-items: center + gap: 0.25rem + font-size: 0.875rem + color: var(--color-text-muted) + + .uuid-text + font-family: var(--font-mono, 'Courier New', monospace) + background: var(--color-surface-variant) + padding: 0.125rem 0.375rem + border-radius: var(--border-radius-sm) + font-size: 0.75rem + + .machine-actions + display: flex + gap: 0.5rem + flex-shrink: 0 + +.modal-description + color: var(--color-text-muted) + font-size: 0.875rem + line-height: 1.5 + margin: 0 0 1.5rem 0 + +.provisioning-modal + .provisioning-generate + text-align: center + padding: 2rem 0 + + .generate-button + min-width: 200px + + .provisioning-result + .code-section + margin-bottom: 1.5rem + + .code-label + display: block + font-size: 0.875rem + font-weight: 600 + color: var(--color-text) + margin-bottom: 0.5rem + + .code-display + display: flex + gap: 0.5rem + align-items: stretch + + .code-input + flex: 1 + font-family: var(--font-mono, 'Courier New', monospace) + font-size: 0.875rem + + input + font-family: inherit + + .copy-button + flex-shrink: 0 + + .code-info + margin-bottom: 1rem + padding: 1rem + background: var(--color-surface-variant) + border-radius: var(--border-radius) + + .code-notice + text-align: center + +// Empty state overrides for machines +.content .empty-state + .empty-state-icon + color: var(--color-primary-light) + + .empty-state-title + color: var(--color-text) + + .empty-state-description + color: var(--color-text-muted) diff --git a/webui/src/pages/SystemSettings/SystemSettings.jsx b/webui/src/pages/SystemSettings/SystemSettings.jsx new file mode 100644 index 0000000..9de7ab7 --- /dev/null +++ b/webui/src/pages/SystemSettings/SystemSettings.jsx @@ -0,0 +1,263 @@ +import React, {useState, useEffect, useContext} from 'react'; +import {UserContext} from '@/common/contexts/UserContext.jsx'; +import {useToast} from '@/common/contexts/ToastContext.jsx'; +import {getRequest, postRequest} from '@/common/utils/RequestUtil.js'; +import Button from '@/common/components/Button'; +import Input from '@/common/components/Input'; +import Card, {CardHeader, CardBody} from '@/common/components/Card'; +import LoadingSpinner from '@/common/components/LoadingSpinner'; +import PageHeader from '@/common/components/PageHeader'; +import { + SlidersIcon, + FloppyDiskIcon, + ArrowClockwiseIcon, + InfoIcon, + WarningIcon +} from '@phosphor-icons/react'; +import './styles.sass'; + +export const SystemSettings = () => { + const toast = useToast(); + const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [formData, setFormData] = useState({}); + const [formErrors, setFormErrors] = useState({}); + const [submitting, setSubmitting] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + fetchConfigs(); + }, []); + + const fetchConfigs = async () => { + try { + setLoading(true); + const response = await getRequest('admin/config'); + setConfigs(response.configs); + + const initialData = {}; + response.configs.forEach(config => { + initialData[config.key] = config.value || config.default_value || ''; + }); + setFormData(initialData); + setHasChanges(false); + } catch (error) { + console.error('Failed to fetch configs:', error); + toast.error('Failed to load system settings. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleInputChange = (key, value) => { + setFormData(prev => { + const newData = {...prev, [key]: value}; + + const originalConfig = configs.find(c => c.key === key); + const hasChange = Object.keys(newData).some(k => { + const original = configs.find(c => c.key === k); + const origVal = original?.value || original?.default_value || ''; + return newData[k] !== origVal; + }); + + setHasChanges(hasChange); + return newData; + }); + + if (formErrors[key]) { + setFormErrors(prev => ({ + ...prev, + [key]: '' + })); + } + }; + + const validateForm = () => { + const errors = {}; + + configs.forEach(config => { + const value = formData[config.key] || ''; + + if (config.required && !value.trim()) { + errors[config.key] = `${config.key} is required`; + return; + } + + switch (config.key) { + case 'EXTERNAL_URL': + if (value && !value.startsWith('http://') && !value.startsWith('https://')) { + errors[config.key] = 'URL must start with http:// or https://'; + } + break; + case 'MAX_UPLOAD_SIZE_MB': + case 'BACKUP_RETENTION_DAYS': + case 'SESSION_TIMEOUT_HOURS': + if (value && (isNaN(value) || parseInt(value) <= 0)) { + errors[config.key] = 'Must be a positive number'; + } + break; + } + }); + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSave = async () => { + if (!validateForm()) { + return; + } + + setSubmitting(true); + + try { + const savePromises = []; + + for (const config of configs) { + const newValue = formData[config.key] || ''; + const originalValue = config.value || config.default_value || ''; + + if (newValue !== originalValue) { + savePromises.push( + postRequest('admin/config', { + key: config.key, + value: newValue + }) + ); + } + } + + await Promise.all(savePromises); + + toast.success('System settings saved successfully'); + setHasChanges(false); + await fetchConfigs(); + } catch (error) { + console.error('Failed to save settings:', error); + const errorMessage = error.error || 'Failed to save settings. Please try again.'; + toast.error(errorMessage); + } finally { + setSubmitting(false); + } + }; + + const handleReset = async () => { + if (!window.confirm('Are you sure you want to reset all settings to their current saved values? Any unsaved changes will be lost.')) { + return; + } + + await fetchConfigs(); + toast.info('Settings reset to saved values'); + }; + + const getFieldType = (key) => { + switch (key) { + case 'SESSION_TIMEOUT_HOURS': + return 'number'; + case 'EXTERNAL_URL': + return 'url'; + default: + return 'text'; + } + }; + + const getFieldPlaceholder = (config) => { + if (config.default_value) { + return `Default: ${config.default_value}`; + } + return `Enter ${config.key.toLowerCase().replace(/_/g, ' ')}`; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + +
+ } + /> + + {hasChanges && ( +
+ + You have unsaved changes +
+ )} + +
+ {configs.map(config => ( + + +
+
+

+ {config.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + {config.required && *} +

+

{config.description}

+
+ {config.default_value && ( +
+ + Default: {config.default_value} +
+ )} +
+
+ + + handleInputChange(config.key, e.target.value)} + placeholder={getFieldPlaceholder(config)} + error={formErrors[config.key]} + disabled={submitting} + className="setting-input" + /> + +
+ ))} +
+ + {configs.length === 0 && ( + + +
+ +

No settings available

+

No configurable settings are currently defined.

+
+
+
+ )} + + ); +}; \ No newline at end of file diff --git a/webui/src/pages/SystemSettings/index.js b/webui/src/pages/SystemSettings/index.js new file mode 100644 index 0000000..3689258 --- /dev/null +++ b/webui/src/pages/SystemSettings/index.js @@ -0,0 +1 @@ +export { SystemSettings as default } from './SystemSettings.jsx'; \ No newline at end of file diff --git a/webui/src/pages/SystemSettings/styles.sass b/webui/src/pages/SystemSettings/styles.sass new file mode 100644 index 0000000..9cce915 --- /dev/null +++ b/webui/src/pages/SystemSettings/styles.sass @@ -0,0 +1,91 @@ +.settings-actions + display: flex + gap: 0.5rem + align-items: center + +.settings-notice + display: flex + align-items: center + gap: 0.5rem + padding: 1rem + background: var(--color-warning-light) + border: 1px solid var(--color-warning) + border-radius: var(--border-radius) + color: var(--color-warning-dark) + font-size: 0.875rem + margin-bottom: 1.5rem + + svg + flex-shrink: 0 + +.settings-grid + display: grid + gap: 1.5rem + grid-template-columns: 1fr + + @media (min-width: 768px) + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)) + +.setting-card + .setting-header + display: flex + justify-content: space-between + align-items: flex-start + gap: 1rem + + .setting-info + flex: 1 + + .setting-title + font-size: 1rem + font-weight: 600 + color: var(--color-text) + margin: 0 0 0.5rem 0 + display: flex + align-items: center + gap: 0.25rem + + .required + color: var(--color-danger) + font-size: 0.875rem + + .setting-description + font-size: 0.875rem + color: var(--color-text-muted) + margin: 0 + line-height: 1.4 + + .setting-default + display: flex + align-items: center + gap: 0.25rem + font-size: 0.75rem + color: var(--color-text-muted) + background: var(--color-surface-variant) + padding: 0.25rem 0.5rem + border-radius: var(--border-radius-sm) + white-space: nowrap + + svg + flex-shrink: 0 + + .setting-input + margin: 0 + +.empty-settings + text-align: center + padding: 2rem + color: var(--color-text-muted) + + svg + color: var(--color-text-disabled) + margin-bottom: 1rem + + h3 + margin: 0 0 0.5rem 0 + font-size: 1.125rem + color: var(--color-text) + + p + margin: 0 + font-size: 0.875rem