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 */}
+
+
+
+
+ {/* 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