Compare commits

...
Author SHA1 Message Date
ChuckandClaude Opus 5.5 85d75cdd70 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 <noreply@anthropic.com>
2026-10-12 17:49:10 -04:00
7 changed files with 208 additions and 1 deletions
+10
View File
@@ -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
+2 -1
View File
@@ -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',
@@ -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); });
+60
View File
@@ -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
+17
View File
@@ -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.
@@ -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;
}
@@ -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 %}
<div class="form-group mb-6" id="setting-general-display_autostart" data-setting-key="display_autostart">
<label class="flex items-center">
<input type="checkbox"
data-action="display-autostart"
{% if display_autostart %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Start Display on Boot</span>
{{ ui.help_tip('Start the LED display automatically when the device powers on.\nApplies right away; no Save needed.\nWhen off, start it with Start Display on the Overview tab. The web interface still starts on boot.\nDefault: on.', 'Start Display on Boot') }}
</label>
</div>
{% endif %}
<form hx-post="/api/v3/config/main"
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'