From 85d75cdd70bd51e94a2f48ab5a049e8ff0e9b79c Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:10 -0400 Subject: [PATCH] feat(web): Start Display on Boot toggle on the General tab Turns ledmatrix.service's autostart on or off at once through the existing enable_autostart / disable_autostart system actions (sudo grants already in lib_sudoers.sh). The checkbox shows systemd's own state, read with `systemctl is-enabled`, so nothing is stored in config.json and the default stays on. Hidden when the state cannot be read; a refused change puts the box back and shows the route's message. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++ test/js/run_all.js | 3 +- .../js/unit/test_general_display_autostart.js | 77 +++++++++++++++++++ test/test_general_display_autostart.py | 60 +++++++++++++++ web_interface/blueprints/pages_v3.py | 17 ++++ web_interface/static/v3/js/pages/general.js | 26 +++++++ .../templates/v3/partials/general.html | 16 ++++ 7 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 test/js/unit/test_general_display_autostart.js create mode 100644 test/test_general_display_autostart.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a709790..6e4f752e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Features + +- General tab: a **Start Display on Boot** toggle turns + `ledmatrix.service`'s autostart on or off right away (no Save needed), + through the existing `enable_autostart` / `disable_autostart` system + actions. It shows systemd's own state, so nothing is stored in + `config.json`; the default stays on (the installer enables the unit). The + web interface still starts on boot either way. Hidden when the unit's state + cannot be read (not installed, or no systemd). + ### Fixes - `SportsRankingsMixin` (#789's deferred review points, now that it is the diff --git a/test/js/run_all.js b/test/js/run_all.js index f9e9218b..8f42356a 100755 --- a/test/js/run_all.js +++ b/test/js/run_all.js @@ -32,7 +32,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js', 'unit/test_page_registry.js', 'unit/test_core_modules.js', 'unit/test_overview_reconciliation_poll.js', 'unit/test_display_partial_ids.js', - 'unit/test_general_web_login_token.js']; + 'unit/test_general_web_login_token.js', + 'unit/test_general_display_autostart.js']; const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', 'dom/test_tools_sections.js', 'dom/test_cache_page.js', 'dom/test_durations_page.js', 'dom/test_operation_history_page.js', diff --git a/test/js/unit/test_general_display_autostart.js b/test/js/unit/test_general_display_autostart.js new file mode 100644 index 00000000..8841bb20 --- /dev/null +++ b/test/js/unit/test_general_display_autostart.js @@ -0,0 +1,77 @@ +// The General tab's "Start Display on Boot" checkbox (setDisplayAutostart in +// static/v3/js/pages/general.js) posts enable_autostart / disable_autostart +// to /api/v3/system/action and puts the box back when that is refused -- +// including the route's HTTP-200 {status: "error"} answer for a failed +// systemctl (e.g. the sudo rule is missing). +// +// Fake fetch, no jsdom and no server needed. + +const path = require('path'); +const { pathToFileURL } = require('url'); + +const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js'); +const load = f => import(pathToFileURL(path.join(JS, f)).href); + +let pass = 0, fail = 0; +const ok = (label, cond, extra) => cond + ? (pass++, console.log(' ok ' + label)) + : (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : ''))); + +async function run(checked, answer) { + const { createApi } = await load('core/api.js'); + const general = await load('pages/general.js'); + const sent = []; + const notes = []; + const fetch = (url, init) => { + sent.push([url, JSON.parse(init.body)]); + if (answer === 'network') return Promise.reject(new TypeError('Failed to fetch')); + return Promise.resolve({ + ok: answer.ok, status: answer.ok ? 200 : 500, + headers: { get: () => null }, + text: () => Promise.resolve(JSON.stringify(answer.body)), + }); + }; + const ctx = { root: {}, state: {}, signal: { aborted: false }, + api: createApi({ fetch }), notify: (m, t) => notes.push([m, t]) }; + const box = { checked, disabled: false }; + await general.setDisplayAutostart(ctx, box); + return { box, sent, notes }; +} + +(async () => { + console.log('\n── General tab: Start Display on Boot ──'); + + { + const t = await run(false, { ok: true, body: { status: 'success', message: 'ok' } }); + ok('unchecking posts disable_autostart', + t.sent.length === 1 && t.sent[0][0] === '/api/v3/system/action' && t.sent[0][1].action === 'disable_autostart', t.sent); + ok('...stays unchecked and re-enabled', t.box.checked === false && t.box.disabled === false); + ok('...and reports success', t.notes.some(([, type]) => type === 'success'), t.notes); + } + + { + const t = await run(true, { ok: true, body: { status: 'success' } }); + ok('checking posts enable_autostart', t.sent[0][1].action === 'enable_autostart', t.sent); + } + + { + const t = await run(false, { ok: true, body: { status: 'error', message: 'sudo: a password is required' } }); + ok('a failed systemctl (HTTP 200, status error) puts the box back', t.box.checked === true && !t.box.disabled); + ok('...and shows the route\'s message', + t.notes.some(([m, type]) => type === 'error' && /sudo/.test(m)), t.notes); + } + + { + const t = await run(true, { ok: false, body: { status: 'error', message: 'boom' } }); + ok('an HTTP error puts the box back', t.box.checked === false && !t.box.disabled); + } + + { + const t = await run(true, 'network'); + ok('a network failure puts the box back', t.box.checked === false && !t.box.disabled); + ok('...and reports it', t.notes.some(([, type]) => type === 'error'), t.notes); + } + + console.log(`\n${pass} passed, ${fail} failed\n`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); }); diff --git a/test/test_general_display_autostart.py b/test/test_general_display_autostart.py new file mode 100644 index 00000000..7f1f3403 --- /dev/null +++ b/test/test_general_display_autostart.py @@ -0,0 +1,60 @@ +"""The General tab's "Start Display on Boot" toggle reads systemd's state. + +ledmatrix.service enabled -> checked, disabled -> unchecked, unreadable (no +systemctl, unit not installed) -> no toggle. The toggle itself posts the +existing enable_autostart / disable_autostart system actions, whose sudo +grants test_systemctl_sudoers_alignment.py already checks. +""" +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from test.test_web_settings_ui import client # noqa: F401,E402 +from web_interface.blueprints import pages_v3 # noqa: E402 + + +REAL_RUN = subprocess.run +UNIT_QUERY = ['systemctl', 'is-enabled', 'ledmatrix.service'] + + +def _fake_systemctl(monkeypatch, answer): + """Answer the unit query with `answer` (stdout, or an exception to + raise); every other command (the update channel's git) runs for real.""" + calls = [] + + def run(cmd, *args, **kwargs): + if cmd != UNIT_QUERY: + return REAL_RUN(cmd, *args, **kwargs) + calls.append(cmd) + if isinstance(answer, Exception): + raise answer + return subprocess.CompletedProcess(cmd, 0, stdout=answer, stderr='') + monkeypatch.setattr(pages_v3.subprocess, 'run', run) + return calls + + +def _toggle(body): + start = body.find('data-action="display-autostart"') + return None if start < 0 else body[start:body.find('>', start)] + + +@pytest.mark.parametrize('stdout, checked', [('enabled\n', True), ('disabled\n', False)]) +def test_toggle_reflects_the_unit(client, monkeypatch, stdout, checked): + calls = _fake_systemctl(monkeypatch, stdout) + tag = _toggle(client.get('/v3/partials/general').get_data(as_text=True)) + assert calls == [UNIT_QUERY] + assert tag is not None + assert ('checked' in tag) is checked + + +@pytest.mark.parametrize('outcome', [FileNotFoundError(), 'not-found\n']) +def test_toggle_hidden_when_state_unreadable(client, monkeypatch, outcome): + _fake_systemctl(monkeypatch, outcome) + body = client.get('/v3/partials/general').get_data(as_text=True) + assert _toggle(body) is None + assert 'name="plugins_directory"' in body # rest of the tab still renders diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 260ad7be..1de29c11 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -4,6 +4,7 @@ from html.parser import HTMLParser import json import logging import re +import subprocess from pathlib import Path # Strict allowlists for URL-derived values used in path and script operations. @@ -487,9 +488,25 @@ def _load_general_partial(): main_config=main_config, auto_update_status=auto_update_status, update_channel_status=update_channel_status, + display_autostart=_display_autostart_state(), web_login=_web_login_state()) +def _display_autostart_state(): + """Whether ledmatrix.service starts at boot; None hides the toggle. + + systemd's enablement is the setting itself (the installer enables the + unit), so nothing is stored in config.json. None when it cannot be read: + no systemctl (a dev machine) or the unit is not installed. + """ + try: + result = subprocess.run(['systemctl', 'is-enabled', 'ledmatrix.service'], + capture_output=True, text=True, timeout=5) + except (OSError, subprocess.SubprocessError): + return None + return {'enabled': True, 'disabled': False}.get(result.stdout.strip()) + + def _web_login_state(): """What the General tab's Security section shows; None hides it. diff --git a/web_interface/static/v3/js/pages/general.js b/web_interface/static/v3/js/pages/general.js index 00e3f5dc..a1135f5f 100644 --- a/web_interface/static/v3/js/pages/general.js +++ b/web_interface/static/v3/js/pages/general.js @@ -191,6 +191,28 @@ export function revokeToken(ctx, id, name) { }).catch(failed(ctx)); } +/** + * The "Start Display on Boot" checkbox: enable or disable ledmatrix.service + * at once. A refusal (e.g. the sudo rule is missing) puts the box back. + */ +export function setDisplayAutostart(ctx, box) { + const on = box.checked; + box.disabled = true; + return send(ctx, 'POST', '/api/v3/system/action', { action: on ? 'enable_autostart' : 'disable_autostart' }).then(function(res) { + if (!res) return false; + const ok = res.ok && res.d.status === 'success'; + if (!ok) box.checked = !on; + ctx.notify(ok ? (on ? 'The display will start on boot' : 'The display will not start on boot') + : (res.d.message || 'Could not change display autostart'), ok ? 'success' : 'error'); + return ok; + }).catch(function(err) { + box.checked = !on; + failed(ctx)(err); + }).finally(function() { + box.disabled = false; + }); +} + const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken }; function drawTimezone(root, ctx) { @@ -238,6 +260,10 @@ export function init(root, ctx) { if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName); } }, on); + root.addEventListener('change', function(event) { + const box = event.target; + if (box && box.dataset && box.dataset.action === 'display-autostart') setDisplayAutostart(ctx, box); + }, on); drawTimezone(root, ctx); active = ctx; } diff --git a/web_interface/templates/v3/partials/general.html b/web_interface/templates/v3/partials/general.html index 5f4ab099..3415d3c6 100644 --- a/web_interface/templates/v3/partials/general.html +++ b/web_interface/templates/v3/partials/general.html @@ -12,6 +12,22 @@ {{ ui.settings_filter() }} + {# Display autostart is systemd's enable/disable of ledmatrix.service, not a + config.json key: outside the form so Save never posts it. general.js + applies it on change. Hidden when the unit's state cannot be read. #} + {% if display_autostart is not none %} +
+ +
+ {% endif %} +