1 Commits

Author SHA1 Message Date
Chuck
ea95f37d73 fix(reconciler): add sync, github, youtube to _SYSTEM_CONFIG_KEYS (#351)
config_manager.load_config() deep-merges config_secrets.json into the
main config before returning it. This means secrets top-level keys
(github, youtube) appear alongside structural config keys (sync) in the
dict that _get_config_state() iterates.

_SYSTEM_CONFIG_KEYS was missing all three, so the reconciler treated them
as plugin IDs and flagged them as PLUGIN_MISSING_ON_DISK on every startup,
showing the "Stale plugin config entries found" warning banner to users on
a fresh install where those plugins have never existed.

Add the three keys with brief comments explaining which file each comes
from so the distinction is clear when the list grows.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:09:26 -04:00
3 changed files with 31 additions and 63 deletions

View File

@@ -185,13 +185,19 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}" message=f"Reconciliation failed: {str(e)}"
) )
# Top-level config keys that are NOT plugins # Top-level config keys that are NOT plugins.
# Includes both config.json structural keys and config_secrets.json top-level
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
_SYSTEM_CONFIG_KEYS = frozenset({ _SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display', 'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width', 'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging', 'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule', 'dim_schedule', 'network', 'system', 'schedule',
# Multi-display sync config (config.json structural key)
'sync',
# Secrets file top-level keys (merged in by load_config)
'github', 'youtube',
}) })
def _get_config_state(self) -> Dict[str, Dict[str, Any]]: def _get_config_state(self) -> Dict[str, Dict[str, Any]]:

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: