3 Commits

Author SHA1 Message Date
Chuck
fbe4628a38 fix(lint): declare updateDisplayPreview in ESLint global comment
Codacy flagged 'updateDisplayPreview is not defined' at app.js:73.
The function is defined in base.html and already guarded with
typeof check, matching the existing updateSystemStats pattern — it
just wasn't listed in the /* global */ declaration at the top of the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:48:16 -04:00
Chuck
9914e26ca0 fix(web-ui): harden SSE broadcaster — drop-oldest on full queue, exit on no subscribers, reattach reconnect handlers
- _broadcast: on queue.Full drop the oldest item and retry the put
  instead of removing the client from _clients — a slow tab now stays
  subscribed and receives the latest data rather than being silently
  ejected
- _broadcast: break instead of continue when _clients is empty so the
  background generator thread exits rather than spinning indefinitely;
  subscribe() already restarts it on the next connection
- base.html: expose _statsOpenHandler, _statsErrorHandler, and
  _displayErrorHandler as window properties so reconnectSSE() can
  reattach them after replacing the EventSource instances
- app.js: reconnectSSE() now reattaches those handlers after creating
  each new EventSource so the status badge and display-stream console
  logging survive a manual reconnect

Heartbeat path (~line 646) is a queue read (q.get), not a write; no
queue.Full can occur there so no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:47:16 -04:00
Chuck
704e99f55c fix(web-ui): support multiple browser tabs via SSE broadcaster pattern
Each SSE stream (stats, display preview, logs) previously ran a separate
generator per connected client, so two open tabs meant double the PIL
image encodes per second and double the journalctl subprocesses. Under
load or on reconnect storms the tight "20 per minute" rate limit was
easily exhausted, silently breaking tabs without any user-facing
explanation.

- Replace per-client sse_response generators with _StreamBroadcaster:
  one background thread per stream type fans data to all subscribed
  client queues, keeping CPU/subprocess work constant regardless of
  how many tabs are open
- Add 30-second SSE heartbeat comments to keep idle connections alive
  through proxies
- Raise SSE rate limit from "20/min" to "200/min" to prevent reconnect
  storms from exhausting the limit
- Assign statsSource/displaySource to window.* so reconnectSSE() in
  app.js can actually reach them (was dead code due to const scoping)
- Add displaySource error handler so display preview failures are no
  longer completely silent
- Improve connection status badge: shows "Reconnecting…" on first few
  errors, "Disconnected" with tooltip hint after persistent failure
- Complete the empty displaySource.onmessage stub in reconnectSSE()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:16:47 -04:00
3 changed files with 65 additions and 163 deletions

View File

@@ -151,18 +151,6 @@ class WiFiManager:
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, " f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}") f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
# Once per process: remove a stale force-AP flag left by a prior crash.
# Guard with a class-level flag so the nmcli AP-state check only runs
# once even though WiFiManager is instantiated per-request.
if not WiFiManager._startup_cleanup_done:
WiFiManager._startup_cleanup_done = True
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
try:
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.debug("Removed stale force-AP flag on startup (AP not active)")
except OSError as exc:
logger.warning(f"Could not remove stale force-AP flag: {exc}")
def _show_led_message(self, message: str, duration: int = 5): def _show_led_message(self, message: str, duration: int = 5):
""" """
Show a WiFi status message on the LED display. Show a WiFi status message on the LED display.
@@ -486,10 +474,7 @@ class WiFiManager:
if result.returncode == 0: if result.returncode == 0:
for line in result.stdout.strip().split('\n'): for line in result.stdout.strip().split('\n'):
if '/' in line: if '/' in line:
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix"; ip_address = line.split('/')[0].strip()
# bare "x.x.x.x/prefix" is also accepted defensively.
_, sep, rest = line.partition(':')
ip_address = (rest if sep else line).split('/')[0].strip()
break break
# Final fallback: Get signal strength by matching SSID in WiFi list # Final fallback: Get signal strength by matching SSID in WiFi list
@@ -515,13 +500,6 @@ class WiFiManager:
# Check if AP mode is active # Check if AP mode is active
ap_active = self._is_ap_mode_active() ap_active = self._is_ap_mode_active()
# wlan0 shows as "connected" in AP mode; clear client-station fields so
# callers don't mistake the AP for an outbound WiFi connection.
if ap_active and wifi_connected:
wifi_connected = False
ssid = None
ip_address = None
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
return WiFiStatus( return WiFiStatus(
connected=wifi_connected, connected=wifi_connected,
@@ -712,10 +690,6 @@ class WiFiManager:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi _IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _validate_ap_config(self) -> Tuple[str, int]: def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults.""" """Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
@@ -1393,7 +1367,7 @@ class WiFiManager:
logger.error(f"Failed to restore original connection: {original_ssid}") logger.error(f"Failed to restore original connection: {original_ssid}")
# Trigger AP mode as last resort # Trigger AP mode as last resort
self._show_led_message("Enabling AP mode...", duration=5) self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode(force=True) ap_success, ap_msg = self.enable_ap_mode()
if ap_success: if ap_success:
logger.info("AP mode enabled as failsafe") logger.info("AP mode enabled as failsafe")
return False, "Connection failed and restoration failed. AP mode enabled." return False, "Connection failed and restoration failed. AP mode enabled."
@@ -1405,7 +1379,7 @@ class WiFiManager:
elif not success: elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore") logger.warning(f"Connection to {ssid} failed and no original connection to restore")
self._show_led_message("Enabling AP mode...", duration=5) self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode(force=True) ap_success, ap_msg = self.enable_ap_mode()
if ap_success: if ap_success:
logger.info("AP mode enabled as failsafe") logger.info("AP mode enabled as failsafe")
return False, "Connection failed. AP mode enabled." return False, "Connection failed. AP mode enabled."
@@ -1426,7 +1400,7 @@ class WiFiManager:
logger.error(f"Failed to restore after exception: {restore_error}") logger.error(f"Failed to restore after exception: {restore_error}")
# Last resort: enable AP mode # Last resort: enable AP mode
try: try:
self.enable_ap_mode(force=True) self.enable_ap_mode()
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True) logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
return False, str(e) return False, str(e)
@@ -1490,29 +1464,26 @@ class WiFiManager:
# Show LED message # Show LED message
self._show_led_message(f"Connecting to {ssid}...", duration=10) self._show_led_message(f"Connecting to {ssid}...", duration=10)
# Find existing NM connection for this SSID. # First, check if connection already exists and try to activate it
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show', # NetworkManager connection names might not match SSID exactly, so search by SSID
# so list all wifi connections then query each one's SSID individually. check_result = subprocess.run(
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input ["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], capture_output=True,
capture_output=True, text=True, timeout=5 text=True,
timeout=5
) )
existing_conn_name = None existing_conn_name = None
if list_result.returncode == 0: if check_result.returncode == 0:
for line in list_result.stdout.strip().split('\n'): for line in check_result.stdout.strip().split('\n'):
if ':' not in line: if ':' in line:
continue parts = line.split(':')
parts = line.split(':') if len(parts) >= 2:
if len(parts) < 2 or parts[1].strip() != '802-11-wireless': conn_name = parts[0].strip()
continue conn_ssid = parts[1].strip() if len(parts) > 1 else ""
conn_name = parts[0].strip() if conn_ssid == ssid:
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input existing_conn_name = conn_name
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name], break
capture_output=True, text=True, timeout=5
)
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
existing_conn_name = conn_name
break
# Also try direct lookup by SSID (in case connection name matches SSID) # Also try direct lookup by SSID (in case connection name matches SSID)
if not existing_conn_name: if not existing_conn_name:
@@ -1884,7 +1855,7 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts") logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False return False
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]: def enable_ap_mode(self) -> Tuple[bool, str]:
""" """
Enable access point mode Enable access point mode
@@ -1906,29 +1877,20 @@ class WiFiManager:
if not self._ensure_wifi_radio_enabled(): if not self._ensure_wifi_radio_enabled():
return False, "WiFi radio is disabled and could not be enabled" return False, "WiFi radio is disabled and could not be enabled"
# Check if WiFi is connected (skip when force=True) # Check if WiFi is connected
status = self.get_wifi_status() status = self.get_wifi_status()
if not force and status.connected: if status.connected:
return False, "Cannot enable AP mode while WiFi is connected" return False, "Cannot enable AP mode while WiFi is connected"
# Check if Ethernet is connected (skip when force=True) # Check if Ethernet is connected
if not force and self._is_ethernet_connected(): if self._is_ethernet_connected():
return False, "Cannot enable AP mode while Ethernet is connected" return False, "Cannot enable AP mode while Ethernet is connected"
if force:
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
# Try hostapd/dnsmasq first (captive portal mode) # Try hostapd/dnsmasq first (captive portal mode)
if self.has_hostapd and self.has_dnsmasq: if self.has_hostapd and self.has_dnsmasq:
result = self._enable_ap_mode_hostapd() result = self._enable_ap_mode_hostapd()
if result[0]: if result[0]:
self._ap_enabled_at = time.time() self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result return result
# Fallback to nmcli hotspot (simpler, no captive portal) # Fallback to nmcli hotspot (simpler, no captive portal)
@@ -1938,12 +1900,6 @@ class WiFiManager:
result = self._enable_ap_mode_nmcli_hotspot() result = self._enable_ap_mode_nmcli_hotspot()
if result[0]: if result[0]:
self._ap_enabled_at = time.time() self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result return result
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)" return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
@@ -2135,14 +2091,8 @@ class WiFiManager:
self._clear_led_message() self._clear_led_message()
return False, "AP started but captive-portal redirect setup failed" return False, "AP started but captive-portal redirect setup failed"
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation) # Verify the AP is actually running
status = {} status = self._get_ap_status_nmcli()
for _attempt in range(5):
status = self._get_ap_status_nmcli()
if status.get('active'):
break
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
time.sleep(2)
if status.get('active'): if status.get('active'):
ip = status.get('ip', '192.168.4.1') ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip} (open network, no password)") logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
@@ -2340,7 +2290,6 @@ class WiFiManager:
logger.warning("WiFi radio may be disabled after nmcli AP cleanup") logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
self._ap_enabled_at = None self._ap_enabled_at = None
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.info("AP mode disabled successfully") logger.info("AP mode disabled successfully")
return True, "AP mode disabled" return True, "AP mode disabled"
except Exception as e: except Exception as e:
@@ -2529,29 +2478,22 @@ address=/detectportal.firefox.com/192.168.4.1
else: else:
logger.warning(f"Failed to enable AP mode: {message}") logger.warning(f"Failed to enable AP mode: {message}")
elif not should_have_ap and ap_active: elif not should_have_ap and ap_active:
# Should not have AP but do - check if it was manually force-enabled # Should not have AP but do - disable AP mode
force_active = self._FORCE_AP_FLAG_PATH.exists() # Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
if status.connected: if status.connected or ethernet_connected:
# WiFi connected: always disable AP (user successfully configured WiFi)
success, message = self.disable_ap_mode() success, message = self.disable_ap_mode()
if success: if success:
logger.info("Auto-disabled AP mode (WiFi connected)") if status.connected:
self._disconnected_checks = 0 logger.info("Auto-disabled AP mode (WiFi connected)")
elif ethernet_connected:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0 # Reset counter
return True return True
else: else:
logger.warning(f"Failed to auto-disable AP mode: {message}") logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and not force_active:
# Ethernet connected, AP not manually forced: auto-disable
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and force_active:
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
elif not auto_enable: elif not auto_enable:
# AP is active but auto_enable is disabled - this means it was manually enabled
# Don't disable it automatically, let it stay active
logger.debug("AP mode is active (manually enabled), keeping active") logger.debug("AP mode is active (manually enabled), keeping active")
# Idle-timeout check: disable AP if no client has connected within the window. # Idle-timeout check: disable AP if no client has connected within the window.

View File

@@ -3,7 +3,6 @@ import json
import logging import logging
import os import os
import queue import queue
import shutil
import sys import sys
import subprocess import subprocess
import threading import threading
@@ -25,9 +24,6 @@ from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.operation_history import OperationHistory from src.plugin_system.operation_history import OperationHistory
from src.plugin_system.health_monitor import PluginHealthMonitor from src.plugin_system.health_monitor import PluginHealthMonitor
_JOURNALCTL = shutil.which('journalctl')
_SYSTEMCTL = shutil.which('systemctl')
# Create Flask app # Create Flask app
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.urandom(24) app.secret_key = os.urandom(24)
@@ -496,13 +492,12 @@ def system_status_generator():
# Check if display service is running (cached to avoid per-client subprocess forks) # Check if display service is running (cached to avoid per-client subprocess forks)
now = time.time() now = time.time()
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL: if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
if _SYSTEMCTL: try:
try: result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'], capture_output=True, text=True, timeout=2)
capture_output=True, text=True, timeout=2) _ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active' except (subprocess.SubprocessError, OSError):
except (subprocess.SubprocessError, OSError) as e: pass
app.logger.warning("systemctl status check failed: %s", e)
_ledmatrix_service_cache['timestamp'] = now _ledmatrix_service_cache['timestamp'] = now
service_active = _ledmatrix_service_cache['active'] service_active = _ledmatrix_service_cache['active']
@@ -594,13 +589,8 @@ def logs_generator():
# Get recent logs from journalctl (simplified version) # Get recent logs from journalctl (simplified version)
# Note: User should be in systemd-journal group to read logs without sudo # Note: User should be in systemd-journal group to read logs without sudo
try: try:
if not _JOURNALCTL:
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
time.sleep(60)
continue
result = subprocess.run( result = subprocess.run(
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service', ['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
'-n', '50', '--no-pager', '--output=short-iso'],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5
) )
@@ -616,7 +606,7 @@ def logs_generator():
# No logs available # No logs available
logs_data = { logs_data = {
'timestamp': time.time(), 'timestamp': time.time(),
'logs': 'No logs available from ledmatrix or ledmatrix-web service' 'logs': 'No logs available from ledmatrix service'
} }
yield logs_data yield logs_data
else: else:

View File

@@ -4,7 +4,6 @@ import os
import re import re
import stat import stat
import sys import sys
import shutil
import subprocess import subprocess
import tempfile import tempfile
import time import time
@@ -26,9 +25,6 @@ from src.web_interface.validators import (
) )
from src.error_aggregator import get_error_aggregator from src.error_aggregator import get_error_aggregator
_SUDO = shutil.which('sudo')
_JOURNALCTL = shutil.which('journalctl')
# Will be initialized when blueprint is registered # Will be initialized when blueprint is registered
config_manager = None config_manager = None
plugin_manager = None plugin_manager = None
@@ -1460,41 +1456,31 @@ def execute_system_action():
if mode: if mode:
# For on-demand modes, we would need to integrate with the display controller # For on-demand modes, we would need to integrate with the display controller
# For now, just start the display service # For now, just start the display service
try: result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], capture_output=True, text=True)
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) logger.info("start_display (%s) returned code %d", mode, result.returncode)
if result.returncode != 0 and result.stderr: return jsonify({
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error', 'status': 'success' if result.returncode == 0 else 'error',
'message': 'Display started' if result.returncode == 0 else 'Failed to start display', 'message': 'Display started' if result.returncode == 0 else 'Failed to start display',
} })
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
return jsonify(resp)
else: else:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
elif action == 'stop_display': elif action == 'stop_display':
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
elif action == 'enable_autostart': elif action == 'enable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
elif action == 'disable_autostart': elif action == 'disable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
elif action == 'reboot_system': elif action == 'reboot_system':
result = subprocess.run(['sudo', 'reboot'], result = subprocess.run(['sudo', 'reboot'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
elif action == 'shutdown_system': elif action == 'shutdown_system':
result = subprocess.run(['sudo', 'poweroff'], result = subprocess.run(['sudo', 'poweroff'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
elif action == 'git_pull': elif action == 'git_pull':
# Use PROJECT_ROOT instead of hardcoded path # Use PROJECT_ROOT instead of hardcoded path
project_dir = str(PROJECT_ROOT) project_dir = str(PROJECT_ROOT)
@@ -1569,29 +1555,20 @@ def execute_system_action():
}) })
elif action == 'restart_display_service': elif action == 'restart_display_service':
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
elif action == 'restart_web_service': elif action == 'restart_web_service':
# Try to restart the web service (assuming it's ledmatrix-web.service) # Try to restart the web service (assuming it's ledmatrix-web.service)
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'], result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True)
else: else:
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400 return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
logger.info("system action '%s' returncode=%d", action, result.returncode) logger.info("system action '%s' returncode=%d", action, result.returncode)
if result.returncode != 0 and result.stderr: return jsonify({
logger.error("system action '%s' stderr: %s", action, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error', 'status': 'success' if result.returncode == 0 else 'error',
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details', '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()
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: except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True) logger.error("execute_system_action failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
@@ -6448,14 +6425,9 @@ def list_plugin_assets():
def get_logs(): def get_logs():
"""Get system logs from journalctl""" """Get system logs from journalctl"""
try: try:
if not _JOURNALCTL:
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
# Get recent logs from journalctl # Get recent logs from journalctl
_cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [
'-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
'-n', '100', '--no-pager', '--output=short-iso']
result = subprocess.run( result = subprocess.run(
_cmd, ['sudo', 'journalctl', '-u', 'ledmatrix.service', '-n', '100', '--no-pager'],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=5 timeout=5
@@ -6466,7 +6438,7 @@ def get_logs():
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'data': { 'data': {
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service' 'logs': logs_text if logs_text else 'No logs available from ledmatrix service'
} }
}) })
else: else:
@@ -6570,7 +6542,7 @@ def scan_wifi_networks():
ap_was_active = wifi_manager._is_ap_mode_active() ap_was_active = wifi_manager._is_ap_mode_active()
# Perform the scan (this will handle AP mode disabling/enabling internally) # Perform the scan (this will handle AP mode disabling/enabling internally)
networks, _was_cached = wifi_manager.scan_networks() networks = wifi_manager.scan_networks()
# Convert to dict format # Convert to dict format
networks_data = [ networks_data = [
@@ -6708,9 +6680,7 @@ def enable_ap_mode():
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
_force_raw = (request.get_json(silent=True) or {}).get('force', False) success, message = wifi_manager.enable_ap_mode()
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
success, message = wifi_manager.enable_ap_mode(force=force)
if success: if success:
return jsonify({ return jsonify({