diff --git a/api/app.py b/api/app.py index 7b56619..1e0ccb0 100644 --- a/api/app.py +++ b/api/app.py @@ -373,13 +373,14 @@ def get_config(): """Get cell configuration.""" try: service_configs = config_manager.get_all_configs() + identity = service_configs.pop('_identity', {}) config = { - 'cell_name': os.environ.get('CELL_NAME', 'mycell'), - 'domain': os.environ.get('CELL_DOMAIN', 'cell'), - 'ip_range': os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'), - 'wireguard_port': int(os.environ.get('WG_PORT', '51820')), + 'cell_name': identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), + 'domain': identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')), + 'ip_range': identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), + 'wireguard_port': identity.get('wireguard_port', int(os.environ.get('WG_PORT', '51820'))), } - config.update(service_configs) + config['service_configs'] = service_configs return jsonify(config) except Exception as e: logger.error(f"Error getting config: {e}") @@ -392,18 +393,26 @@ def update_config(): data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 - - # Update configuration using config manager + + # Handle identity fields (cell_name, domain, ip_range, wireguard_port) + identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'} + identity_updates = {k: v for k, v in data.items() if k in identity_keys} + if identity_updates: + stored = config_manager.configs.get('_identity', {}) + stored.update(identity_updates) + config_manager.configs['_identity'] = stored + config_manager._save_all_configs() + + # Update service configurations for service, config in data.items(): if service in config_manager.service_schemas: success = config_manager.update_service_config(service, config) if success: - # Publish config change event service_bus.publish_event(EventType.CONFIG_CHANGED, service, { 'service': service, 'config': config }) - + logger.info(f"Updated config: {data}") return jsonify({"message": "Configuration updated successfully"}) except Exception as e: @@ -483,6 +492,19 @@ def import_config(): logger.error(f"Error importing config: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/config/backups/', methods=['DELETE']) +def delete_config_backup(backup_id): + """Delete a configuration backup.""" + try: + success = config_manager.delete_backup(backup_id) + if success: + return jsonify({"message": f"Backup {backup_id} deleted"}) + else: + return jsonify({"error": f"Failed to delete backup {backup_id}"}), 500 + except Exception as e: + logger.error(f"Error deleting backup: {e}") + return jsonify({"error": str(e)}), 500 + # Service bus endpoints @app.route('/api/services/bus/status', methods=['GET']) def get_service_bus_status(): diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index 3befd23..3cc6d9c 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -1,98 +1,589 @@ -import { useState, useEffect } from 'react'; -import { Settings as SettingsIcon, Server, Shield } from 'lucide-react'; -import { cellAPI } from '../services/api'; - -function Settings() { - const [config, setConfig] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchConfig(); - }, []); - - const fetchConfig = async () => { - try { - const response = await cellAPI.getConfig(); - setConfig(response.data); - } catch (error) { - console.error('Failed to fetch config:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Settings

-

- Configure your Personal Internet Cell -

-
- -
- {/* Cell Configuration */} -
-
- -

Cell Configuration

-
- {config ? ( -
-
- Cell Name: - {config.cell_name} -
-
- Domain: - {config.domain} -
-
- IP Range: - {config.ip_range} -
-
- WireGuard Port: - {config.wireguard_port} -
-
- ) : ( -

Configuration unavailable

- )} -
- - {/* Security Settings */} -
-
- -

Security Settings

-
-
-
- TLS Certificate: - Valid -
-
- Firewall: - Active -
-
- VPN Encryption: - Enabled -
-
-
-
-
- ); -} - -export default Settings; \ No newline at end of file +import { useState, useEffect, useCallback } from 'react'; +import { + Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, + HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, + Save, ChevronDown, ChevronRight, CheckCircle, XCircle, AlertCircle, + RefreshCw, Lock +} from 'lucide-react'; +import { cellAPI } from '../services/api'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function toast(msg, type = 'success') { + // simple inline notification via a thrown CustomEvent consumed below + window.dispatchEvent(new CustomEvent('settings-toast', { detail: { msg, type } })); +} + +function Toast({ toasts }) { + return ( +
+ {toasts.map((t) => ( +
+ {t.type === 'success' ? : } + {t.msg} +
+ ))} +
+ ); +} + +function useToasts() { + const [toasts, setToasts] = useState([]); + useEffect(() => { + const handler = (e) => { + const id = Date.now(); + setToasts((prev) => [...prev, { ...e.detail, id }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); + }; + window.addEventListener('settings-toast', handler); + return () => window.removeEventListener('settings-toast', handler); + }, []); + return toasts; +} + +// ── Section wrapper ─────────────────────────────────────────────────────────── + +function Section({ icon: Icon, title, children, collapsible = false, defaultOpen = true }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {(!collapsible || open) &&
{children}
} +
+ ); +} + +// ── Field components ────────────────────────────────────────────────────────── + +function Field({ label, children, hint }) { + return ( +
+ +
{children}
+ {hint && {hint}} +
+ ); +} + +function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) { + return ( + onChange && onChange(e.target.value)} + placeholder={placeholder} + readOnly={readOnly} + className={`w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 ${ + readOnly ? 'bg-gray-50 text-gray-500 cursor-default' : 'bg-white' + }`} + /> + ); +} + +function NumberInput({ value, onChange, min, max }) { + return ( + onChange && onChange(Number(e.target.value))} + className="w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 bg-white" + /> + ); +} + +function BoolToggle({ value, onChange, label }) { + return ( + + ); +} + +function TagList({ value = [], onChange, placeholder }) { + const [input, setInput] = useState(''); + const add = () => { + const v = input.trim(); + if (v && !value.includes(v)) { onChange([...value, v]); setInput(''); } + }; + return ( +
+
+ {value.map((item) => ( + + {item} + + + ))} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())} + placeholder={placeholder} + /> + +
+
+ ); +} + +// ── Service config forms ────────────────────────────────────────────────────── + +function NetworkForm({ data, onChange }) { + return ( +
+ + onChange({ ...data, dns_port: v })} min={1} max={65535} /> + + + onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" /> + + + onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" /> + +
+ ); +} + +function WireguardForm({ data, onChange }) { + return ( +
+ + onChange({ ...data, port: v })} min={1} max={65535} /> + + + onChange({ ...data, address: v })} placeholder="10.0.0.1/24" /> + + + onChange({ ...data, private_key: v })} placeholder="base64 private key" type="password" /> + +
+ ); +} + +function EmailForm({ data, onChange }) { + return ( +
+ + onChange({ ...data, domain: v })} placeholder="mail.example.com" /> + + + onChange({ ...data, smtp_port: v })} min={1} max={65535} /> + + + onChange({ ...data, imap_port: v })} min={1} max={65535} /> + +
+ ); +} + +function CalendarForm({ data, onChange }) { + return ( +
+ + onChange({ ...data, port: v })} min={1} max={65535} /> + + + onChange({ ...data, data_dir: v })} placeholder="/app/data/radicale" /> + +
+ ); +} + +function FilesForm({ data, onChange }) { + return ( +
+ + onChange({ ...data, port: v })} min={1} max={65535} /> + + + onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" /> + + + onChange({ ...data, quota: v })} min={0} /> + +
+ ); +} + +function RoutingForm({ data, onChange }) { + return ( +
+ + onChange({ ...data, nat_enabled: v })} label="NAT Enabled" /> + + + onChange({ ...data, firewall_enabled: v })} label="Firewall Enabled" /> + +
+ ); +} + +function VaultForm({ data, onChange }) { + return ( +
+ + onChange({ ...data, ca_configured: v })} label="CA Configured" /> + + + onChange({ ...data, fernet_configured: v })} label="Fernet Encryption Configured" /> + +
+ ); +} + +// service config meta +const SERVICE_DEFS = [ + { key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } }, + { key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } }, + { key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 587, imap_port: 993 } }, + { key: 'calendar', label: 'Calendar (CalDAV)', icon: Calendar, Form: CalendarForm, defaults: { port: 5232, data_dir: '' } }, + { key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 80, data_dir: '', quota: 1024 } }, + { key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } }, + { key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } }, +]; + +// ── Main component ──────────────────────────────────────────────────────────── + +function Settings() { + const toasts = useToasts(); + + // identity + const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '', wireguard_port: 51820 }); + const [identityDirty, setIdentityDirty] = useState(false); + const [identitySaving, setIdentitySaving] = useState(false); + + // service configs + const [serviceConfigs, setServiceConfigs] = useState({}); + const [serviceDirty, setServiceDirty] = useState({}); + const [serviceSaving, setServiceSaving] = useState({}); + + // backups + const [backups, setBackups] = useState([]); + const [backupsLoading, setBackupsLoading] = useState(false); + const [backupCreating, setBackupCreating] = useState(false); + + const [isLoading, setIsLoading] = useState(true); + + const loadAll = useCallback(async () => { + setIsLoading(true); + try { + const [cfgRes, bkRes] = await Promise.all([ + cellAPI.getConfig(), + cellAPI.listBackups(), + ]); + const cfg = cfgRes.data; + setIdentity({ + cell_name: cfg.cell_name || '', + domain: cfg.domain || '', + ip_range: cfg.ip_range || '', + wireguard_port: cfg.wireguard_port || 51820, + }); + setServiceConfigs(cfg.service_configs || {}); + setBackups(bkRes.data || []); + } catch (err) { + toast('Failed to load configuration', 'error'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { loadAll(); }, [loadAll]); + + // identity save + const saveIdentity = async () => { + setIdentitySaving(true); + try { + await cellAPI.updateConfig(identity); + setIdentityDirty(false); + toast('Cell identity saved'); + } catch { + toast('Failed to save identity', 'error'); + } finally { + setIdentitySaving(false); + } + }; + + // service config save + const saveService = async (key) => { + setServiceSaving((s) => ({ ...s, [key]: true })); + try { + await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); + setServiceDirty((d) => ({ ...d, [key]: false })); + toast(`${key} configuration saved`); + } catch { + toast(`Failed to save ${key} config`, 'error'); + } finally { + setServiceSaving((s) => ({ ...s, [key]: false })); + } + }; + + const updateServiceConfig = (key, data) => { + setServiceConfigs((prev) => ({ ...prev, [key]: data })); + setServiceDirty((d) => ({ ...d, [key]: true })); + }; + + // backups + const createBackup = async () => { + setBackupCreating(true); + try { + await cellAPI.createBackup(); + toast('Backup created'); + const res = await cellAPI.listBackups(); + setBackups(res.data || []); + } catch { + toast('Failed to create backup', 'error'); + } finally { + setBackupCreating(false); + } + }; + + const restoreBackup = async (id) => { + if (!confirm(`Restore backup ${id}? Current config will be overwritten.`)) return; + try { + await cellAPI.restoreBackup(id); + toast('Configuration restored — reloading…'); + setTimeout(() => loadAll(), 500); + } catch { + toast('Failed to restore backup', 'error'); + } + }; + + const deleteBackup = async (id) => { + if (!confirm(`Delete backup ${id}?`)) return; + try { + await cellAPI.deleteBackup(id); + setBackups((prev) => prev.filter((b) => b.backup_id !== id)); + toast('Backup deleted'); + } catch { + toast('Failed to delete backup', 'error'); + } + }; + + // export + const exportConfig = async () => { + try { + const res = await cellAPI.exportConfig('json'); + const blob = new Blob([res.data.config], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `pic-config-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast('Export failed', 'error'); + } + }; + + // import + const importConfig = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const text = await file.text(); + if (!confirm('Import this config? Current settings will be replaced.')) { e.target.value = ''; return; } + try { + await cellAPI.importConfig(text, 'json'); + toast('Config imported — reloading…'); + setTimeout(() => loadAll(), 500); + } catch { + toast('Import failed', 'error'); + } finally { + e.target.value = ''; + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+

Settings

+

Configure your Personal Internet Cell

+
+ + {/* Cell Identity */} +
+
+ + { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); }} + placeholder="mycell" + /> + + + { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); }} + placeholder="cell.local" + /> + + + { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }} + placeholder="172.20.0.0/16" + /> + + + { setIdentity((i) => ({ ...i, wireguard_port: v })); setIdentityDirty(true); }} + min={1} max={65535} + /> + +
+
+ +
+

+ Note: IP Range and WireGuard Port are also set via environment variables in docker-compose.yml. + Changes here are stored in config and take effect on next container start. +

+
+ + {/* Service Configurations */} +
+

Service Configuration

+
+ {SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => { + const data = { ...defaults, ...(serviceConfigs[key] || {}) }; + const dirty = serviceDirty[key]; + const saving = serviceSaving[key]; + return ( +
+
updateServiceConfig(key, d)} /> +
+ +
+
+ ); + })} + + {/* Backup & Restore */} +
+
+ {backups.length} backup{backups.length !== 1 ? 's' : ''} stored + +
+ {backups.length === 0 ? ( +

No backups yet

+ ) : ( +
+ + + + + + + + + + {backups.map((b) => ( + + + + + + + ))} + +
Backup IDTimestampServices +
{b.backup_id}{new Date(b.timestamp).toLocaleString()}{(b.services || []).length} services +
+ + +
+
+
+ )} +
+ + {/* Export / Import */} +
+
+ + +
+

+ Export downloads all service configs as JSON. Import replaces current service configs. +

+
+
+ ); +} + +export default Settings; diff --git a/webui/src/services/api.js b/webui/src/services/api.js index c4c9045..a87ae04 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -37,6 +37,12 @@ export const cellAPI = { getStatus: () => api.get('/api/status'), getConfig: () => api.get('/api/config'), updateConfig: (config) => api.put('/api/config', config), + createBackup: () => api.post('/api/config/backup'), + listBackups: () => api.get('/api/config/backups'), + restoreBackup: (id) => api.post(`/api/config/restore/${id}`), + deleteBackup: (id) => api.delete(`/api/config/backups/${id}`), + exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }), + importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }), }; // Network Services API