fix: silent autosave, pending dedup, domain/cell_name pending, containers access

- Settings: remove Save buttons; autosave is silent (no toast on success, error only)
- Settings: loadAll() resets dirty flags to prevent stale autosave after discard
- app.py: fix domain/ip_range "actually changed" check — full identity is always
  sent on save so these were triggering pending on every keystroke regardless
- app.py: _dedup_changes handles port-change format "service field: old → new"
  (split on ':' not ' changed') so dns_port changed twice shows one entry
- app.py: domain + cell_name changes now go through pending restart banner;
  apply_domain/apply_cell_name write files immediately (reload=False) and set
  pending; Discard restores zone files + Caddyfile to pre-change state
- app.py: _set_pending_restart captures pre-change snapshot BEFORE config writes
  (was snapshotting after, making Discard a no-op)
- app.py: is_local_request reads /proc/net/route to allow the actual Docker
  bridge subnet (172.0.0.0/24) which is not RFC-1918; fixes Containers page 403
- container_manager: get_container_logs raises instead of swallowing exceptions
  so nonexistent container returns 500+error not 200+empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 07:16:13 -04:00
parent 4215e03ac6
commit 2bd6545f0e
5 changed files with 184 additions and 103 deletions
+36 -54
View File
@@ -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() {
/>
</Field>
</div>
<div className="flex justify-end mt-4">
<button
onClick={saveIdentity}
disabled={!identityDirty || identitySaving || !!ipRangeError || !!cellNameError || !!domainError}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{identitySaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Identity
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
IP Range and port changes update the .env file and mark affected containers for restart.
Use the banner above to apply when ready.
</p>
</Section>
{/* 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 (
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} errors={errors} />
<div className="flex items-center justify-between mt-4">
<span className="text-xs text-gray-400">Port/directory changes take effect after container restart.</span>
<button
onClick={() => saveService(key)}
disabled={!dirty || saving || hasErrors}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</button>
</div>
</Section>
);
})}