diff --git a/api/app.py b/api/app.py index bb47520..c4019dc 100644 --- a/api/app.py +++ b/api/app.py @@ -319,6 +319,26 @@ def health_monitor_loop(): health_monitor_thread = threading.Thread(target=health_monitor_loop, daemon=True) health_monitor_thread.start() +def _local_subnets(): + """Return all subnets the container is directly connected to (from routing table).""" + import ipaddress as _ipa, socket as _sock, struct as _struct + nets = [] + try: + with open('/proc/net/route') as _f: + for _line in _f.readlines()[1:]: + _parts = _line.strip().split() + if len(_parts) < 8 or _parts[0] == 'lo': + continue + _dest = _sock.inet_ntoa(_struct.pack(' dict: return ports -def _set_pending_restart(changes: list, containers: list = None, network_recreate: bool = False): +def _dedup_changes(existing: list, new: list) -> list: + """Merge change lists, keeping only the latest entry per config key.""" + def key_of(msg: str) -> str: + # "ip_range changed to X" → "ip_range" + if ' changed' in msg: + return msg.split(' changed')[0].strip() + # "network dns_port: 52 → 53" → "network dns_port" + if ':' in msg: + return msg.split(':')[0].strip() + return msg + merged = {key_of(c): c for c in existing} + merged.update({key_of(c): c for c in new}) + return list(merged.values()) + + +def _set_pending_restart(changes: list, containers: list = None, network_recreate: bool = False, + pre_change_snapshot: dict = None): """Record that specific containers need to be restarted to apply configuration. containers: list of docker-compose service names, or None/'*' to restart all. network_recreate: True when the Docker bridge subnet changed (requires down+up). + pre_change_snapshot: full config captured BEFORE this save (for Discard to revert). Merges with any existing pending state so multiple changes accumulate. """ from datetime import datetime as _dt @@ -729,6 +789,13 @@ def _set_pending_restart(changes: list, containers: list = None, network_recreat existing_changes = existing.get('changes', []) if existing.get('needs_restart') else [] existing_containers = existing.get('containers', []) if existing.get('needs_restart') else [] + # Keep the oldest snapshot (the true pre-change state). Never overwrite it with a + # later snapshot — subsequent changes while pending should still revert to origin. + if not existing.get('needs_restart'): + snapshot = pre_change_snapshot or {} + else: + snapshot = existing.get('_snapshot', {}) + if containers is None or '*' in (containers or []) or existing_containers == ['*']: new_containers = ['*'] else: @@ -737,9 +804,10 @@ def _set_pending_restart(changes: list, containers: list = None, network_recreat config_manager.configs['_pending_restart'] = { 'needs_restart': True, 'changed_at': _dt.utcnow().isoformat(), - 'changes': existing_changes + changes, + 'changes': _dedup_changes(existing_changes, changes), 'containers': new_containers, 'network_recreate': network_recreate or existing.get('network_recreate', False), + '_snapshot': snapshot, } config_manager._save_all_configs() @@ -765,7 +833,37 @@ def get_pending_config(): @app.route('/api/config/pending', methods=['DELETE']) def cancel_pending_config(): - """Discard pending configuration changes without restarting any containers.""" + """Discard pending configuration changes and restore config to pre-change snapshot.""" + pending = config_manager.configs.get('_pending_restart', {}) + snapshot = pending.get('_snapshot', {}) + if snapshot: + # Capture current (changed) identity before reverting, to rewrite config files + cur_identity = dict(config_manager.configs.get('_identity', {})) + old_identity = snapshot.get('_identity', {}) + + # Restore config values from snapshot + for k, v in snapshot.items(): + config_manager.configs[k] = v + + # Rewrite DNS/Caddy config files back to old values so they match restored config + import ip_utils as _ip_revert + _id = config_manager.configs.get('_identity', {}) + _range = _id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')) + _cell = _id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')) + _dom = _id.get('domain', os.environ.get('CELL_DOMAIN', 'cell')) + + cur_domain = cur_identity.get('domain', '') + old_domain = old_identity.get('domain', '') + if cur_domain and old_domain and cur_domain != old_domain: + network_manager.apply_domain(old_domain, reload=False) + + cur_cell_name = cur_identity.get('cell_name', '') + old_cell_name = old_identity.get('cell_name', '') + if cur_cell_name and old_cell_name and cur_cell_name != old_cell_name: + network_manager.apply_cell_name(cur_cell_name, old_cell_name, reload=False) + + _ip_revert.write_caddyfile(_range, _cell, _dom, '/app/config/caddy/Caddyfile') + _clear_pending_restart() return jsonify({'message': 'Pending changes discarded'}) diff --git a/api/container_manager.py b/api/container_manager.py index 98f1d88..29047eb 100644 --- a/api/container_manager.py +++ b/api/container_manager.py @@ -283,15 +283,10 @@ class ContainerManager(BaseServiceManager): def get_container_logs(self, name: str, tail: int = 100) -> str: """Get container logs""" - try: - if not self.client: - return "Docker client not available" - - container = self.client.containers.get(name) - return container.logs(tail=tail).decode('utf-8') - except Exception as e: - logger.error(f"Error getting logs for container {name}: {e}") - return str(e) + if not self.client: + raise RuntimeError("Docker client not available") + container = self.client.containers.get(name) + return container.logs(tail=tail).decode('utf-8') def get_container_stats(self, name: str) -> dict: """Get container statistics""" diff --git a/api/network_manager.py b/api/network_manager.py index 4bed0dd..a64a76e 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -5,6 +5,7 @@ Handles DNS, DHCP, and NTP functionality """ import os +import re import json import subprocess import logging @@ -383,8 +384,11 @@ class NetworkManager(BaseServiceManager): return {'restarted': restarted, 'warnings': warnings} - def apply_domain(self, domain: str) -> Dict[str, Any]: - """Update domain across dnsmasq, Corefile, and zone file; reload DNS + DHCP.""" + def apply_domain(self, domain: str, reload: bool = True) -> Dict[str, Any]: + """Update domain across dnsmasq, Corefile, and zone file; reload DNS + DHCP. + + reload=False writes config files only — use when deferring container restart. + """ restarted = [] warnings = [] @@ -400,8 +404,9 @@ class NetworkManager(BaseServiceManager): ] with open(dhcp_conf, 'w') as f: f.writelines(lines) - self._reload_dhcp_service() - restarted.append('cell-dhcp (reloaded)') + if reload: + self._reload_dhcp_service() + restarted.append('cell-dhcp (reloaded)') except Exception as e: warnings.append(f"dnsmasq domain update failed: {e}") @@ -424,8 +429,6 @@ class NetworkManager(BaseServiceManager): dns_data = os.path.join(self.data_dir, 'dns') if os.path.isdir(dns_data): dst = os.path.join(dns_data, f'{domain}.zone') - # Find the best source: prefer a non-target zone (old domain) so we - # can migrate its content; fall back to the target zone itself. zone_files = [ os.path.join(dns_data, f) for f in os.listdir(dns_data) @@ -443,7 +446,6 @@ class NetworkManager(BaseServiceManager): r'^\$ORIGIN\s+\S+', f'$ORIGIN {domain}.', zone_content, flags=re.MULTILINE) with open(dst, 'w') as f: f.write(zone_content) - # Remove every zone file that is not the current domain's file for zone_path in zone_files: if zone_path != dst: try: @@ -453,17 +455,21 @@ class NetworkManager(BaseServiceManager): except Exception as e: warnings.append(f"zone file domain update failed: {e}") - # 4. Reload CoreDNS - try: - self._reload_dns_service() - restarted.append('cell-dns (reloaded)') - except Exception as e: - warnings.append(f"CoreDNS reload failed: {e}") + # 4. Reload CoreDNS (only when not deferring to Apply) + if reload: + try: + self._reload_dns_service() + restarted.append('cell-dns (reloaded)') + except Exception as e: + warnings.append(f"CoreDNS reload failed: {e}") return {'restarted': restarted, 'warnings': warnings} - def apply_cell_name(self, old_name: str, new_name: str) -> Dict[str, Any]: - """Update the cell hostname record in the primary DNS zone file.""" + def apply_cell_name(self, old_name: str, new_name: str, reload: bool = True) -> Dict[str, Any]: + """Update the cell hostname record in the primary DNS zone file. + + reload=False writes the zone file only — use when deferring container restart. + """ restarted = [] warnings = [] if not old_name or not new_name or old_name == new_name: @@ -476,8 +482,6 @@ class NetworkManager(BaseServiceManager): zone_file = os.path.join(dns_data, fname) with open(zone_file) as f: content = f.read() - # Replace hostname record: old_name IN A ... - import re content = re.sub( rf'^{re.escape(old_name)}(\s+IN\s+A\s+)', f'{new_name}\\1', @@ -486,8 +490,9 @@ class NetworkManager(BaseServiceManager): with open(zone_file, 'w') as f: f.write(content) break - self._reload_dns_service() - restarted.append('cell-dns (reloaded)') + if reload: + self._reload_dns_service() + restarted.append('cell-dns (reloaded)') except Exception as e: warnings.append(f"cell_name DNS update failed: {e}") return {'restarted': restarted, 'warnings': warnings} diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 1deb2c9..030180a 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -215,6 +215,7 @@ function AppCore() { const handleCancel = useCallback(async () => { await cellAPI.cancelPending(); setPending({ needs_restart: false, changes: [] }); + window.dispatchEvent(new CustomEvent('pic-config-discarded')); }, []); const navigation = [ diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index ed192ed..0a59b05 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -4,7 +4,7 @@ import { useDraftConfig } from '../contexts/DraftConfigContext'; import { Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, - Save, ChevronDown, ChevronRight, CheckCircle, XCircle, AlertCircle, + ChevronDown, ChevronRight, CheckCircle, XCircle, RefreshCw, Lock } from 'lucide-react'; import { cellAPI } from '../services/api'; @@ -402,12 +402,10 @@ function Settings() { // identity const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' }); const [identityDirty, setIdentityDirty] = useState(false); - const [identitySaving, setIdentitySaving] = useState(false); // service configs const [serviceConfigs, setServiceConfigs] = useState({}); const [serviceDirty, setServiceDirty] = useState({}); - const [serviceSaving, setServiceSaving] = useState({}); const portConflicts = useMemo(() => detectPortConflicts(serviceConfigs), [serviceConfigs]); @@ -431,7 +429,9 @@ function Settings() { domain: cfg.domain || '', ip_range: cfg.ip_range || '', }); + setIdentityDirty(false); setServiceConfigs(cfg.service_configs || {}); + setServiceDirty({}); setBackups(bkRes.data || []); } catch (err) { toast('Failed to load configuration', 'error'); @@ -442,15 +442,11 @@ function Settings() { useEffect(() => { loadAll(); }, [loadAll]); - const _applyResult = (res, label) => { - const { restarted = [], warnings = [] } = res.data || {}; - if (restarted.length > 0) { - toast(`${label} saved — restarted: ${restarted.join(', ')}`); - } else { - toast(`${label} saved`); - } - warnings.forEach((w) => toast(w, 'warning')); - }; + useEffect(() => { + const handler = () => loadAll(); + window.addEventListener('pic-config-discarded', handler); + return () => window.removeEventListener('pic-config-discarded', handler); + }, [loadAll]); // identity save const ipRangeError = identity.ip_range && !isRFC1918Cidr(identity.ip_range) @@ -465,42 +461,34 @@ function Settings() { ? 'Domain must be 255 characters or fewer' : (!identity.domain ? 'Domain is required' : null); - const saveIdentity = async () => { + const saveIdentity = useCallback(async () => { if (ipRangeError || cellNameError || domainError) return; - setIdentitySaving(true); try { - const res = await cellAPI.updateConfig(identity); + await cellAPI.updateConfig(identity); setIdentityDirty(false); draftConfig?.setDirty('identity', false); - _applyResult(res, 'Cell identity'); refreshConfig(); } catch (err) { toast(err.response?.data?.error || 'Failed to save identity', 'error'); - } finally { - setIdentitySaving(false); } - }; + }, [identity, ipRangeError, cellNameError, domainError, draftConfig, refreshConfig]); // service config save - const saveService = async (key) => { + const saveService = useCallback(async (key) => { const { defaults } = SERVICE_DEFS.find((d) => d.key === key) || {}; const data = { ...(defaults || {}), ...(serviceConfigs[key] || {}) }; const hasFieldErrors = Object.keys(validateServiceConfig(key, data)).length > 0; const hasConflicts = (PORT_CONFLICT_FIELDS[key] || []).some(f => portConflicts[`${key}|${f}`]); if (hasFieldErrors || hasConflicts) return; - setServiceSaving((s) => ({ ...s, [key]: true })); try { - const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); + await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); setServiceDirty((d) => ({ ...d, [key]: false })); draftConfig?.setDirty(key, false); - _applyResult(res, key); refreshConfig(); } catch (err) { toast(err.response?.data?.error || `Failed to save ${key} config`, 'error'); - } finally { - setServiceSaving((s) => ({ ...s, [key]: false })); } - }; + }, [serviceConfigs, portConflicts, draftConfig, refreshConfig]); const updateServiceConfig = (key, data) => { setServiceConfigs((prev) => ({ ...prev, [key]: data })); @@ -541,6 +529,28 @@ function Settings() { }, [draftConfig]); // ───────────────────────────────────────────────────────────────────────── + // ── Debounced auto-save ─────────────────────────────────────────────────── + useEffect(() => { + if (!identityDirty) return; + if (ipRangeError || cellNameError || domainError) return; + const timer = setTimeout(() => saveIdentityRef.current(), 800); + return () => clearTimeout(timer); + }, [identity, identityDirty, ipRangeError, cellNameError, domainError]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const timers = SERVICE_DEFS + .filter(({ key }) => serviceDirty[key]) + .filter(({ key, defaults }) => { + const data = { ...defaults, ...(serviceConfigs[key] || {}) }; + const hasFieldErrors = Object.keys(validateServiceConfig(key, data)).length > 0; + const hasConflicts = (PORT_CONFLICT_FIELDS[key] || []).some(f => portConflicts[`${key}|${f}`]); + return !hasFieldErrors && !hasConflicts; + }) + .map(({ key }) => setTimeout(() => saveServiceRef.current(key), 800)); + return () => timers.forEach(clearTimeout); + }, [serviceConfigs, serviceDirty, portConflicts]); // eslint-disable-line react-hooks/exhaustive-deps + // ───────────────────────────────────────────────────────────────────────── + // backups const createBackup = async () => { setBackupCreating(true); @@ -654,20 +664,6 @@ function Settings() { /> -
- -
-

- IP Range and port changes update the .env file and mark affected containers for restart. - Use the banner above to apply when ready. -

{/* Service Configurations */} @@ -682,23 +678,9 @@ function Settings() { if (msg) conflictErrors[field] = msg; } const errors = { ...validateServiceConfig(key, data), ...conflictErrors }; - const hasErrors = Object.keys(errors).length > 0; - const dirty = serviceDirty[key]; - const saving = serviceSaving[key]; return (
updateServiceConfig(key, d)} errors={errors} /> -
- Port/directory changes take effect after container restart. - -
); })}