Files
LEDMatrix/web_interface/templates/v3/partials/general.html
T
ChuckandClaude Opus 5 869e36fb2f feat(web): weekly automatic updates with health check and rollback (#581)
* feat(web): weekly automatic updates with health check and rollback

A General-tab toggle (off by default) checks for and installs LEDMatrix and
plugin updates once a week, overnight in the configured timezone.

- Pre-update checks skip (and report) instead of forcing: local edits or
  commits, merge/live rebase, no upstream, low disk, missing health check, or
  a version that was already rolled back. An abandoned rebase (HEAD back on a
  branch) is cleared, since it would otherwise block every pull.
- The pull reuses the Update Code path (now perform_core_update(), which
  reports dependency install failures as data).
- ledmatrix-update-verify.service, started via a .path unit from a request
  file, restarts the services from its own cgroup, requires them to come up
  and stay up, and otherwise resets to the previous commit and reinstalls the
  previous requirements. It runs a copy of the checker taken before the pull.
- No SSH needed: switching the toggle on restarts the display service, which
  (as root) installs the two units from the repo templates for the web user.
  first_time_install.sh installs them too and takes --enable-auto-update /
  LEDMATRIX_AUTO_UPDATE (passed through by one-shot-install.sh).
- Plugins update after the code passes its check; failures, blocks and
  rollbacks raise an Overview banner and show under the toggle.

Tested end to end on a Pi: web-UI setup, a good update, a broken web service
and a broken display (both rolled back), a blocked local edit, and an
abandoned rebase found on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(auto-update): address static-analysis findings

- Replace the subprocess.CompletedProcess the verifier fabricated for a
  command that could not start with a plain namedtuple; nothing is executed
  there, but the scanner flags any CompletedProcess built from variables.
- Mark the subprocess imports with the repo's standard B404 annotation (all
  calls are list-form argv, no shell).
- Mark the rollback-failed message as not SQL (B608 matched its wording).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): CI failures on Linux

- Keep the setup result when chown fails. CI runs as a non-root user, where
  chown to the web user raises; that discarded the result file, so the
  General tab would never learn whether setup worked. Regression test added.
- Register the two new /api/v3/system/auto-update routes in the URL map
  snapshot.
- Use utility classes app.css defines (space-y-1, hover:text-red-600).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): address review feedback

- Health check: a failed restart command no longer lets the check run
  against the still-running old process; it counts as a failure (and after a
  rollback, as a failed rollback). An unreadable restart count is never
  treated as stable, since a crash loop looks healthy between attempts.
- Installer writes the auto_update setting to a temp file and swaps it in,
  keeping mode and owner, so a running config watcher never reads a
  truncated config.json.
- Verify unit quotes its command-line paths (install folders with spaces);
  setup refuses folder names systemd would reinterpret (%, quotes,
  backslashes, control characters) and says so on the General tab.
- The auto-update status route no longer returns exception text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): keep error detail in the status route's 500

test_web_error_detail requires every 5xx handler to log the traceback and
return describe_exception(e), which redacts credentials, so failures are
diagnosable from the web UI. Dropping it for CodeQL broke that policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): dismiss route rejects non-object JSON with 400

A JSON array or scalar body made `.get('alert_id')` raise, returning 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): let the app-wide handler answer status-route errors

CodeQL (py/stack-trace-exposure, #709) flagged the route's own except,
which returned describe_exception(e). web_interface/app.py's error handler
already logs the traceback and returns the same redacted detail for any
unhandled exception, so the local copy is removed: same response, no new
exception-to-response flow, and test_web_error_detail's policy still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:58:57 -04:00

231 lines
14 KiB
HTML

{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure general system settings and location information.</p>
</div>
{{ ui.settings_filter() }}
<form hx-post="/api/v3/config/main"
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="
var xhr = event.detail.xhr;
var isSuccess = xhr.status >= 200 && xhr.status < 300;
var message = '';
var status = 'success';
try {
var data = JSON.parse(xhr.responseText);
message = data.message || '';
status = data.status || status;
} catch (e) {}
if (isSuccess) {
message = message || 'Settings saved';
} else {
message = message || 'Failed to save settings';
status = 'error';
}
showNotification(message, status);
"
class="space-y-6">
<!-- Web Display Autostart -->
<div class="form-group" id="setting-general-web_display_autostart" data-setting-key="web_display_autostart">
<label class="flex items-center">
<input type="checkbox"
name="web_display_autostart"
value="true"
{% if main_config.web_display_autostart %}checked{% endif %}
class="form-control 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">Web Display Autostart</span>
{{ ui.help_tip('Automatically start the web interface when the device boots.\nDefault: on. Turn off if you launch the web UI manually or run headless.', 'Web Display Autostart') }}
</label>
</div>
<!-- Weekly Automatic Updates -->
<div class="form-group" id="setting-general-auto_update" data-setting-key="auto_update.enabled">
<label class="flex items-center">
<input type="checkbox"
name="auto_update_enabled"
value="true"
{% if (main_config.auto_update or {}).enabled %}checked{% endif %}
class="form-control 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">Automatically check for and install updates once a week</span>
{{ ui.help_tip('Once a week, update LEDMatrix and every installed plugin that has a newer version.\nSkipped (and reported) if you have local changes, low disk space, or the rollback service is missing. After updating, the services are restarted and checked; if they do not stay healthy, the update is rolled back automatically.\nRuns overnight (2-5 AM in your timezone) when possible. Problems show as a banner on Overview. A stopped display is not started. Default: off.', 'Automatic Updates') }}
</label>
{% if auto_update_status and (main_config.auto_update or {}).enabled and auto_update_status.verifier_installed is sameas false %}
<p class="mt-1 ml-6 text-xs text-amber-700">
{% if auto_update_status.setup_status == 'failed' %}
Setting up the update health check failed: {{ auto_update_status.setup_message }}
LEDMatrix code updates are paused until it is fixed; plugin updates still run.
{% else %}
Setting up the update health check. The display service does this when it starts, so it is
ready shortly after saving (or once the display is started). LEDMatrix code updates begin after that.
{% endif %}
</p>
{% endif %}
{% if auto_update_status and auto_update_status.verifying %}
<p class="mt-1 ml-6 text-xs text-blue-700">An update was just installed and its health check is running.</p>
{% endif %}
{% if auto_update_status and (auto_update_status.last_run or auto_update_status.next_due) %}
<div class="mt-1 ml-6 text-xs text-gray-500 space-y-1">
{% if auto_update_status.last_run %}
<p>Last automatic update: {{ auto_update_status.last_run }}
<span class="{{ 'text-red-600' if auto_update_status.status == 'error' else '' }}">— {{ auto_update_status.summary }}</span></p>
{% endif %}
{% if auto_update_status.next_due %}
<p>Next check: {{ auto_update_status.next_due }} or the first overnight window after it</p>
{% endif %}
</div>
{% endif %}
</div>
<!-- Timezone -->
<div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
<div id="timezone_container" class="mt-1"></div>
</div>
<!-- Scroll frame rate (device-wide) -->
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
<input type="number"
id="target_fps"
name="target_fps"
value="{{ main_config.target_fps or 100 }}"
min="30"
max="200"
class="form-control">
</div>
<script>
(function() {
// Track if already initialized to prevent re-render
if (window.__timezoneWidgetInitialized) return;
function initTimezoneWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initTimezoneWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('timezone-selector');
if (!widget) { setTimeout(initTimezoneWidget, 50); return; }
var container = document.getElementById('timezone_container');
if (!container) return;
// Only render if container is empty (not already rendered)
if (container.children.length > 0) return;
widget.render(container, {
'x-options': { showOffset: true, placeholder: 'Select your timezone...' }
}, {{ (main_config.timezone or "America/Chicago")|tojson }}, {
fieldId: 'timezone',
name: 'timezone'
});
window.__timezoneWidgetInitialized = true;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTimezoneWidget);
} else {
setTimeout(initTimezoneWidget, 50);
}
})();
</script>
<!-- Location Information -->
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
<div class="form-group" id="setting-general-city" data-setting-key="location.city">
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, radar, and other location-based content.\nExample: Kansas City.\nUsed as the default for the location_city setting on plugins that have one; a value saved on the plugin itself overrides it.', 'City') }}</label>
<input type="text"
id="city"
name="city"
value="{{ main_config.location.city or 'Dallas' }}"
class="form-control">
</div>
<div class="form-group" id="setting-general-state" data-setting-key="location.state">
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Missouri. Improves location-lookup accuracy.\nUsed as the default for the location_state setting on plugins that have one.', 'State') }}</label>
<input type="text"
id="state"
name="state"
value="{{ main_config.location.state or 'Texas' }}"
class="form-control">
</div>
<div class="form-group" id="setting-general-country" data-setting-key="location.country">
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather, radar, and geolocation.\nUsed as the default for the location_country setting on plugins that have one.', 'Country') }}</label>
<input type="text"
id="country"
name="country"
value="{{ main_config.location.country or 'US' }}"
class="form-control">
</div>
</div>
<!-- Plugin System Settings -->
<div class="border-t border-gray-200 pt-6 mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Plugin System Settings</h3>
<p class="text-sm text-gray-600 mb-4">Configure the core plugin system behavior.</p>
<div class="space-y-4">
<!-- Auto Discover -->
<div class="form-group" id="setting-general-auto_discover" data-setting-key="plugin_system.auto_discover">
<label class="flex items-center">
<input type="checkbox"
name="auto_discover"
value="true"
{% if main_config.get('plugin_system', {}).get('auto_discover', True) %}checked{% endif %}
class="form-control 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">Auto Discover Plugins</span>
{{ ui.help_tip('Scan the plugins directory for installed plugins each time the service starts.\nDefault: on. Leave on unless you manage plugins manually.', 'Auto Discover Plugins') }}
</label>
</div>
<!-- Auto Load Enabled -->
<div class="form-group" id="setting-general-auto_load_enabled" data-setting-key="plugin_system.auto_load_enabled">
<label class="flex items-center">
<input type="checkbox"
name="auto_load_enabled"
value="true"
{% if main_config.get('plugin_system', {}).get('auto_load_enabled', True) %}checked{% endif %}
class="form-control 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">Auto Load Enabled Plugins</span>
{{ ui.help_tip('Load every plugin marked enabled in the configuration at startup.\nDefault: on. Turn off to keep plugins installed but dormant.', 'Auto Load Enabled Plugins') }}
</label>
</div>
<!-- Development Mode -->
<div class="form-group" id="setting-general-development_mode" data-setting-key="plugin_system.development_mode">
<label class="flex items-center">
<input type="checkbox"
name="development_mode"
value="true"
{% if main_config.get('plugin_system', {}).get('development_mode', False) %}checked{% endif %}
class="form-control 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">Development Mode</span>
{{ ui.help_tip('Enable verbose logging and developer features for plugin debugging.\nDefault: off. Keep off for normal use — it increases log volume.', 'Development Mode') }}
</label>
</div>
<!-- Plugins Directory -->
<div class="form-group" id="setting-general-plugins_directory" data-setting-key="plugin_system.plugins_directory">
<label for="plugins_directory" class="block text-sm font-medium text-gray-700">Plugins Directory{{ ui.help_tip('Folder (relative to the project root) where plugins are stored.\nDefault: plugin-repos. Only change this if you keep plugins in a custom location.', 'Plugins Directory') }}</label>
<input type="text"
id="plugins_directory"
name="plugins_directory"
value="{{ main_config.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') }}"
placeholder="plugin-repos"
class="form-control">
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end mt-6">
<button type="submit"
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-save mr-2"></i>
Save General Settings
</button>
</div>
</form>
</div>