Skip to content

Commit 31d96d8

Browse files
kingpanther13claude
andcommitted
fix(settings-ui): unify restart flow — save commits, button restarts
Every save endpoint (Tools, Server Settings, Backups) now goes through the same restart UX: persist the change, surface a "Saved — restart required" toast and the cross-tab restart banner, and let the user pick the moment to actually fire the supervisor restart via the global Restart Add-on button. No save handler auto-restarts the addon anymore. The button auto-reloads the page 10 seconds after the click. Why - Adjusting a Server Setting toggle still produced a "Restart failed" alert in addon mode. Root cause: the previous fix moved each save's supervisor self-restart into a background task, but the user-visible outcome was unchanged — every toggle change immediately fired a supervisor restart, and if the user toggled multiple settings in quick succession, a later POST would race the in-flight restart (supervisor kills the addon → ingress returns 5xx → JS shows "Restart failed"). - Saving auto-backup settings on the Backups tab jumped straight to "addon is restarting" with no preview, no second-chance confirm, and no way to bundle multiple edits before paying the restart cost. - Clicking the explicit Restart button on the Tools tab left the page stuck on "Restart initiated — reload page in ~30s" forever; the user had to manually F5. Server changes - ``_save_feature_flags`` addon branch: drop ``_schedule_supervisor_self_restart`` call. Return ``{success, applied, mode, restart_required: True}`` instead of ``{..., restarting: True}``. - ``_save_backup_config`` addon branch: same — no auto-restart. - ``_save_backup_config`` file branch: rename ``restarting: False`` → ``restart_required: False`` for naming consistency across all save endpoints. - ``_save_feature_flags`` file/default branch: now also returns ``restart_required: True`` (matches the FEATURE_META copy on every flag, which says "Requires restart to take effect"). The UI surfaces the banner; the restart button stays hidden in non-addon mode since there is no supervisor to drive it. JS changes - ``saveFeatureFlag``: drops the "add-on is restarting…" branch. Every successful save shows ``Saved — restart required`` and surfaces the cross-tab banner. - ``saveBackupConfig``: branches on ``data.restart_required`` instead of ``data.restarting``. Re-enables the Save button so the user can bundle further edits before clicking Restart. - ``restartAddon``: auto-reloads the page after ``RESTART_RELOAD_DELAY_S = 10`` seconds. 4xx responses (genuine config error, e.g. SUPERVISOR_TOKEN missing) suppress the reload and surface the error inline; 5xx-from-ingress and network drops fall through to the reload because both mean the supervisor killed our upstream and the restart is in flight. - ``ORIGIN_INFO_NOTE.addon`` and ``BACKUP_ORIGIN_LABELS.addon``: "save will restart the add-on" → "restart required after save". Test changes - ``test_addon_save_merges_and_schedules_restart`` renamed to ``test_addon_save_merges_without_restart_returns_restart_required`` for both the backup-config and feature-flags variants. New assertions: ``schedule_mock.assert_not_called()``, ``body["restart_required"] is True``, ``"restarting" not in body``. - ``test_standalone_writes_file_and_invalidates_cache``: assert the new field name. Verified: ruff check, ruff format, mypy src/, 85 passing in test_settings_ui.py (1 pre-existing Windows skip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 493c6dc commit 31d96d8

2 files changed

Lines changed: 97 additions & 46 deletions

File tree

src/ha_mcp/settings_ui.py

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -921,28 +921,41 @@ def apply_tool_visibility(
921921
}
922922
}
923923
924+
const RESTART_RELOAD_DELAY_S = 10;
925+
924926
async function restartAddon() {
925927
const btn = document.getElementById('restartBtn');
926-
if (!confirm('Restart the add-on now? The web UI will become unreachable for ~30 seconds.')) return;
928+
if (!confirm('Restart the add-on now? The page will reload automatically once the add-on is back online.')) return;
927929
btn.disabled = true;
928-
btn.textContent = 'Restarting...';
930+
btn.textContent = 'Restarting…';
931+
// Only suppress the auto-reload on a genuine config error (4xx like
932+
// SUPERVISOR_TOKEN missing). Anything else — 200, 5xx from ingress
933+
// when supervisor killed our upstream mid-response, or a thrown
934+
// network error from the same kill — means the restart is in flight
935+
// and we should reload after the addon comes back.
936+
let configError = false;
929937
try {
930938
const resp = await fetch('./api/settings/restart', {method: 'POST'});
931-
if (resp.ok) {
932-
btn.textContent = 'Restart initiated — reload page in ~30s';
933-
} else {
939+
if (!resp.ok && resp.status < 500) {
940+
configError = true;
934941
let msg = 'Restart failed';
935942
try {
936943
const err = await resp.json();
937-
if (err.error && err.error.message) msg = 'Failed: ' + err.error.message;
944+
if (err && err.error && err.error.message) {
945+
msg = 'Failed: ' + err.error.message;
946+
}
938947
} catch (_e) {}
939948
btn.textContent = msg;
940949
btn.disabled = false;
941950
alert(msg);
942951
}
943952
} catch (_e) {
944-
// Connection lost mid-request is actually expected — the addon is restarting
945-
btn.textContent = 'Restart initiated (connection dropped)';
953+
// Connection lost mid-request — restart in flight, fall through.
954+
}
955+
if (!configError) {
956+
btn.textContent =
957+
'Restarting… page will reload in ' + RESTART_RELOAD_DELAY_S + 's';
958+
setTimeout(() => window.location.reload(), RESTART_RELOAD_DELAY_S * 1000);
946959
}
947960
}
948961
@@ -1204,7 +1217,7 @@ def apply_tool_visibility(
12041217
};
12051218
12061219
const BACKUP_ORIGIN_LABELS = {
1207-
addon: 'Synced to Supervisor — save will restart the add-on.',
1220+
addon: 'Synced to Supervisor — restart required after save.',
12081221
env: null, // banner generated dynamically with the env var name
12091222
file: 'Persisted locally; takes effect immediately.',
12101223
default: 'Using default; first save creates a local override file.',
@@ -1300,14 +1313,18 @@ def apply_tool_visibility(
13001313
statusEl.textContent = msg;
13011314
return;
13021315
}
1303-
if (data.restarting) {
1304-
statusEl.textContent = 'Saved — addon is restarting. Reload in ~30s.';
1305-
// Surface the cross-tab restart banner so the user has a clear
1306-
// reload-when-ready signal regardless of which tab they're on.
1316+
btn.disabled = false;
1317+
if (data.restart_required) {
1318+
// Unified restart flow — save persists but does NOT auto-restart.
1319+
// Surface the cross-tab restart-required banner; user picks the
1320+
// moment via the global Restart Add-on button. Refresh the form
1321+
// so origins update (default → addon/file etc.) but skip the
1322+
// backup-list reload until after the actual restart.
1323+
statusEl.textContent = 'Saved — restart required';
13071324
document.getElementById('restartNotice').classList.add('show');
1325+
loadBackupConfig();
13081326
} else {
13091327
statusEl.textContent = 'Saved.';
1310-
btn.disabled = false;
13111328
// Refresh display so origins update (default → file, etc.).
13121329
loadBackupConfig();
13131330
loadBackups();
@@ -1497,7 +1514,7 @@ def apply_tool_visibility(
14971514
};
14981515
14991516
const ORIGIN_INFO_NOTE = {
1500-
addon: 'Synced to the add-on Configuration tab — save will restart the add-on.',
1517+
addon: 'Synced to the add-on Configuration tab — restart required after save.',
15011518
};
15021519
15031520
async function loadFeatureFlags() {
@@ -1624,19 +1641,15 @@ def apply_tool_visibility(
16241641
updateStatus(msg);
16251642
return;
16261643
}
1627-
if (data && data.restarting) {
1628-
// Addon mode: the server has already POSTed the merged options to
1629-
// Supervisor and scheduled a self-restart in a background task —
1630-
// the user just needs to wait. Surface the restart-required banner
1631-
// so it is visible regardless of which tab they are on (the banner
1632-
// lives above the tabs).
1633-
updateStatus('Saved — add-on is restarting. Reload in ~30s.', true);
1644+
// Unified restart flow — save persists the change but does NOT fire
1645+
// the addon restart. The user picks when to restart by clicking the
1646+
// global Restart Add-on button in the cross-tab restart-required
1647+
// banner. Same UX as the Tools tab. In standalone modes the restart
1648+
// button is hidden (no supervisor to drive it) but the banner still
1649+
// surfaces "restart required" as guidance.
1650+
updateStatus('Saved — restart required', true);
1651+
if (data && data.restart_required) {
16341652
document.getElementById('restartNotice').classList.add('show');
1635-
} else {
1636-
// Standalone modes: the cache is reset in-process. Most flags still
1637-
// require an MCP-host restart to take effect (the docstring on each
1638-
// flag says so) but the addon itself is not the host — just toast.
1639-
updateStatus('Saved — restart required', true);
16401653
}
16411654
}
16421655
@@ -2390,13 +2403,19 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
23902403
create_error_response(code, err.message),
23912404
status_code=err.status_code,
23922405
)
2393-
_schedule_supervisor_self_restart(server.settings.verify_ssl)
2406+
# Unified restart flow: every save that requires an addon
2407+
# restart (Tools, Server Settings, Backups) returns
2408+
# ``restart_required=True`` and lets the user pick when to
2409+
# fire the actual restart via the global Restart Add-on
2410+
# button. Don't auto-restart here — racing the response
2411+
# against the supervisor kill caused the "Restart failed"
2412+
# alert reported by the user.
23942413
return JSONResponse(
23952414
{
23962415
"success": True,
23972416
"applied": new_overrides,
23982417
"mode": "addon",
2399-
"restarting": True,
2418+
"restart_required": True,
24002419
}
24012420
)
24022421

@@ -2486,8 +2505,13 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
24862505
# required for many flags — the UI surfaces that — but the
24872506
# cached singleton must not return the stale pre-write
24882507
# values to subsequent /api/settings/features GETs).
2508+
# ``restart_required=True`` because every flag in
2509+
# FEATURE_META carries "Requires restart to take effect" in its
2510+
# help text — file-mode saves persist the value but the live
2511+
# process keeps the old reads until the MCP host is restarted
2512+
# (Claude Desktop relaunch, Docker container restart, etc.).
24892513
_reset_global_settings()
2490-
return JSONResponse({"success": True})
2514+
return JSONResponse({"success": True, "mode": "file", "restart_required": True})
24912515

24922516
# ---- Auto-backup routes (#1288) ----
24932517

@@ -2817,19 +2841,15 @@ async def _save_backup_config(request: Request) -> JSONResponse:
28172841
create_error_response(code, sup_err.message),
28182842
status_code=sup_err.status_code,
28192843
)
2820-
# Background restart so this JSON response can flush through HA
2821-
# ingress before supervisor kills the addon. The old code POSTed
2822-
# synchronously and the kill-mid-response would convert into a
2823-
# 5xx Bad Gateway at the proxy, which the JS rendered as
2824-
# "Save failed" / "Restart failed" — see _restart_addon for the
2825-
# same fix.
2826-
_schedule_supervisor_self_restart(server.settings.verify_ssl)
2844+
# Unified restart flow — see _save_feature_flags for the
2845+
# rationale. Don't auto-restart from a save handler; the
2846+
# global Restart Add-on button is the single restart path.
28272847
return JSONResponse(
28282848
{
28292849
"success": True,
28302850
"applied": clean,
28312851
"mode": "addon",
2832-
"restarting": True,
2852+
"restart_required": True,
28332853
}
28342854
)
28352855

@@ -2871,13 +2891,18 @@ async def _save_backup_config(request: Request) -> JSONResponse:
28712891
status_code=500,
28722892
)
28732893
# Drop the cached Settings so the next read sees the merged value.
2894+
# File-mode auto-backup settings take effect immediately on the
2895+
# next ``get_global_settings()`` read — no restart needed, hence
2896+
# ``restart_required=False``. The JS uses the same field name on
2897+
# every save endpoint to decide whether to surface the
2898+
# restart-required banner.
28742899
_reset_global_settings()
28752900
return JSONResponse(
28762901
{
28772902
"success": True,
28782903
"applied": clean,
28792904
"mode": "file",
2880-
"restarting": False,
2905+
"restart_required": False,
28812906
}
28822907
)
28832908

tests/src/unit/test_settings_ui.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,7 +1094,13 @@ async def test_standalone_writes_file_and_invalidates_cache(
10941094
assert resp.status_code == 200
10951095
body = json.loads(resp.body)
10961096
assert body["mode"] == "file"
1097-
assert body["restarting"] is False
1097+
# File-mode auto-backup save applies live via the cache reset;
1098+
# no addon restart needed, hence restart_required=False. The
1099+
# field name was renamed from "restarting" → "restart_required"
1100+
# as part of the unified restart flow (Tools / Server Settings
1101+
# / Backups all use the same field).
1102+
assert body["restart_required"] is False
1103+
assert "restarting" not in body # legacy field name must be gone
10981104
on_disk = json.loads(override_path.read_text())
10991105
assert on_disk["enable_auto_backup"] is True
11001106
assert on_disk["auto_backup_throttle_minutes"] == 9
@@ -1526,7 +1532,20 @@ def decorator(fn):
15261532
return captured["post"]
15271533

15281534
@pytest.mark.asyncio
1529-
async def test_addon_save_merges_and_schedules_restart(self, monkeypatch):
1535+
async def test_addon_save_merges_without_restart_returns_restart_required(
1536+
self, monkeypatch
1537+
):
1538+
"""Unified restart flow: every save endpoint (Tools, Server
1539+
Settings, Backups) commits the change but **does not** fire the
1540+
addon restart. The user picks when to restart via the global
1541+
Restart Add-on button. The save response carries
1542+
``restart_required=True`` so the cross-tab banner appears.
1543+
1544+
Pins the contract — a regression that re-introduces an
1545+
auto-restart from inside the save handler races the supervisor
1546+
kill against the JSON response flush and surfaces a spurious
1547+
"Restart failed" alert / "addon is restarting" message.
1548+
"""
15301549
post_handler = self._capture_post_handler(monkeypatch)
15311550

15321551
merge_mock = AsyncMock(return_value=(True, None))
@@ -1547,12 +1566,13 @@ async def test_addon_save_merges_and_schedules_restart(self, monkeypatch):
15471566
assert resp.status_code == 200
15481567
body = json.loads(resp.body)
15491568
assert body["mode"] == "addon"
1550-
assert body["restarting"] is True
1569+
assert body["restart_required"] is True
1570+
assert "restarting" not in body # legacy field name must be gone
15511571
merge_mock.assert_awaited_once_with(
15521572
True,
15531573
{"enable_auto_backup": True, "auto_backup_throttle_minutes": 5},
15541574
)
1555-
schedule_mock.assert_called_once_with(True)
1575+
schedule_mock.assert_not_called()
15561576

15571577
@pytest.mark.asyncio
15581578
async def test_addon_save_surfaces_validation_error_with_supervisor_status(
@@ -1670,7 +1690,12 @@ def decorator(fn):
16701690
return captured["post"]
16711691

16721692
@pytest.mark.asyncio
1673-
async def test_addon_save_merges_and_schedules_restart(self, monkeypatch):
1693+
async def test_addon_save_merges_without_restart_returns_restart_required(
1694+
self, monkeypatch
1695+
):
1696+
"""Mirror of TestSaveBackupConfigAddonMode's counterpart — see
1697+
that test for the unified-restart-flow rationale.
1698+
"""
16741699
post_handler = self._capture_post_handler(monkeypatch)
16751700

16761701
merge_mock = AsyncMock(return_value=(True, None))
@@ -1689,9 +1714,10 @@ async def test_addon_save_merges_and_schedules_restart(self, monkeypatch):
16891714
assert resp.status_code == 200
16901715
body = json.loads(resp.body)
16911716
assert body["mode"] == "addon"
1692-
assert body["restarting"] is True
1717+
assert body["restart_required"] is True
1718+
assert "restarting" not in body
16931719
merge_mock.assert_awaited_once_with(True, {"enable_yaml_config_editing": True})
1694-
schedule_mock.assert_called_once_with(True)
1720+
schedule_mock.assert_not_called()
16951721

16961722
@pytest.mark.asyncio
16971723
async def test_addon_save_surfaces_validation_error_with_supervisor_status(

0 commit comments

Comments
 (0)