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:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
+15
-13
@@ -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>
|
||||
<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>}
|
||||
<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="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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user