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:
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user