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
|
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'])
|
@bp.route('/api/ddns/register', methods=['POST'])
|
||||||
def ddns_register():
|
def ddns_register():
|
||||||
"""Trigger (re-)registration with the configured DDNS provider."""
|
"""Trigger (re-)registration with the configured DDNS provider."""
|
||||||
|
|||||||
@@ -88,6 +88,19 @@ def complete_setup():
|
|||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
result = sm.complete_setup(payload)
|
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
|
status_code = 200 if result.get('success') else 400
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,8 @@ function Settings() {
|
|||||||
const [ddnsDirty, setDdnsDirty] = useState(false);
|
const [ddnsDirty, setDdnsDirty] = useState(false);
|
||||||
const [ddnsSaving, setDdnsSaving] = useState(false);
|
const [ddnsSaving, setDdnsSaving] = useState(false);
|
||||||
const [ddnsRegistering, setDdnsRegistering] = 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}
|
const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining}
|
||||||
|
|
||||||
// service configs
|
// service configs
|
||||||
@@ -411,6 +413,10 @@ function Settings() {
|
|||||||
|
|
||||||
useEffect(() => { loadAll(); }, [loadAll]);
|
useEffect(() => { loadAll(); }, [loadAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (domainMode === 'pic_ngo') checkDdnsStatus();
|
||||||
|
}, [domainMode, checkDdnsStatus]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => loadAll();
|
const handler = () => loadAll();
|
||||||
window.addEventListener('pic-config-discarded', handler);
|
window.addEventListener('pic-config-discarded', handler);
|
||||||
@@ -514,6 +520,18 @@ function Settings() {
|
|||||||
}
|
}
|
||||||
}, [ddnsCfToken, domainName]);
|
}, [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 () => {
|
const reRegister = useCallback(async () => {
|
||||||
setDdnsRegistering(true);
|
setDdnsRegistering(true);
|
||||||
try {
|
try {
|
||||||
@@ -522,12 +540,13 @@ function Settings() {
|
|||||||
setDdnsHasToken(true);
|
setDdnsHasToken(true);
|
||||||
setPicAvail(null);
|
setPicAvail(null);
|
||||||
toast(`Registered as ${res.data.subdomain}`);
|
toast(`Registered as ${res.data.subdomain}`);
|
||||||
|
checkDdnsStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.response?.data?.error || 'Registration failed', 'error');
|
toast(err.response?.data?.error || 'Registration failed', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setDdnsRegistering(false);
|
setDdnsRegistering(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [checkDdnsStatus]);
|
||||||
|
|
||||||
const verifyDuck = useCallback(async () => {
|
const verifyDuck = useCallback(async () => {
|
||||||
if (!ddnsDuckToken.trim()) return;
|
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.
|
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.
|
Change the Cell Name above to update this subdomain.
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={reRegister}
|
onClick={reRegister}
|
||||||
|
|||||||
+15
-13
@@ -367,19 +367,21 @@ function Step2Domain({
|
|||||||
<label className="block text-sm text-gray-400 mb-1.5">
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
Subdomain <span className="text-red-400">*</span>
|
Subdomain <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" autoComplete="off" spellCheck={false}
|
<div className="flex items-center gap-2">
|
||||||
value={picName}
|
<input type="text" autoComplete="off" spellCheck={false}
|
||||||
onChange={e => { onPicName(e.target.value.toLowerCase()); setErrors(p => ({...p, name: ''})); setPicAvail(null); }}
|
value={picName}
|
||||||
onBlur={() => picName && checkPicAvail(picName)}
|
onChange={e => { onPicName(e.target.value.toLowerCase()); setErrors(p => ({...p, name: ''})); setPicAvail(null); }}
|
||||||
placeholder="e.g. myhome"
|
onBlur={() => picName && checkPicAvail(picName)}
|
||||||
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" />
|
placeholder="e.g. myhome"
|
||||||
{CELL_NAME_RE.test(picName) && (
|
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" />
|
||||||
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
|
<span className="text-gray-500 text-sm">.pic.ngo</span>
|
||||||
<Globe className="h-3.5 w-3.5" />
|
</div>
|
||||||
<span className="font-mono">{picName}.pic.ngo</span>
|
{CELL_NAME_RE.test(picName) && picAvail && (
|
||||||
{picAvail === 'checking' && <span className="text-gray-400 ml-1">checking…</span>}
|
<p className="mt-1.5 text-xs flex items-center gap-1">
|
||||||
{picAvail === 'available' && <span className="text-green-400 ml-1">— available</span>}
|
<Globe className="h-3.5 w-3.5 text-blue-400" />
|
||||||
{picAvail === 'taken' && <span className="text-yellow-400 ml-1">— may already be taken</span>}
|
{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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<FieldError message={errors.name} />
|
<FieldError message={errors.name} />
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ export const ddnsAPI = {
|
|||||||
checkName: (name) => api.get(`/api/ddns/check/${name}`),
|
checkName: (name) => api.get(`/api/ddns/check/${name}`),
|
||||||
updateConfig: (data) => api.put('/api/ddns', data),
|
updateConfig: (data) => api.put('/api/ddns', data),
|
||||||
register: () => api.post('/api/ddns/register'),
|
register: () => api.post('/api/ddns/register'),
|
||||||
|
getStatus: () => api.get('/api/ddns/status'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup Wizard API
|
// Setup Wizard API
|
||||||
|
|||||||
Reference in New Issue
Block a user