feat: improve setup wizard and DDNS UX
Unit Tests / test (push) Successful in 7m29s

Setup wizard (Issue 1 — UI):
- pic.ngo subdomain input now uses the same split-field style as DuckDNS:
  input + static '.pic.ngo' suffix in a flex row, availability status below

Setup wizard (Issue 2 — Caddy not regenerating after completion):
- complete_setup route now fires IDENTITY_CHANGED after a successful wizard
  submission so CaddyManager regenerates the Caddyfile immediately; users
  no longer need to press 'Renew Certificate' to start ACME

Settings — DDNS status (Issue 2 — domain status missing):
- New GET /api/ddns/status endpoint: returns registered flag, domain_name,
  public_ip (ipify with 30s cache), last_ip from heartbeat
- Settings DDNS section for pic_ngo now shows a live status row with
  color-coded dot (green=registered+current, yellow=registered+stale,
  gray=not registered), current public IP, and a Check button
- Status auto-refreshes on mount and after each successful re-registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 00:36:47 -04:00
parent fb0326dae7
commit 40f9d90fad
5 changed files with 114 additions and 14 deletions
+36
View File
@@ -586,6 +586,42 @@ def update_ddns_config():
return jsonify({'error': str(e)}), 500
_ddns_public_ip_cache: dict = {'ip': None, 'at': 0}
@bp.route('/api/ddns/status', methods=['GET'])
def ddns_status():
import time as _time
from app import config_manager
ddns_cfg = config_manager.configs.get('ddns', {})
identity = config_manager.configs.get('_identity', {})
now = _time.time()
if now - _ddns_public_ip_cache['at'] > 30 or not _ddns_public_ip_cache['ip']:
try:
import requests as _req
resp = _req.get('https://api.ipify.org', timeout=5)
if resp.ok:
_ddns_public_ip_cache['ip'] = resp.text.strip()
_ddns_public_ip_cache['at'] = now
except Exception:
pass
last_ip = None
try:
from app import ddns_manager as _ddns_mgr_singleton
last_ip = _ddns_mgr_singleton._last_ip
except Exception:
pass
registered = bool(ddns_cfg.get('token'))
return jsonify({
'registered': registered,
'domain_name': identity.get('domain_name', ''),
'public_ip': _ddns_public_ip_cache['ip'],
'last_ip': last_ip,
})
@bp.route('/api/ddns/register', methods=['POST'])
def ddns_register():
"""Trigger (re-)registration with the configured DDNS provider."""
+13
View File
@@ -88,6 +88,19 @@ def complete_setup():
payload = request.get_json(silent=True) or {}
result = sm.complete_setup(payload)
if result.get('success'):
try:
from app import config_manager, service_bus, EventType
identity = config_manager.configs.get('_identity', {})
service_bus.publish_event(EventType.IDENTITY_CHANGED, 'setup', {
'cell_name': identity.get('cell_name'),
'domain': identity.get('domain'),
'domain_name': identity.get('domain_name'),
'domain_mode': identity.get('domain_mode'),
'effective_domain': config_manager.get_effective_domain(),
})
except Exception as exc:
logger.warning(f'Failed to publish IDENTITY_CHANGED after setup: {exc}')
status_code = 200 if result.get('success') else 400
return jsonify(result), status_code
+49 -1
View File
@@ -354,6 +354,8 @@ function Settings() {
const [ddnsDirty, setDdnsDirty] = useState(false);
const [ddnsSaving, setDdnsSaving] = useState(false);
const [ddnsRegistering, setDdnsRegistering] = useState(false);
const [ddnsStatus, setDdnsStatus] = useState(null);
const [ddnsStatusLoading, setDdnsStatusLoading] = useState(false);
const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining}
// service configs
@@ -411,6 +413,10 @@ function Settings() {
useEffect(() => { loadAll(); }, [loadAll]);
useEffect(() => {
if (domainMode === 'pic_ngo') checkDdnsStatus();
}, [domainMode, checkDdnsStatus]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handler = () => loadAll();
window.addEventListener('pic-config-discarded', handler);
@@ -514,6 +520,18 @@ function Settings() {
}
}, [ddnsCfToken, domainName]);
const checkDdnsStatus = useCallback(async () => {
setDdnsStatusLoading(true);
try {
const res = await ddnsAPI.getStatus();
setDdnsStatus(res.data);
} catch {
setDdnsStatus(null);
} finally {
setDdnsStatusLoading(false);
}
}, []);
const reRegister = useCallback(async () => {
setDdnsRegistering(true);
try {
@@ -522,12 +540,13 @@ function Settings() {
setDdnsHasToken(true);
setPicAvail(null);
toast(`Registered as ${res.data.subdomain}`);
checkDdnsStatus();
} catch (err) {
toast(err.response?.data?.error || 'Registration failed', 'error');
} finally {
setDdnsRegistering(false);
}
}, []);
}, [checkDdnsStatus]);
const verifyDuck = useCallback(async () => {
if (!ddnsDuckToken.trim()) return;
@@ -843,6 +862,35 @@ function Settings() {
Your cell is registered as <span className="font-mono font-semibold">{domainName || `${identity.cell_name}.pic.ngo`}</span> on pic.ngo.
Change the Cell Name above to update this subdomain.
</div>
{(() => {
const ipsMatch = ddnsStatus?.registered && ddnsStatus?.public_ip && ddnsStatus?.last_ip && ddnsStatus.public_ip === ddnsStatus.last_ip;
const dotColor = !ddnsStatus || !ddnsStatus.registered
? 'bg-gray-400'
: ipsMatch
? 'bg-green-500'
: 'bg-yellow-400';
const label = !ddnsStatus || !ddnsStatus.registered
? 'Not registered'
: ipsMatch
? 'Registered — IP current'
: 'Registered — IP stale';
return (
<div className="flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`} aria-hidden="true" />
<span className="text-xs text-gray-600">{label}</span>
{ddnsStatus?.public_ip && (
<span className="text-xs text-gray-400 font-mono">{ddnsStatus.public_ip}</span>
)}
<button
onClick={checkDdnsStatus}
disabled={ddnsStatusLoading}
className="ml-1 px-2 py-0.5 text-xs font-medium rounded border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{ddnsStatusLoading ? 'Checking…' : 'Check'}
</button>
</div>
);
})()}
<div className="flex items-center gap-3">
<button
onClick={reRegister}
+10 -8
View File
@@ -367,19 +367,21 @@ function Step2Domain({
<label className="block text-sm text-gray-400 mb-1.5">
Subdomain <span className="text-red-400">*</span>
</label>
<div className="flex items-center gap-2">
<input type="text" autoComplete="off" spellCheck={false}
value={picName}
onChange={e => { onPicName(e.target.value.toLowerCase()); setErrors(p => ({...p, name: ''})); setPicAvail(null); }}
onBlur={() => picName && checkPicAvail(picName)}
placeholder="e.g. myhome"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600" />
{CELL_NAME_RE.test(picName) && (
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
<Globe className="h-3.5 w-3.5" />
<span className="font-mono">{picName}.pic.ngo</span>
{picAvail === 'checking' && <span className="text-gray-400 ml-1">checking</span>}
{picAvail === 'available' && <span className="text-green-400 ml-1"> available</span>}
{picAvail === 'taken' && <span className="text-yellow-400 ml-1"> may already be taken</span>}
className="flex-1 bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600" />
<span className="text-gray-500 text-sm">.pic.ngo</span>
</div>
{CELL_NAME_RE.test(picName) && picAvail && (
<p className="mt-1.5 text-xs flex items-center gap-1">
<Globe className="h-3.5 w-3.5 text-blue-400" />
{picAvail === 'checking' && <span className="text-gray-400">checking</span>}
{picAvail === 'available' && <span className="text-green-400">available</span>}
{picAvail === 'taken' && <span className="text-yellow-400">may already be taken</span>}
</p>
)}
<FieldError message={errors.name} />
+1
View File
@@ -343,6 +343,7 @@ export const ddnsAPI = {
checkName: (name) => api.get(`/api/ddns/check/${name}`),
updateConfig: (data) => api.put('/api/ddns', data),
register: () => api.post('/api/ddns/register'),
getStatus: () => api.get('/api/ddns/status'),
};
// Setup Wizard API