From 348bbfbca6d50777c9bcf81892a7fb317c7c6de5 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Sun, 28 Jun 2026 16:26:32 +0000 Subject: [PATCH] dhcp: track pending config changes with hash, update UI button --- daemon/handlers/dnsmasq.py | 30 ++++++++++++++++++++++++------ lib/state.py | 13 +++++++++++++ webui/static/pages/dhcp.js | 32 ++++++++++++++++++++++---------- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py index a4af2ea..adda319 100644 --- a/daemon/handlers/dnsmasq.py +++ b/daemon/handlers/dnsmasq.py @@ -1,5 +1,6 @@ """Dnsmasq daemon handler.""" +import hashlib import logging from copy import deepcopy 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": []}, } +# 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: """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) ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR) run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True) - run_proc( - ["tee", DNSMASQ_CONF, "--"], - sudo=True, - check=True, - input=conf_text, - ) + tmp = Path("/tmp") / "vacuum-wall-dnsmasq.tmp" + with open(tmp, "w") as f: + f.write(conf_text) + run(["cp", str(tmp), DNSMASQ_CONF], sudo=True) + tmp.unlink(missing_ok=True) run(["systemctl", "reload", "dnsmasq"], sudo=True) 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"]) return {"applied": True} diff --git a/lib/state.py b/lib/state.py index 507777e..cd40cc5 100644 --- a/lib/state.py +++ b/lib/state.py @@ -5,6 +5,8 @@ state instead of invoking subprocesses on every request. """ import contextlib +import hashlib +import json import logging import os from copy import deepcopy @@ -588,12 +590,23 @@ def _collect_dnsmasq() -> dict[str, Any]: # Check config file on disk 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 { "config": cfg, "status": { "service_active": service_active, "config_file_exists": conf_exists, "active_leases": len(leases), + "pending_changes": pending_changes, }, "leases": leases, "timestamp": _now_iso(), diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 93c106f..80d9363 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -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) { const opts = [ @@ -122,9 +122,11 @@ export default definePage({ if (guard) return guard; const cfg = state.dnsmasq.data?.config || {}; - const ranges = cfg.ranges || []; - const staticLeases = cfg.static_leases || []; - const dnsRecords = cfg.dns_records || []; + const dhcpCfg = cfg.dhcp || {}; + const dnsCfg = cfg.dns || {}; + const ranges = dhcpCfg.ranges || []; + const staticLeases = dhcpCfg.static_leases || []; + const dnsRecords = dnsCfg.custom_records || []; const status = state.dnsmasq.data?.status || {}; const rangesRows = ranges.map((r) => html` @@ -172,12 +174,22 @@ export default definePage({ html``, html``, html``, - ActionButton({ - url: '/api/dhcp/apply', - successMsg: 'dnsmasq applied', - label: 'Apply', - refresh: 'dnsmasq', - }), + (() => { + const pending = status.pending_changes === true; + return h('button', { + class: pending ? 'btn btn-primary btn-apply-pending' : 'btn btn-outline', + 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'