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 (