dhcp: track pending config changes with hash, update UI button

This commit is contained in:
2026-06-28 16:26:32 +00:00
parent baa441fa13
commit 348bbfbca6
3 changed files with 59 additions and 16 deletions
+24 -6
View File
@@ -1,5 +1,6 @@
"""Dnsmasq daemon handler.""" """Dnsmasq daemon handler."""
import hashlib
import logging import logging
from copy import deepcopy from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -49,6 +50,20 @@ DEFAULT_CFG: dict[str, Any] = {
"dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []}, "dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []},
} }
# Internal field for tracking applied config version
_APPLY_HASH_KEY = "_last_applied_hash"
def _config_hash(cfg: dict[str, Any]) -> str:
"""Compute a hash of the config, excluding the _last_applied_hash field.
Used to detect whether the JSON config has changed since the last apply.
"""
import json
clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
def _get_state() -> dict[str, Any] | None: def _get_state() -> dict[str, Any] | None:
"""Retrieve cached dnsmasq state from the state store.""" """Retrieve cached dnsmasq state from the state store."""
@@ -172,14 +187,17 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
conf_text = _generate_conf(cfg) conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR) ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True) run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True)
run_proc( tmp = Path("/tmp") / "vacuum-wall-dnsmasq.tmp"
["tee", DNSMASQ_CONF, "--"], with open(tmp, "w") as f:
sudo=True, f.write(conf_text)
check=True, run(["cp", str(tmp), DNSMASQ_CONF], sudo=True)
input=conf_text, tmp.unlink(missing_ok=True)
)
run(["systemctl", "reload", "dnsmasq"], sudo=True) run(["systemctl", "reload", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and reloaded") logger.info("dnsmasq config written and reloaded")
# Store the config hash so state collector can detect pending changes
cfg_after = _get_config()
cfg_after[_APPLY_HASH_KEY] = _config_hash(cfg_after)
_save_config(cfg_after)
refresh_state(["dnsmasq"]) refresh_state(["dnsmasq"])
return {"applied": True} return {"applied": True}
+13
View File
@@ -5,6 +5,8 @@ state instead of invoking subprocesses on every request.
""" """
import contextlib import contextlib
import hashlib
import json
import logging import logging
import os import os
from copy import deepcopy from copy import deepcopy
@@ -588,12 +590,23 @@ def _collect_dnsmasq() -> dict[str, Any]:
# Check config file on disk # Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file() conf_exists = Path(DNSMASQ_CONF).is_file()
# Check if JSON config has changed since last apply
_APPLY_HASH_KEY = "_last_applied_hash"
pending_changes = True
if _APPLY_HASH_KEY in cfg:
clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
current_hash = hashlib.sha256(
json.dumps(clean, sort_keys=True).encode()
).hexdigest()
pending_changes = cfg[_APPLY_HASH_KEY] != current_hash
return { return {
"config": cfg, "config": cfg,
"status": { "status": {
"service_active": service_active, "service_active": service_active,
"config_file_exists": conf_exists, "config_file_exists": conf_exists,
"active_leases": len(leases), "active_leases": len(leases),
"pending_changes": pending_changes,
}, },
"leases": leases, "leases": leases,
"timestamp": _now_iso(), "timestamp": _now_iso(),
+22 -10
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7'; import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
function makeAddRange(activeZones, interfaces) { function makeAddRange(activeZones, interfaces) {
const opts = [ const opts = [
@@ -122,9 +122,11 @@ export default definePage({
if (guard) return guard; if (guard) return guard;
const cfg = state.dnsmasq.data?.config || {}; const cfg = state.dnsmasq.data?.config || {};
const ranges = cfg.ranges || []; const dhcpCfg = cfg.dhcp || {};
const staticLeases = cfg.static_leases || []; const dnsCfg = cfg.dns || {};
const dnsRecords = cfg.dns_records || []; const ranges = dhcpCfg.ranges || [];
const staticLeases = dhcpCfg.static_leases || [];
const dnsRecords = dnsCfg.custom_records || [];
const status = state.dnsmasq.data?.status || {}; const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}> const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
@@ -172,12 +174,22 @@ export default definePage({
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`, html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`, html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`, html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
ActionButton({ (() => {
url: '/api/dhcp/apply', const pending = status.pending_changes === true;
successMsg: 'dnsmasq applied', return h('button', {
label: 'Apply', class: pending ? 'btn btn-primary btn-apply-pending' : 'btn btn-outline',
refresh: 'dnsmasq', disabled: !pending,
}), 'on:click': async () => {
const res = await apiFetch('/api/dhcp/apply', { method: 'POST' });
if (res.ok) {
toast('dnsmasq applied', 'success');
modelFetch('dnsmasq');
} else {
toast(res.error || 'Apply failed', 'error');
}
},
}, pending ? 'Apply' : 'Synced');
})(),
); );
const leaseTable = state.activeTab === 'active' const leaseTable = state.activeTab === 'active'