diff --git a/CHANGELOG.md b/CHANGELOG.md
index e84aaf87..0c021862 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,22 @@ per the process in [`docs/releasing.md`](docs/releasing.md).
## [Unreleased]
+## [1.5.3] - 2026-07-14
+
+### Fixed
+
+- **Config editor: a clear note for host-only blocks instead of a silent "no changes" (#519).**
+ Editing `dashboard.energy` — read straight from `config.json` and never rendered to `.env` — made
+ the editor report "No configuration changes detected" and disable Apply, a silent no-op that looked
+ broken. The preview now surfaces a non-committable note that the block is applied on the host.
+ (Committing `dashboard.energy` from the dashboard is tracked separately in #504.)
+- **Configuration tab spacing (#505).** Fixed the run-together "setdashboard.control.enabled" in the
+ disabled-editing hint, and added a gap between the Configuration editor and the Access-log panel.
+
+### Testing
+
+- Guard against drift in the worker writable-key allowlist across its pithead-repo copies (#515).
+
## [1.5.2] - 2026-07-14
### Fixed
diff --git a/VERSION b/VERSION
index 4cda8f19..8af85beb 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.5.2
+1.5.3
diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs
index b0a951e1..05090cf8 100644
--- a/build/dashboard/mining_dashboard/web/static/components.mjs
+++ b/build/dashboard/mining_dashboard/web/static/components.mjs
@@ -843,7 +843,11 @@ function DashboardView({
<${AdvancedHint} ui=${ui} onView=${onView} onDismissHint=${onDismissHint} />
- ${configView ? html`<${ConfigView} /><${SecurityPanel} />` : null}
+ ${
+ configView
+ ? html`
<${ConfigView} /><${SecurityPanel} />
`
+ : null
+ }
${
configView
? null
diff --git a/build/dashboard/mining_dashboard/web/static/configview.mjs b/build/dashboard/mining_dashboard/web/static/configview.mjs
index 2e997d90..cad68894 100644
--- a/build/dashboard/mining_dashboard/web/static/configview.mjs
+++ b/build/dashboard/mining_dashboard/web/static/configview.mjs
@@ -67,6 +67,9 @@ const Field = ({ field, edits, onEdit }) => {
const PreviewModal = ({ preview, confirmText, onConfirmText, onConfirm, onCancel, busy }) => {
const changes = preview.changes || [];
+ // #519: HOST-flagged entries (e.g. dashboard.energy) are config.json-only — informational, not
+ // committable from the dashboard, so they never arm the Apply button.
+ const committable = changes.filter((c) => c.flag !== "HOST");
const armed = !preview.destructive || confirmText === "APPLY";
return html`
@@ -190,8 +195,8 @@ export class ConfigView extends Component {
if (phase === "disabled") {
return html`
Configuration
-
Configuration editing is off (the default). To enable it, set
- dashboard.control.enabled: true in config.json on the host and
+
Configuration editing is off (the default). To enable it, set dashboard.control.enabled: true
+ in config.json on the host and
run ./pithead apply. It requires a dashboard login.
`;
}
diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css
index 9201e6f3..a16563f2 100644
--- a/build/dashboard/mining_dashboard/web/static/dashboard.css
+++ b/build/dashboard/mining_dashboard/web/static/dashboard.css
@@ -106,6 +106,14 @@ body {
min-width: 0;
}
+/* Vertically-stacked cards that aren't in a .grid need their own gap (#505: the Configuration
+ * editor and the Access-log panel otherwise touch with no space between them). */
+.card-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
/* Brand block (Issue #81): the Pithead mark + wordmark in the header, with the host IP demoted
* to a subtitle beneath the name. The host IP (HOST_IP) is arbitrary user input; a long unbroken
* value (no hyphens/dots to break at) would otherwise push the header — and the page — wider than
@@ -1094,6 +1102,11 @@ button.upgrade-btn {
.config-preview-dest {
color: var(--warn);
}
+/* #519: a config.json-only block (e.g. dashboard.energy) edited from the dashboard — informational,
+ * not committable here, so it's dimmed to read as a note rather than a pending change. */
+.config-preview-host {
+ color: var(--text-muted);
+}
.config-confirm-type {
display: block;
margin: 12px 0;
diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml
index 1b454053..474ced31 100644
--- a/build/dashboard/pyproject.toml
+++ b/build/dashboard/pyproject.toml
@@ -7,7 +7,7 @@ name = "mining-dashboard"
# Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version
# (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version
# from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only.
-version = "1.5.2"
+version = "1.5.3"
description = "Monitoring dashboard and XvB switching engine for Pithead"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/build/dashboard/tests/service/test_control_service.py b/build/dashboard/tests/service/test_control_service.py
index 08b1f034..d5fc3ce3 100644
--- a/build/dashboard/tests/service/test_control_service.py
+++ b/build/dashboard/tests/service/test_control_service.py
@@ -218,3 +218,30 @@ def test_submit_worker_apply_spools_tokenless_intent(self, spool):
}
# No secret / addressing leaks into the container-writable spool.
assert "host" not in req and "port" not in req and "token" not in req
+
+
+def test_writable_key_allowlist_has_no_intra_repo_drift():
+ """#515: the worker writable-key allowlist is hardcoded in THREE places kept in sync only by
+ comments — the dashboard (WORKER_WRITABLE_KEYS), the pithead host runner
+ (control_worker_apply's jq allowlist), and rigforge's control-server.py WRITABLE (the authority
+ the rig enforces, #236). Drift means edits for the drifted key silently fail closed. Guard the
+ two pithead-repo copies here; rigforge#236 carries the reciprocal check on the rig side."""
+ import re
+ from pathlib import Path
+
+ canonical = {"pools", "DONATION", "autotune", "watchdog", "watchdog_interval_min", "max_temp_c"}
+ assert set(control_service.WORKER_WRITABLE_KEYS) == canonical
+
+ # The pithead CLI lives at the repo root; the dashboard-only Docker test image doesn't ship it.
+ # Verify the cross-file drift where pithead is reachable (full checkout / CI shell tests), and
+ # skip cleanly where it isn't.
+ here = Path(__file__).resolve()
+ pithead_path = next((p / "pithead" for p in here.parents if (p / "pithead").is_file()), None)
+ if pithead_path is None:
+ pytest.skip("pithead CLI not present in this test context (dashboard-only image)")
+ pithead = pithead_path.read_text()
+ # [^\]]* (not .*?) so the match survives the jq array being reflowed across multiple lines.
+ m = re.search(r"\(\[([^\]]*)\]\)\s*as\s*\$ok", pithead)
+ assert m, "could not find the writable-key allowlist in pithead's control_worker_apply"
+ pithead_keys = set(re.findall(r'"([^"]+)"', m.group(1)))
+ assert pithead_keys == canonical, f"pithead allowlist {pithead_keys} drifted from {canonical}"
diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock
index 9d7e79c9..a6b73dbf 100644
--- a/build/dashboard/uv.lock
+++ b/build/dashboard/uv.lock
@@ -772,7 +772,7 @@ wheels = [
[[package]]
name = "mining-dashboard"
-version = "1.5.2"
+version = "1.5.3"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
diff --git a/pithead b/pithead
index 178d4c11..6d771171 100755
--- a/pithead
+++ b/pithead
@@ -4743,6 +4743,13 @@ control_preview() { #
result=$(printf '%s\n' "$out" | jq -R -s '
[split("\n")[] | select(length > 0) | split("\t") | {flag: .[0], key: .[1], msg: (.[2:] | join("\t"))}]
| {status: "previewed", changes: ., destructive: (map(.flag == "DEST") | any), ts: (now | floor)}')
+ # #519: dashboard.energy is config.json-only (never rendered to .env), so an energy-only edit
+ # produces no porcelain change and the UI would otherwise show a bare "no changes detected".
+ # Surface it as a non-committable HOST note so the operator knows to apply it on the host
+ # (committing this block from the dashboard is tracked in #504).
+ if ! jq -e --slurpfile live "$CONFIG_FILE" '(.dashboard.energy // {}) == ($live[0].dashboard.energy // {})' "$staged" >/dev/null 2>&1; then
+ result=$(printf '%s' "$result" | jq '.changes += [{flag:"HOST",key:"dashboard.energy",msg:"dashboard.energy changed — this block is edited directly in config.json on the host: set it and run \"./pithead apply\". Committing it from the dashboard is tracked in #504."}]')
+ fi
control_write_result "$cdir/results" "$id" "$result"
control_audit "$cdir/audit/control.log" "$id" "$actor" "preview" "previewed" "$(porcelain_keys "$out")"
else