Files
LEDMatrix/web_interface/templates/v3/base.html
T
ChuckandClaude Opus 5.5 7804ea8f69 feat(update): stable/beta update channel; stable follows release tags (#684)
Adds auto_update.channel: stable follows the newest vX.Y.Z release tag
(detached HEAD; pre-releases and other tags ignored), beta follows main as
before. Nothing ever moves a device backwards: a checkout newer than the
newest release keeps following main (or stays put when detached) until a
release contains its commit. Legacy configs migrate to stable when they
reach a release. Update Code, the weekly updater's preflight, and the
verifier's rollback (back to old_ref: branch or detached release) all
honour the channel. General tab Update Channel select, GET/POST
/api/v3/system/update-channel, release-aware Overview banner and Tools git
panel. New installs default to stable.

Rig fix (ledpi): /system/check-update reports update_available: false when
the channel's action is none (a detached HEAD newer than the newest
release), matching Update Code; the Tools panel no longer calls every
detached HEAD "a release".

Merged with main through #687 (heartbeat verifier, #683 login, #688
plugin_catalog, #685 Tailwind build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:35:26 -04:00

1137 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Matrix Control Panel</title>
<!-- Installable web app (Add to Home Screen) -->
<link rel="manifest" href="{{ url_for('static', filename='v3/manifest.json') }}">
<meta name="theme-color" content="#111827">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='v3/icons/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='v3/icons/icon-192.png') }}">
<!-- Debug logging gate: verbose console output is suppressed unless
localStorage.pluginDebug === 'true' (the same switch the plugin
scripts already use). console.error/warn are never gated. -->
<script>
window.LEDMATRIX_DEBUG = (function() {
try { return localStorage.getItem('pluginDebug') === 'true'; } catch (e) { return false; }
})();
window.debugLog = function() {
if (window.LEDMATRIX_DEBUG) console.log.apply(console, arguments);
};
</script>
<!-- Optional web login (web_interface/auth.py): a fetch() answered 401
with X-LEDMatrix-Login -- the session ran out, or login was turned on
from another browser -- goes to the login page instead of leaving
every panel showing an error. HTMX requests follow HX-Redirect on
their own. Only a same-origin answer can expose that header. -->
<script>
(function() {
if (typeof window.fetch !== 'function') return;
var originalFetch = window.fetch;
window.fetch = function() {
return originalFetch.apply(this, arguments).then(function(response) {
var login = response.status === 401 && response.headers.get('X-LEDMatrix-Login');
if (login && login.charAt(0) === '/' && login.charAt(1) !== '/') {
window.location.assign(login);
}
return response;
});
};
})();
</script>
<!-- Theme initialization (must run before CSS to prevent flash) -->
<script>
(function() {
// Safely read from localStorage (may throw in private browsing / restricted contexts)
function getStorage(key) {
try { return localStorage.getItem(key); } catch (e) { return null; }
}
function setStorage(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* no-op */ }
}
// Safely query prefers-color-scheme (matchMedia may be unavailable)
function prefersDark() {
try {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
} catch (e) { return false; }
}
var saved = getStorage('theme');
var theme = saved || (prefersDark() ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
// Theme toggle function
window.toggleTheme = function() {
try {
var current = document.documentElement.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
setStorage('theme', next);
window.updateThemeIcon(next);
} catch (e) { /* no-op */ }
};
// Update icon visibility and ARIA state based on current theme
window.updateThemeIcon = function(theme) {
var darkIcon = document.getElementById('theme-icon-dark');
var lightIcon = document.getElementById('theme-icon-light');
var btn = document.getElementById('theme-toggle');
if (darkIcon && lightIcon) {
if (theme === 'dark') {
darkIcon.classList.add('hidden');
lightIcon.classList.remove('hidden');
} else {
darkIcon.classList.remove('hidden');
lightIcon.classList.add('hidden');
}
}
if (btn) {
var isDark = theme === 'dark';
btn.setAttribute('aria-pressed', String(isDark));
var label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
};
// Initialize icon state once DOM is ready
document.addEventListener('DOMContentLoaded', function() {
window.updateThemeIcon(document.documentElement.getAttribute('data-theme') || 'light');
});
// Listen for OS theme changes (only when no explicit user preference)
try {
var mql = window.matchMedia('(prefers-color-scheme: dark)');
var handler = function(e) {
if (!getStorage('theme')) {
var t = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', t);
window.updateThemeIcon(t);
}
};
if (mql.addEventListener) {
mql.addEventListener('change', handler);
} else if (mql.addListener) {
mql.addListener(handler);
}
} catch (e) { /* matchMedia unavailable */ }
})();
</script>
<!-- HTMX for dynamic content loading.
Local (vendored) files are the primary source for every network —
LAN-speed loads, fully offline-capable; the pinned CDN copies remain
only as a last-ditch rescue if a local file somehow fails. -->
<script>
(function() {
const htmxSrc = '/static/v3/js/htmx.min.js';
const jsonEncSrc = '/static/v3/js/htmx-json-enc.js';
const htmxCdn = 'https://unpkg.com/htmx.org@1.9.10';
const jsonEncCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js';
// Load a script with an optional one-shot fallback source
function loadScript(src, fallback, onLoad) {
const script = document.createElement('script');
script.src = src;
script.onload = onLoad || (() => {});
script.onerror = function() {
if (fallback && src !== fallback) {
console.warn(`Failed to load ${src}, trying fallback ${fallback}`);
const fallbackScript = document.createElement('script');
fallbackScript.src = fallback;
fallbackScript.onload = onLoad || (() => {});
document.head.appendChild(fallbackScript);
} else {
console.error(`Failed to load script: ${src}`);
}
};
document.head.appendChild(script);
}
function htmxReady() {
debugLog('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready'));
// Load the json-enc extension after core loads
loadScript(jsonEncSrc, jsonEncCdn);
}
// Load HTMX core (local first, CDN rescue), then the extensions
loadScript(htmxSrc, htmxCdn, function() {
// Wait a moment for HTMX to initialize, then verify
setTimeout(function() {
if (typeof htmx !== 'undefined') {
htmxReady();
return;
}
// Loaded but not initialized (e.g. corrupt cached copy):
// one CDN rescue attempt, mirroring the Alpine fallback.
console.error('HTMX not initialized after load, trying CDN rescue...');
loadScript(htmxCdn, null, function() {
setTimeout(function() {
if (typeof htmx !== 'undefined') {
htmxReady();
} else {
console.error('HTMX failed to load from both local and CDN sources');
// Trigger fallback content loading
window.dispatchEvent(new Event('htmx-load-failed'));
}
}, 100);
});
}, 100);
});
})();
</script>
<script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script>
<!-- Direct-fetch fallback for tab panels. Used when HTMX never loads, and
by the hx-on::response-error handlers on the Overview, WiFi and
Plugin Manager panels when HTMX's own request fails. -->
<script>
(function() {
const PANEL_LABELS = {
'overview-content': 'the Overview',
'wifi-content': 'WiFi setup',
'plugins-content': 'the Plugin Manager'
};
// Fetches `url` into the element `id`, runs the partial's inline
// scripts, and lets HTMX and Alpine process the new markup. The
// scripts run before Alpine sees the content for the same reason
// htmx-config.js runs them on htmx:afterSwap: partials define their
// x-data component functions in those scripts.
window.loadPartialDirect = function(id, url) {
const el = document.getElementById(id);
if (!el || !url || el.hasAttribute('data-loaded') || el.hasAttribute('data-direct-loading')) return;
el.setAttribute('data-direct-loading', 'true');
debugLog('Loading ' + url + ' directly via fetch (HTMX fallback)');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
el.innerHTML = html;
el.querySelectorAll('script').forEach(oldScript => {
const script = document.createElement('script');
for (const attr of oldScript.attributes) script.setAttribute(attr.name, attr.value);
script.textContent = oldScript.textContent;
oldScript.replaceWith(script);
});
el.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') htmx.process(el);
if (window.Alpine) window.Alpine.initTree(el);
if (id === 'plugins-content' && window.initPluginsPage) {
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
window.initPluginsPage();
}
})
.catch(err => {
console.error('Failed to load ' + url + ':', err);
const label = PANEL_LABELS[id] || 'this page';
el.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load ' + label + '. Check that the device is still reachable, then try again.</p><button type="button" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
el.querySelector('button').addEventListener('click', () => window.loadPartialDirect(id, url));
})
.finally(() => {
clearTimeout(timeout);
el.removeAttribute('data-direct-loading');
});
};
function activeTab() {
const appEl = document.querySelector('[x-data="app()"]');
try {
return (window.Alpine && appEl) ? window.Alpine.$data(appEl).activeTab : null;
} catch (e) {
return null; // Alpine has not initialised the app yet
}
}
function loadActiveTabDirect() {
const el = document.getElementById((activeTab() || 'overview') + '-content');
if (el) window.loadPartialDirect(el.id, el.getAttribute('hx-get'));
}
// HTMX loaded but never initialised, even from the CDN rescue.
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, loading the active tab directly');
setTimeout(loadActiveTabDirect, 2000);
});
// HTMX still missing after 5 seconds: load the active tab, and the
// Plugin Manager regardless of tab so the plugin tabs work.
const fallbackTimer = setTimeout(function() {
if (typeof htmx !== 'undefined') return;
console.warn('HTMX not loaded after 5 seconds, loading tabs directly');
window.loadPartialDirect('plugins-content', '/v3/partials/plugins');
loadActiveTabDirect();
}, 5000);
// AP mode (setting up WiFi) opens on the WiFi tab and can least
// afford to wait, so it gets an earlier check.
const apTimer = setTimeout(function() {
const host = window.location.hostname;
if (typeof htmx === 'undefined' && host.startsWith('192.168.4.')) {
console.warn('HTMX not loaded, loading WiFi setup directly');
window.loadPartialDirect('wifi-content', '/v3/partials/wifi');
}
}, 3000);
window.addEventListener('htmx:ready', function() {
clearTimeout(fallbackTimer);
clearTimeout(apTimer);
}, { once: true });
})();
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
<!-- App shell (full app(), Alpine stores, SSE streams). Deferred so it
doesn't block rendering, and placed before Alpine: deferred scripts run
in document order, and its alpine:init listener must be registered
before Alpine starts. -->
<script defer src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<!-- Alpine.js for reactive components.
Load the local copy first (always works, no CDN round-trip, no AP-mode
branch needed). `defer` on an HTML-parsed <script> is honored and runs
after DOM parse but before DOMContentLoaded, which is exactly what
Alpine wants — so no deferLoadingAlpine gymnastics are needed.
The inline rescue below only fires if the local file is missing. -->
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
<script>
// Rescue: if the local Alpine didn't load for any reason, pull the CDN
// copy once on window load. This is a last-ditch fallback, not the
// primary path.
window.addEventListener('load', function() {
if (typeof window.Alpine === 'undefined') {
console.warn('[Alpine] Local file failed to load, falling back to CDN');
const s = document.createElement('script');
s.src = 'https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js';
document.head.appendChild(s);
}
});
</script>
<!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
<!-- Utility classes, generated by scripts/build_css.py (Tailwind CLI) and
committed. app.css comes second so its components and theme overrides
win over utilities of equal specificity. -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/tailwind.css') }}">
<!-- Custom v3 styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
</head>
<body x-data="app()" class="bg-gray-50 min-h-screen">
<a href="#tab-content" class="skip-link">Skip to main content</a>
<!-- Header -->
<header class="bg-white shadow-md border-b border-gray-200">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
<div class="flex justify-between items-center h-16">
<div class="flex items-center min-w-0">
<!-- Mobile nav toggle (hamburger) — hidden at md and up -->
<button type="button"
class="theme-toggle-btn p-2 rounded-md mr-2 md:hidden"
@click="mobileNavOpen = !mobileNavOpen"
:aria-expanded="mobileNavOpen ? 'true' : 'false'"
aria-controls="site-nav"
:aria-label="mobileNavOpen ? 'Close navigation menu' : 'Open navigation menu'"
aria-label="Open navigation menu"
title="Menu">
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
<h1 class="text-lg sm:text-xl font-bold text-gray-900 truncate">
<i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
</h1>
</div>
<!-- Connection status and theme toggle -->
<div id="header-widgets" class="flex items-center flex-shrink-0 ml-2 space-x-4">
<!-- Global settings search -->
<div class="relative hidden sm:block" id="settings-search-wrap">
<input id="settings-search"
type="text"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="settings-search-results"
aria-label="Search settings"
placeholder="Search settings…"
autocomplete="off"
class="form-control text-sm pl-8 pr-4 py-1.5 w-48 lg:w-64">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
<div id="settings-search-results"
role="listbox"
aria-label="Settings search results"
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div>
</div>
{% if web_auth_state and web_auth_state.signed_in %}
<!-- Log out (only shown when the optional web login is on) -->
<form method="post" action="{{ url_for('ledmatrix_auth.logout') }}" class="flex items-center">
<button type="submit" class="theme-toggle-btn p-2 rounded-md"
title="Log out" aria-label="Log out">
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
</button>
</form>
{% endif %}
<!-- Theme toggle -->
<button id="theme-toggle"
type="button"
onclick="toggleTheme()"
class="theme-toggle-btn p-2 rounded-md"
title="Switch to dark mode"
aria-label="Switch to dark mode"
aria-pressed="false">
<i class="fas fa-moon" id="theme-icon-dark" aria-hidden="true"></i>
<i class="fas fa-sun hidden" id="theme-icon-light" aria-hidden="true"></i>
</button>
<div id="connection-status" class="flex items-center space-x-2 text-sm">
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600">Disconnected</span>
</div>
<!-- System stats (populated via SSE) -->
<div id="system-stats" class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
<span id="cpu-stat" class="flex items-center space-x-1" title="CPU usage">
<i class="fas fa-microchip" aria-hidden="true"></i>
<span>--%</span>
</span>
<span id="memory-stat" class="flex items-center space-x-1" title="Memory usage">
<i class="fas fa-memory" aria-hidden="true"></i>
<span>--%</span>
</span>
<span id="temp-stat" class="flex items-center space-x-1" title="CPU temperature">
<i class="fas fa-thermometer-half" aria-hidden="true"></i>
<span>--°C</span>
</span>
<span id="power-stat" class="hidden items-center space-x-1" title="">
<i class="fas fa-bolt" aria-hidden="true"></i>
<span>Power</span>
</span>
</div>
</div>
</div>
</div>
</header>
<!-- Update available banner -->
<div id="update-banner" style="display:none"
class="update-banner border-b transition-all duration-300 ease-in-out">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-arrow-circle-up text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="update-banner-text"
aria-live="polite" aria-atomic="true">
A new LEDMatrix update is available
</span>
</div>
<div class="flex items-center space-x-3">
<button onclick="applyUpdate()" id="update-banner-btn"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
update-banner-action transition-colors duration-150">
<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now
</button>
<button type="button" onclick="dismissUpdateBanner()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss update">
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Restart-pending banner: shown when a response says restart_required
-- a main-config save (display hardware, rotation order, durations,
general settings), or a plugin install, update or uninstall the
running display cannot pick up live. Plugin config saves apply live
and don't trigger this. Wired in app.js (noteRestartRequired). -->
<div id="restart-pending-banner" style="display:none"
class="update-banner border-b transition-all duration-300 ease-in-out">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-rotate text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" aria-live="polite"
id="restart-pending-text">
Configuration saved &mdash; restart the display to apply the changes
</span>
</div>
<div class="flex items-center space-x-3">
<button onclick="window.restartPendingNow()" id="restart-pending-btn"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
update-banner-action transition-colors duration-150">
<i class="fas fa-power-off mr-1" aria-hidden="true"></i> Restart Now
</button>
<button type="button" onclick="window.dismissRestartPending()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss restart reminder">
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Under-voltage / throttling warning banner -->
<div id="power-warning-banner" style="display:none"
class="power-warning-banner border-b transition-all duration-300 ease-in-out">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-exclamation-triangle text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="power-warning-banner-text"
aria-live="polite" aria-atomic="true">
A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.
</span>
</div>
<button type="button" onclick="dismissPowerWarningBanner()"
class="power-warning-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss power warning">
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<!-- Main content -->
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
<!-- Backdrop shown behind the mobile nav drawer (no-op at md and up) -->
<div class="nav-backdrop" x-show="mobileNavOpen" x-transition.opacity
@click="mobileNavOpen = false" aria-hidden="true" style="display: none;"></div>
<!-- Navigation: static tab rows at md and up; off-canvas drawer below md.
The drawer reuses the exact same tab buttons/handlers — CSS repositions
this container, nothing is duplicated. Clicking any tab closes the drawer. -->
<div id="site-nav" class="site-nav" :class="mobileNavOpen ? 'open' : ''"
@click="if ($event.target.closest('.nav-tab')) mobileNavOpen = false">
<!-- Below md, the header's settings-search and system-stats widgets are
moved (not copied) into this slot by placeHeaderWidgets() in app.js -->
<div id="drawer-widgets"></div>
<!-- Navigation tabs -->
<div class="mb-8">
<!-- First row - System tabs -->
<div class="border-b border-gray-200 mb-4">
<nav aria-label="System settings" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'overview'"
:class="activeTab === 'overview' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tachometer-alt" aria-hidden="true"></i>Overview
</button>
<button @click="activeTab = 'general'"
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-sliders-h" aria-hidden="true"></i>General
</button>
<button @click="activeTab = 'wifi'"
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-wifi" aria-hidden="true"></i>WiFi
</button>
<button @click="activeTab = 'schedule'"
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-clock" aria-hidden="true"></i>Schedule
</button>
<button @click="activeTab = 'display'"
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-desktop" aria-hidden="true"></i>Display
</button>
<button @click="activeTab = 'durations'"
:class="activeTab === 'durations' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-rotate" aria-hidden="true"></i>Rotation
</button>
<button @click="activeTab = 'config-editor'"
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-code" aria-hidden="true"></i>Config Editor
</button>
<button @click="activeTab = 'backup-restore'"
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-save" aria-hidden="true"></i>Backup &amp; Restore
</button>
<button @click="activeTab = 'fonts'"
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-font" aria-hidden="true"></i>Fonts
</button>
<button @click="activeTab = 'logs'"
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-alt" aria-hidden="true"></i>Logs
</button>
<button @click="activeTab = 'cache'"
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-database" aria-hidden="true"></i>Cache
</button>
<button @click="activeTab = 'operation-history'"
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-history" aria-hidden="true"></i>Operation History
</button>
<button @click="activeTab = 'tools'"
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tools" aria-hidden="true"></i>Tools
</button>
</nav>
</div>
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPartialDirect('plugins-content', '/v3/partials/plugins')); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
</button>
<!-- Installed plugin tabs will be added here dynamically -->
</nav>
</div>
</div>
</div> <!-- /#site-nav -->
<!-- Floating mini display preview: available on every tab except
Overview (which has the full preview). Fed from the same SSE
stream by updateDisplayPreview in app-shell.js; open/closed state
persists in localStorage. -->
<div id="floating-preview" class="floating-preview" style="display:none">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-xs text-gray-300"><i class="fas fa-tv mr-1" aria-hidden="true"></i>Live Preview</span>
<span class="flex items-center">
<button type="button" onclick="window.cycleFloatingPreviewSize()"
class="text-gray-400 hover:text-white ml-2" aria-label="Cycle preview size"
title="Cycle preview size (drag the corner to resize on desktop)">
<i class="fas fa-up-right-and-down-left-from-center text-xs" aria-hidden="true"></i>
</button>
<button type="button" onclick="window.toggleFloatingPreview(false)"
class="text-gray-400 hover:text-white ml-2" aria-label="Close live preview">
<i class="fas fa-times text-xs" aria-hidden="true"></i>
</button>
</span>
</div>
<img id="floating-preview-img" alt="Live display preview"
style="image-rendering: pixelated; display: block; width: 100%; height: auto;">
</div>
<button id="floating-preview-toggle" type="button" onclick="window.toggleFloatingPreview(true)"
class="floating-preview-toggle" style="display:none"
title="Show live display preview" aria-label="Show live display preview">
<i class="fas fa-tv" aria-hidden="true"></i>
</button>
<!-- Tab content -->
<div id="tab-content" class="space-y-6" tabindex="-1">
<!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- General tab -->
<div x-show="activeTab === 'general'" x-transition>
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- WiFi tab -->
<div x-show="activeTab === 'wifi'" x-transition>
<div id="wifi-content"
hx-get="/v3/partials/wifi"
hx-trigger="loadtab"
hx-swap="innerHTML"
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Schedule tab -->
<div x-show="activeTab === 'schedule'" x-transition>
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Display tab -->
<div x-show="activeTab === 'display'" x-transition>
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="h-32 bg-gray-200 rounded"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Rotation & Durations tab -->
<div x-show="activeTab === 'durations'" x-transition>
<div id="durations-content" hx-get="/v3/partials/durations" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Backup & Restore tab -->
<div x-show="activeTab === 'backup-restore'" x-transition>
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Config Editor tab -->
<div x-show="activeTab === 'config-editor'" x-transition>
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-64 bg-gray-200 rounded"></div>
<div class="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="h-24 bg-gray-200 rounded"></div>
<div class="h-24 bg-gray-200 rounded"></div>
<div class="h-24 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Fonts tab -->
<div x-show="activeTab === 'fonts'" x-transition>
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-20 bg-gray-200 rounded"></div>
<div class="h-20 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Logs tab -->
<div x-show="activeTab === 'logs'" x-transition>
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-96 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Cache tab -->
<div x-show="activeTab === 'cache'" x-transition>
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Operation History tab -->
<div x-show="activeTab === 'operation-history'" x-transition>
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Tools tab -->
<div x-show="activeTab === 'tools'" x-transition>
<div id="tools-content" hx-get="/v3/partials/tools" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
<!--
Architecture: Server-side rendered plugin configuration forms
- Each plugin tab loads its config via HTMX when first viewed
- Forms are generated server-side using Jinja2 macros
- Reduces client-side complexity and improves performance
- Uses x-init to trigger HTMX after Alpine renders the element
-->
<template x-for="plugin in installedPlugins" :key="plugin.id">
<div x-show="activeTab === plugin.id" x-transition>
<!-- Only load content when tab is active (lazy loading) -->
<template x-if="activeTab === plugin.id">
<div class="bg-white rounded-lg shadow p-6 plugin-config-tab"
:id="'plugin-config-' + plugin.id"
x-init="$nextTick(() => {
const el = $el;
const pid = plugin.id;
const loadContent = (retries) => {
if (window.htmx && !el.dataset.htmxLoaded) {
el.dataset.htmxLoaded = 'true';
htmx.ajax('GET', '/v3/partials/plugin-config/' + pid, {target: el, swap: 'innerHTML'});
} else if (!window.htmx && retries < 15) {
setTimeout(() => loadContent(retries + 1), 200);
} else if (!window.htmx) {
fetch('/v3/partials/plugin-config/' + pid)
.then(r => r.text())
.then(html => {
el.innerHTML = html;
if (window.Alpine) {
window.Alpine.initTree(el);
}
});
}
};
loadContent(0);
})">
<!-- Loading skeleton shown until HTMX loads server-rendered content -->
<div class="animate-pulse space-y-6">
<div class="border-b border-gray-200 pb-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-6 bg-gray-200 rounded w-48"></div>
<div class="h-4 bg-gray-200 rounded w-96"></div>
</div>
<div class="h-6 bg-gray-200 rounded w-24"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
<div class="h-5 bg-gray-200 rounded w-32"></div>
<div class="space-y-3">
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
<div class="h-5 bg-gray-200 rounded w-32"></div>
<div class="space-y-3">
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-10 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-10 bg-gray-200 rounded w-full"></div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</main>
<!-- Notifications -->
<div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div>
<!-- Custom v3 JavaScript -->
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
<!-- Settings tooltips + settings search -->
<script src="{{ url_for('static', filename='v3/js/tooltips.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/settings-search.js') }}" defer></script>
<!-- Modular Plugin Management JavaScript -->
<!-- Load utilities first -->
<script src="{{ url_for('static', filename='v3/js/utils/dialog.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
<!-- Load core API client first (used by other modules) -->
<script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script>
<!-- Load plugin management modules -->
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
<!-- Shared search/filter/sort controller for the plugin card grids -->
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
<!-- Widget Registry System: all widget scripts in one request (see
web_interface/widget_bundle.py). The individual files stay served at
their own URLs for plugin-loader.js and debugging. -->
<script src="{{ widgets_bundle_url() }}" defer></script>
<!-- plugins_manager.js: loaded last. It uses PluginAPI, ListFilter,
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
or when a card is clicked, so it must follow all of them. No other
script defines the same globals. -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}" defer></script>
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
deferred widget's window assignments always shadowed the inline
copies that used to sit here, so the duplicates were removed) -->
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg" role="dialog" aria-modal="true" aria-labelledby="on-demand-modal-title">
<div class="flex justify-between items-center mb-4">
<h2 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h2>
<button type="button" id="close-on-demand-modal" class="text-gray-500 hover:text-gray-700 p-2 -m-2 rounded-md" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<!-- Service Status Alert -->
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2" aria-hidden="true"></i>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
<p class="text-xs text-yellow-700 mt-1">
The on-demand request will be queued but won't display until the service starts.
Enable "Start display service" below to automatically start it.
</p>
</div>
</div>
</div>
<form id="on-demand-form" class="space-y-4">
<div>
<label for="on-demand-mode" class="block text-sm font-medium text-gray-700 mb-1">Display Mode</label>
<select id="on-demand-mode" name="mode"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</select>
<p id="on-demand-mode-hint" class="text-xs text-gray-500 mt-1"></p>
</div>
<div>
<label for="on-demand-duration" class="block text-sm font-medium text-gray-700 mb-1">
Duration (seconds, optional)
</label>
<input type="number" min="0" id="on-demand-duration" name="duration"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="Leave blank to use plugin default">
<p class="text-xs text-gray-500 mt-1">
Use 0 or leave empty to keep the plugin running until stopped manually.
</p>
</div>
<div class="flex items-center">
<input id="on-demand-pinned" name="pinned" type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="on-demand-pinned" class="ml-2 block text-sm text-gray-700">
Pin plugin to prevent rotation until stopped
</label>
</div>
<div class="flex items-center">
<input id="on-demand-start-service" name="start_service" type="checkbox" checked
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="on-demand-start-service" class="ml-2 block text-sm text-gray-700">
Start display service if it is not running
</label>
</div>
<div class="flex justify-end gap-3 pt-3">
<button type="button" id="cancel-on-demand"
class="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md font-semibold">
Start On-Demand
</button>
</div>
</form>
</div>
</div>
<!-- Update banner logic -->
<script>
(function() {
var CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
function getDismissedSha() {
try { return sessionStorage.getItem('update-sha-dismissed'); } catch(e) { return null; }
}
function checkForUpdate() {
fetch('/api/v3/system/check-update')
.then(function(r) { return r.json(); })
.then(function(data) {
var banner = document.getElementById('update-banner');
var btn = document.getElementById('update-banner-btn');
if (data.check_failed) {
// A check that could not run is not the same as being up
// to date. Hiding the banner here made a checkout git
// refuses to touch look permanently current, with no
// route to the update button and nothing to act on.
document.getElementById('update-banner-text').textContent =
data.error || 'Could not check for updates.';
if (btn) btn.style.display = 'none';
banner.style.display = '';
return;
}
if (btn) btn.style.display = '';
if (data.update_available && getDismissedSha() !== data.remote_sha) {
var n = data.commits_behind || 0;
var msg = 'A new LEDMatrix update is available';
if (data.target_version) {
// Stable channel: the update is a release.
msg = 'LEDMatrix ' + data.target_version + ' is available';
} else if (n > 0) {
msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
}
document.getElementById('update-banner-text').textContent = msg;
banner.style.display = '';
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
} else {
banner.style.display = 'none';
}
})
.catch(function() {});
}
window.dismissUpdateBanner = function() {
document.getElementById('update-banner').style.display = 'none';
try {
var sha = sessionStorage.getItem('update-sha');
if (sha) sessionStorage.setItem('update-sha-dismissed', sha);
} catch(e) {}
};
window.applyUpdate = function() {
var btn = document.getElementById('update-banner-btn');
var originalHTML = '<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now';
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Updating...';
btn.disabled = true;
fetch('/api/v3/system/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'git_pull' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.innerHTML = originalHTML;
btn.disabled = false;
if (data.status === 'success') {
document.getElementById('update-banner').style.display = 'none';
try { sessionStorage.removeItem('update-sha-dismissed'); } catch(e) {}
// The pull replaced files on disk; the running services still
// hold the code they loaded at boot. Ask for the restart that
// makes the update actually take effect.
//
// Both services, not just the display. The plugin store's
// compatibility gate runs in the web process, so a web service
// still holding the previous version refuses every plugin that
// floors on the release just installed -- blaming a core
// version that is already correct on disk.
if (data.restart_required && typeof window.showRestartPending === 'function') {
window.showRestartPending(
'Update installed \u2014 restart the display and web ' +
'services to run the new code');
}
}
showNotification(data.message || 'Update complete', data.status || 'success');
})
.catch(function() {
btn.innerHTML = originalHTML;
btn.disabled = false;
showNotification('Update failed — check your connection', 'error');
});
};
// Initial check shortly after page load, then every CHECK_INTERVAL while
// the page is visible. A hidden tab makes no requests and catches up as
// soon as it is shown again.
var lastCheck = 0;
function checkIfDue() {
if (document.hidden || Date.now() - lastCheck < CHECK_INTERVAL) return;
lastCheck = Date.now();
checkForUpdate();
}
setTimeout(checkIfDue, 2000);
setInterval(checkIfDue, 60 * 1000);
document.addEventListener('visibilitychange', checkIfDue);
})();
</script>
</body>
</html>