Files
LEDMatrix/web_interface/blueprints/api_v3/system.py
ChuckandClaude Opus 5.5 f841fa36b6 feat(install): updates refresh systemd units; new installs run the newest release (#729)
Updates that move HEAD now install changed systemd units through a root-owned helper (/usr/local/sbin/ledmatrix-refresh-units, two literal sudo lines), with a backup restored on rollback; a refresh that fails part-way puts the old units back. Devices without the new sudo rule keep updating and are told to re-run the installer once. The one-shot installer now checks out the newest vX.Y.Z release (LEDMATRIX_CHANNEL=beta keeps main) and never moves an existing checkout backwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:09:53 -04:00

880 lines
44 KiB
Python

"""Service control, updates, versions and system status.
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
so their endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
Any, Dict, PROJECT_ROOT, Path, _GIT, _UPDATE_CHECK_TTL,
_describe_git_failure, _get_display_service_status, _git_current_branch,
_git_remote_branch_exists, _git_upstream, _pip_install_requirements,
_scrub_git_remote_url, _truncate_output, _update_check_cache,
_update_check_failed, api_v3, checkout_branch, describe_exception,
get_git_version, jsonify, logger, os, request, resolve_pull_command,
shutil, subprocess,
)
import threading
from web_interface.cache import get_cached, set_cached
from web_interface.system_metrics import collect_system_metrics, format_uptime
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/system/status', methods=['GET'])
def get_system_status():
"""CPU, memory, disk, temperature and uptime, plus the display service state.
``data`` carries every key of system_metrics.collect_system_metrics() --
the numbers the live status stream sends -- and ``timestamp``, ``uptime``
(formatted) and ``service_active``. A metric that cannot be read is null.
Cached for 10 seconds.
"""
cached_result = get_cached('system_status', ttl_seconds=10)
if cached_result is not None:
return jsonify({'status': 'success', 'data': cached_result})
# A short blocking sample: this may be the first cpu_percent call in the
# process, and a non-blocking first call has nothing to measure against.
status = collect_system_metrics(cpu_interval=0.1)
status['timestamp'] = _pkg.time.time()
status['uptime'] = format_uptime(status['uptime_seconds'])
status['service_active'] = _get_display_service_status().get('active', False)
set_cached('system_status', status, ttl_seconds=10)
return jsonify({'status': 'success', 'data': status})
@api_v3.route('/system/version', methods=['GET'])
def get_system_version():
"""Get LEDMatrix repository version"""
try:
version = get_git_version()
return jsonify({'status': 'success', 'data': {'version': version}})
except Exception as e:
logger.error("get_system_version failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
@api_v3.route('/system/auto-update', methods=['GET'])
def get_auto_update_status():
"""Weekly automatic update status: last result, next check, and any alert.
No local except: a failure falls through to the app-wide handler in
web_interface/app.py, which logs the traceback and returns the redacted
detail -- the response every route gives, without a second copy here.
"""
from web_interface import auto_update
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
return jsonify({'status': 'success', 'data': auto_update.describe_status(config)})
@api_v3.route('/system/auto-update/dismiss', methods=['POST'])
def dismiss_auto_update_alert():
"""Hide the current automatic-update banner until a new alert replaces it."""
from web_interface import auto_update
payload = request.get_json(silent=True)
# A JSON array or scalar is a bad request, not a 500.
alert_id = str(payload.get('alert_id') or '').strip() if isinstance(payload, dict) else ''
if not alert_id:
return jsonify({'status': 'error', 'message': 'alert_id required'}), 400
auto_update.dismiss_alert(alert_id)
return jsonify({'status': 'success'})
def _channel_payload(channel, fetch_error=''):
from web_interface import update_channel
data = dict(channel)
data['channels'] = list(update_channel.CHANNELS)
data['fetch_error'] = fetch_error or None
return data
@api_v3.route('/system/update-channel', methods=['GET'])
def get_update_channel():
"""The update channel: configured, in effect, and what the next update does.
Reads local refs only, unless ``?fetch=1`` asks it to check origin first.
No local except: failures reach the blueprint-wide handler, which logs the
traceback and returns the redacted detail.
"""
fetch = str(request.args.get('fetch', '')).lower() in ('1', 'true', 'yes')
channel, fetch_error = channel_status(fetch=fetch)
return jsonify({'status': 'success', 'data': _channel_payload(channel, fetch_error)})
@api_v3.route('/system/update-channel', methods=['POST'])
def set_update_channel():
"""Switch between the stable and beta update channels: ``{"channel": "stable"}``.
Only the setting changes here; the next Update Code or weekly update
applies it. Switching to stable never moves a device backwards: one
running code newer than the newest release keeps following main until a
release includes it, and the response says so.
"""
from web_interface import update_channel
payload = request.get_json(silent=True)
channel = update_channel.normalize_channel(payload.get('channel')) if isinstance(payload, dict) else None
if channel is None:
return jsonify({'status': 'error',
'message': "channel must be 'stable' or 'beta'"}), 400
cm = getattr(api_v3, 'config_manager', None)
if not cm:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 503
update_channel.set_channel(cm, channel)
_update_check_cache['result'] = None
status, _ = channel_status(fetch=False)
if channel == 'beta':
message = (f'Switched to the beta channel. Updates now follow {update_channel.BETA_BRANCH}, '
'the newest code, before it is released.')
elif status.action == update_channel.ACTION_CHECKOUT_TAG:
message = (f'Switched to the stable channel. The next update moves this device to release '
f'{status.newest_release}.')
else:
# On the newest release already, or waiting for one that includes
# this commit; status.message says which.
message = f'Switched to the stable channel. {status.message}'
if channel == 'beta' or status.action == update_channel.ACTION_CHECKOUT_TAG:
message += ' Use Update Code on the Overview tab to apply it now.'
return jsonify({'status': 'success', 'message': message, 'data': _channel_payload(status)})
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check whether newer LEDMatrix code is available on this device's update channel.
stable compares HEAD with the newest release tag; beta (and stable while
it waits on a branch for a release newer than this commit) with
origin/main. When the channel says an update would do nothing -- on the
newest release, or detached and newer than it -- it is never reported
as available, whatever origin/main holds. The
response carries ``channel``, ``waiting``, ``newest_release`` and, when
the update is a release, ``target_version``.
"""
now = _pkg.time.time()
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
return jsonify(_update_check_cache['result'])
from web_interface import update_channel
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
try:
cwd = str(PROJECT_ROOT)
fetch_result = subprocess.run(
# main and the release tags only: this runs on page loads, with
# a short timeout, and other branches are not needed to answer.
['git', 'fetch', '--quiet', '--tags', '--force', update_channel.REMOTE,
update_channel.BETA_BRANCH],
capture_output=True, timeout=10, cwd=cwd,
)
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.decode(errors='replace').strip()
logger.warning("check-update: git fetch failed (rc=%d): %s",
fetch_result.returncode, stderr)
failed = _update_check_failed(_describe_git_failure(stderr))
_update_check_cache['result'] = failed
_update_check_cache['ts'] = now
return jsonify(failed)
channel, _ = channel_status(cwd, fetch=False)
channel_fields = {'channel': channel.channel, 'configured_channel': channel.configured,
'waiting': channel.waiting, 'newest_release': channel.newest_release,
'current_release': channel.current_release,
'channel_message': channel.message}
if channel.channel == 'stable' or channel.action == update_channel.ACTION_NONE:
# On a release (or about to move to one): compare tags, not
# branch commits -- main is always ahead of the newest release.
# ACTION_NONE covers a detached HEAD newer than the newest
# release too: Update Code leaves it where it is until a release
# includes it, so origin/main being ahead is not an update it
# would install. channel_message says so, in the General tab's
# words.
result = {'update_available': False, 'remote_sha': channel.newest_release_sha or 'unknown',
'commits_behind': 0, **channel_fields}
if channel.action == update_channel.ACTION_CHECKOUT_TAG:
count_str = subprocess.run(
['git', 'rev-list', '--count', f'HEAD..{channel.newest_release_sha}'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
result.update(update_available=True, target_version=channel.newest_release,
commits_behind=int(count_str) if count_str.isdigit() else 0)
_update_check_cache['result'] = result
_update_check_cache['ts'] = now
return jsonify(result)
local = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
remote = subprocess.run(
['git', 'rev-parse', 'origin/main'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
if not local or not remote:
return jsonify({**_safe, **channel_fields})
if local == remote:
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
else:
count_str = subprocess.run(
['git', 'rev-list', 'HEAD..origin/main', '--count'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
count = int(count_str) if count_str.isdigit() else 0
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
result.update(channel_fields)
_update_check_cache['result'] = result
_update_check_cache['ts'] = now
return jsonify(result)
except Exception as e:
logger.warning("check-update failed: %s", e)
return jsonify(_update_check_failed(
"Could not check for updates; see logs for details."))
#: sudo's own wording when it needs a password it cannot ask for. The web
#: interface runs unprivileged, so its systemctl/reboot/journalctl calls only
#: work once scripts/install/configure_web_sudo.sh has granted NOPASSWD --
#: which first_time_install.sh does not do. That makes this the common case on
#: a fresh device, and "Action failed; see logs for details" named none of it,
#: while the log viewer was broken for the very same reason.
_SUDO_NEEDS_PASSWORD = (
'a password is required',
'no tty present',
'a terminal is required',
)
_SUDO_HINT = (
'Passwordless sudo is not configured for the web interface user, so this '
'action cannot run. Run scripts/install/configure_web_sudo.sh as that user, '
'then retry.'
)
def _sudo_hint_for(text):
"""An actionable hint when `text` is sudo refusing to prompt, else None."""
lowered = (text or '').lower()
if any(marker in lowered for marker in _SUDO_NEEDS_PASSWORD):
return _SUDO_HINT
return None
_core_update_lock = threading.Lock()
#: The core's own requirement files, installed after a pull that changes them.
#: scripts/fix_perms/safe_pip_install.sh must accept every one (it refuses
#: anything it does not list), and scripts/utils/auto_update_verify.py
#: reinstalls the same files when it rolls an update back.
CORE_REQUIREMENT_FILES = ('requirements.txt', 'web_interface/requirements.txt')
def perform_core_update(stash_local_changes=True):
"""Pull the latest LEDMatrix code and sync its dependencies.
Shared by the Overview "Update Code" button and the weekly automatic
updater (web_interface/auto_update.py), so both take exactly the same
path. Returns the JSON-able payload the button has always received:
``status``, ``message`` and ``restart_required``.
Update Code stashes local edits before pulling. The automatic updater
passes ``stash_local_changes=False``: then local edits make this return
an error carrying ``local_changes`` (the edited paths) without pulling.
"""
# The button and the scheduler can fire together; two pulls racing
# over one checkout (and one stash) is how local changes get lost.
if not _core_update_lock.acquire(blocking=False):
return {'status': 'error', 'restart_required': False,
'message': 'An update is already in progress; try again shortly.'}
try:
return _perform_core_update_locked(stash_local_changes)
finally:
_core_update_lock.release()
def _load_config_quietly():
# getattr: the blueprint only has a config_manager once the app wired one.
cm = getattr(api_v3, 'config_manager', None)
if not cm:
return {}
try:
return cm.load_config() or {}
except Exception:
logger.warning("Could not load config to read the update channel", exc_info=True)
return {}
def _persist_stable_channel():
"""A config that predates channels moves to stable once it is on a release."""
cm = getattr(api_v3, 'config_manager', None)
if not cm:
return
from web_interface import update_channel
try:
update_channel.set_channel(cm, 'stable')
logger.info("Update channel set to stable: this device is on a release now")
except Exception:
logger.warning("Could not save the stable update channel", exc_info=True)
def channel_status(project_dir=None, fetch=True):
"""The update channel's plan for this checkout (update_channel.resolve).
Returns ``(status, fetch_error)``; ``fetch_error`` is git's first line
when fetching failed, else ''.
"""
from web_interface import update_channel
project_dir = str(project_dir or PROJECT_ROOT)
fetch_error = ''
if fetch:
fetched = update_channel.fetch(project_dir)
if fetched.returncode != 0:
stderr = fetched.stderr.decode(errors='replace') if isinstance(fetched.stderr, bytes) else (fetched.stderr or '')
fetch_error = next((ln.strip() for ln in stderr.splitlines() if ln.strip()), 'git fetch failed')
return update_channel.resolve(project_dir, _load_config_quietly()), fetch_error
def _perform_core_update_locked(stash_local_changes=True):
project_dir = str(PROJECT_ROOT)
from web_interface import update_channel
# Which code to move to: the newest release (stable) or main (beta).
# See web_interface/update_channel.py; it never picks an older commit.
channel, fetch_error = channel_status(project_dir)
if fetch_error:
logger.warning("git fetch failed before update: %s", fetch_error)
return {'status': 'error', 'message': f"Update failed: {fetch_error}",
'restart_required': False, 'dependency_failures': []}
action = channel.action
if action == update_channel.ACTION_NONE:
if channel.migrate:
_persist_stable_channel()
return {'status': 'success', 'restart_required': False, 'dependency_failures': [],
'channel': channel.channel,
'message': f"LEDMatrix is already up to date. {channel.message}"}
# Decide how to pull BEFORE stashing. If this checkout cannot be
# updated at all, stashing first would put the user's local changes
# away for an update that was never going to run.
pull_args, upstream_note = None, ''
if action == update_channel.ACTION_PULL:
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
if pull_error:
logger.warning("git pull not attempted: %s", pull_error)
return {'status': 'error', 'message': pull_error, 'restart_required': False}
# Local changes, counted exactly as the automatic update's preflight
# counts them (auto_update.local_changes): mode-only changes and the
# plugin folders don't count, and the pull's --autostash carries those
# across and reapplies them.
from web_interface import auto_update
try:
changed = auto_update.local_changes(project_dir)
except (subprocess.SubprocessError, OSError) as status_err:
logger.warning("git status failed before pull: %s", status_err)
changed = None
# When git cannot say, assume there are changes rather than pull over them.
has_changes = changed is None or bool(changed)
if has_changes and not stash_local_changes:
# The automatic updater: it promised not to stash, and nothing would
# ever restore a stash taken on its behalf.
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
'local_changes': list(changed or []),
'message': auto_update.describe_local_changes(changed)}
stash_info = ""
# Stash local changes if they exist. The plugin folders are left out:
# plugins are separate installs, and --autostash carries their edits.
if has_changes:
try:
stash_result = subprocess.run(
['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--',
*(f':!{folder}' for folder in auto_update.SEPARATE_INSTALL_DIRS)],
capture_output=True,
text=True,
timeout=30,
cwd=project_dir
)
if stash_result.returncode == 0:
logger.debug("git stash: stashed local changes before pull")
stash_info = " Local changes were stashed."
else:
logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode)
except subprocess.TimeoutExpired:
logger.warning("git stash timed out, proceeding with pull")
# Record HEAD before the pull so dependency changes can be detected
old_head = None
try:
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
if _pre.returncode == 0:
old_head = _pre.stdout.strip()
except subprocess.TimeoutExpired:
logger.warning("git rev-parse timed out before pull")
# Whether the pull actually brought new code in. "Already up to
# date" is a success too, and prompting for a restart then would
# train users to ignore the prompt.
code_changed = False
unit_result = None
# Requirement files whose install failed. The automatic updater refuses
# to restart onto code whose dependencies did not install.
dependency_failures = []
# Move the checkout. A pull on beta; branches without an upstream were
# given an explicit "origin <branch>" above so the update still works.
# Stable checks out the release tag, carrying edits across the way the
# pull's --autostash does.
channel_note = ''
if action == update_channel.ACTION_CHECKOUT_TAG:
result, autostash_note = update_channel.checkout_release(project_dir, channel.newest_release)
channel_note = f"Now on release {channel.newest_release} (stable channel). {autostash_note}".strip()
elif action == update_channel.ACTION_SWITCH_TO_BETA:
result, autostash_note = update_channel.checkout_beta_branch(project_dir)
if result.returncode == 0:
result = subprocess.run(['git', 'pull', '--rebase', '--autostash'],
capture_output=True, text=True, timeout=60, cwd=project_dir)
channel_note = f"Now following {update_channel.BETA_BRANCH} (beta channel). {autostash_note}".strip()
else:
result = subprocess.run(
pull_args,
capture_output=True,
text=True,
timeout=60,
cwd=project_dir
)
if channel.waiting and channel.newest_release:
channel_note = channel.message
# Give the branch tracking information so the next pull is a plain
# `git pull` — otherwise every update repeats the fallback.
if result.returncode == 0 and upstream_note:
branch = _git_current_branch(project_dir)
if branch:
try:
subprocess.run(
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
capture_output=True, text=True, timeout=10, cwd=project_dir)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("could not set upstream for %s: %s", branch, exc)
# Return custom response for git_pull
if result.returncode == 0:
pull_message = "Code updated successfully."
if has_changes:
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
# A checkout (a channel move) prints nothing; it always moved.
if (action != update_channel.ACTION_PULL
or (result.stdout and "Already up to date" not in result.stdout)):
pull_message = f"Code updated successfully.{stash_info}"
if upstream_note:
pull_message = f"{pull_message} {upstream_note}"
if channel_note:
pull_message = f"{pull_message} {channel_note}"
if channel.migrate and action == update_channel.ACTION_CHECKOUT_TAG:
_persist_stable_channel()
# Keep Python dependencies in sync automatically: if the pull
# changed a requirements file, install it now — users updating
# from the web UI (most of them) never SSH in to pip install.
# Installs go through the same root-visible path as the
# Tools-tab buttons (_pip_install_requirements).
dep_notes = []
try:
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
new_head = _post.stdout.strip() if _post.returncode == 0 else None
if old_head and new_head and old_head != new_head:
code_changed = True
diff = subprocess.run(
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
capture_output=True, text=True, timeout=15, cwd=project_dir)
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
for rel in CORE_REQUIREMENT_FILES:
req_path = PROJECT_ROOT / rel
if rel not in changed or not req_path.exists():
continue
# Each file's install is isolated: a timeout or
# OSError (e.g. the sudo wrapper/interpreter
# missing) on one file must not abort the other.
try:
r = _pip_install_requirements(req_path, timeout=180)
if r.returncode == 0:
dep_notes.append(f"Dependencies from {rel} updated.")
else:
dependency_failures.append(rel)
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install failed for %s: %s",
rel, _truncate_output(r.stdout, r.stderr))
except subprocess.TimeoutExpired:
dependency_failures.append(rel)
dep_notes.append(
f"Dependency install from {rel} timed out — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install timed out for %s", rel)
except OSError as install_err:
dependency_failures.append(rel)
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install errored for %s: %s",
rel, install_err)
except subprocess.TimeoutExpired:
logger.warning("post-update dependency sync timed out")
if dep_notes:
pull_message += " " + " ".join(dep_notes)
# A `git pull` restores built-in plugins (committed under
# plugin-repos/) even if the user uninstalled them. Re-remove
# any the user previously uninstalled so the update doesn't
# resurrect them.
if api_v3.plugin_store_manager:
try:
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
if purged:
logger.info(
"Re-removed %d uninstalled plugin(s) restored by update: %s",
len(purged), ", ".join(purged),
)
except (OSError, RuntimeError) as purge_err:
logger.warning("Post-update plugin purge failed: %s", purge_err)
# The new code may come with new systemd unit settings (systemd/*).
# Install them now, so the restart that follows runs under them; the
# automatic update's rollback puts the old ones back.
if code_changed:
from web_interface import unit_refresh
unit_result = unit_refresh.refresh_after_update()
if unit_result['message']:
pull_message += " " + unit_result['message']
else:
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
# Show git's own first line: "check logs" leaves the user with
# nothing to act on, and these failures are usually actionable
# (conflicting local commits, no upstream, network).
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
if ln.strip()), '')
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
# Nothing here restarts anything: the pull replaces files on
# disk while the display and web services keep running the code
# they loaded at boot. Without this the user is told the update
# succeeded and sees no change until they happen to reboot.
return {
'status': 'success' if result.returncode == 0 else 'error',
'message': pull_message,
'restart_required': bool(result.returncode == 0 and code_changed),
'dependency_failures': dependency_failures,
'channel': channel.channel,
# web_interface/unit_refresh.py's result, or None when no code changed.
'unit_refresh': unit_result,
}
@api_v3.route('/system/action', methods=['POST'])
def execute_system_action():
"""Execute system actions (start/stop/reboot/etc)"""
try:
data = request.get_json(silent=True)
if data is None and not request.is_json:
# Every caller in the interface sends JSON (the Quick Actions
# buttons use HTMX's json-enc). A form-encoded body is what a
# cross-site HTML form can send without a CORS preflight, and
# this route reboots, powers off and pulls code, so it is only
# accepted from HTMX: a cross-site form cannot set HX-Request.
# This backs up the app-wide Origin check (origin_guard.py).
if not request.headers.get('HX-Request'):
return jsonify({
'status': 'error',
'message': ('Send the action as JSON '
'(Content-Type: application/json), '
'e.g. {"action": "restart_display_service"}'),
}), 415
data = {
'action': request.form.get('action'),
'mode': request.form.get('mode')
}
if not isinstance(data, dict) or not data.get('action'):
return jsonify({'status': 'error', 'message': 'Action required'}), 400
action = data['action']
mode = data.get('mode') # For on-demand modes
# Map actions to subprocess calls (similar to original implementation)
if action == 'start_display':
if mode:
# For on-demand modes, we would need to integrate with the display controller
# For now, just start the display service
try:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
except subprocess.TimeoutExpired as e:
logger.error("start_display (%s) timed out: %s", mode, e)
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
logger.info("start_display (%s) returned code %d", mode, result.returncode)
if result.returncode != 0 and result.stderr:
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error',
# This branch returns before the shared nonzero-result
# response below, so it needs the hint of its own or an
# on-demand start reports "Failed to start display" and
# says nothing about the sudo that actually refused it.
'message': (
'Display started' if result.returncode == 0
else _sudo_hint_for(result.stderr) or 'Failed to start display'
),
}
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
return jsonify(resp)
else:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'stop_display':
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'enable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'disable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'reboot_system':
result = subprocess.run(['sudo', 'reboot'],
capture_output=True, text=True, timeout=10)
elif action == 'shutdown_system':
result = subprocess.run(['sudo', 'poweroff'],
capture_output=True, text=True, timeout=10)
elif action == 'git_pull':
return jsonify(perform_core_update())
elif action == 'checkout_branch':
# Switch branches from the Tools tab. Needed because a checkout
# that predates tracking (or a restored backup) can leave the pi
# on a branch the update button cannot pull.
result_payload, http_status = checkout_branch(
str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash')))
return jsonify(result_payload), http_status
elif action == 'restart_display_service':
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'restart_web_service':
# Try to restart the web service (assuming it's ledmatrix-web.service)
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
capture_output=True, text=True, timeout=10)
elif action == 'install_base_requirements':
# Base + web interface requirements: flask-compress and friends
# live in web_interface/requirements.txt, not the root file.
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
if f.exists()]
if not req_files:
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
outputs = []
all_ok = True
for req_file in req_files:
label = req_file.relative_to(PROJECT_ROOT)
# Isolate each file's install: a timeout or OSError on one
# (e.g. requirements.txt) must not abort the rest of the
# loop (e.g. web_interface/requirements.txt never attempted).
try:
result = _pip_install_requirements(req_file, timeout=120)
all_ok = all_ok and result.returncode == 0
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
except subprocess.TimeoutExpired:
all_ok = False
outputs.append(f"== {label} ==\nTimed out after 120s")
logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err:
all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({
'status': 'success' if all_ok else 'error',
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
'output': "\n".join(outputs)
})
elif action == 'install_plugin_requirements':
active_pm = getattr(api_v3, 'plugin_catalog', None)
if active_pm:
plugins_dir = Path(active_pm.plugins_dir)
else:
_cm = getattr(api_v3, 'config_manager', None)
_cfg = _cm.load_config() if _cm else {}
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
results = []
if plugins_dir.exists():
for p in sorted(plugins_dir.iterdir()):
req = p / 'requirements.txt'
if p.is_dir() and req.exists():
try:
r = _pip_install_requirements(req, timeout=60)
results.append({
'plugin': p.name,
'ok': r.returncode == 0,
'output': _truncate_output(r.stdout, r.stderr)
})
except subprocess.TimeoutExpired:
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
except OSError as exc:
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
ok_count = sum(1 for r in results if r['ok'])
all_ok = all(r['ok'] for r in results) if results else True
return jsonify({
'status': 'success' if all_ok else 'error',
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
'details': results
})
elif action == 'force_git_reset':
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
project_dir = str(PROJECT_ROOT)
fetch = subprocess.run(
[_GIT, 'fetch', 'origin'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
if fetch.returncode != 0:
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
reset = subprocess.run(
[_GIT, 'reset', '--hard', 'origin/main'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
return jsonify({
'status': 'success' if reset.returncode == 0 else 'error',
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
'output': (reset.stdout + reset.stderr).strip()
})
elif action == 'clear_pycache':
cleared = 0
failed = 0
for d in PROJECT_ROOT.rglob('__pycache__'):
if d.is_dir():
try:
shutil.rmtree(d)
cleared += 1
except OSError:
failed += 1
msg = f'Cleared {cleared} __pycache__ directories'
if failed:
msg += f' ({failed} could not be removed)'
return jsonify({'status': 'success', 'message': msg})
else:
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
logger.info("system action '%s' returncode=%d", action, result.returncode)
if result.returncode != 0 and result.stderr:
logger.error("system action '%s' stderr: %s", action, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details',
}
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
hint = _sudo_hint_for(result.stderr)
if hint:
resp['message'] = hint
return jsonify(resp)
except subprocess.TimeoutExpired as e:
logger.error("system action '%s' timed out: %s", action, e)
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True)
detail = describe_exception(e)
resp = {
'status': 'error',
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
'details': detail,
}
return jsonify(resp), 500
@api_v3.route('/system/git-info', methods=['GET'])
def get_git_info():
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
if branch.returncode != 0:
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
if status.returncode != 0:
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
branch_name = branch.stdout.strip()
upstream = _git_upstream(d)
current_release, channel_message = None, ''
if not branch_name:
# Detached is not always "on a release": a device that pulled
# main and was then detached is newer than the newest one.
# Local refs only; the panel must not wait on the network.
try:
channel, _ = channel_status(d, fetch=False)
current_release, channel_message = channel.current_release, channel.message
except Exception:
logger.debug("git-info: could not read the update channel", exc_info=True)
return jsonify({
'branch': branch_name,
# No branch: the stable update channel checks out release tags.
'detached': not branch_name,
'version': get_git_version(),
'current_release': current_release,
'channel_message': channel_message,
'dirty': bool(status.stdout.strip()),
'status': status.stdout.strip(),
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
# Surfaced so the Tools tab can warn before the user clicks Pull
# Latest, rather than after it fails.
'upstream': upstream,
'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name),
})
except Exception as e:
logger.error("get_git_info failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
@api_v3.route('/system/git-branches', methods=['GET'])
def get_git_branches():
"""List branches available to switch to, for the Tools tab picker."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
# Refresh remote refs so a branch created since the last fetch shows up.
subprocess.run([_GIT, 'fetch', 'origin', '--prune'],
capture_output=True, text=True, timeout=60, cwd=d)
local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'],
capture_output=True, text=True, timeout=15, cwd=d)
remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'],
capture_output=True, text=True, timeout=15, cwd=d)
if local.returncode != 0:
return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500
local_names = [b for b in local.stdout.split() if b]
remote_names = []
for ref in remote.stdout.split() if remote.returncode == 0 else []:
name = ref.split('origin/', 1)[-1]
# origin/HEAD is a symbolic alias, not a branch a user can pick.
if name and name != 'HEAD' and name not in local_names:
remote_names.append(name)
return jsonify({
'status': 'success',
'current': _git_current_branch(d),
'upstream': _git_upstream(d),
'local': sorted(local_names),
'remote_only': sorted(remote_names),
})
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504
except OSError as e:
logger.error("get_git_branches failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500