mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(web): keep Cache and Logs helpers out of each other's way Both partials declared top-level showError and escapeHtml. Their scripts run at global scope after every HTMX swap, so whichever tab was opened last owned window.showError, and a Cache failure after visiting Logs rendered into the Logs panel (and the other way round). Each script is now an IIFE; Cache still exports deleteCacheFile for its row buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): make the HTMX-failure fallbacks for tab panels actually run - The "HTMX never loaded" fallback read appElement.__x.$data, which is Alpine 2. The page ships Alpine 3, so the check was always false and the Overview never loaded without HTMX. It now reads Alpine.$data(). - The Overview and WiFi panels used hx-on::htmx:response-error, which htmx expands to "htmx:htmx:response-error", an event that never fires. - loadTabContent sent requests with <body> as the source, so htmx fired its events on <body> and no panel's hx-on handler ran at all. The panel is now the source. htmx also resolves its promise on a 4xx/5xx, and the panel was stamped data-loaded anyway, leaving a skeleton that never retried; it is now stamped only when no responseError fired. loadPluginsDirect, loadOverviewDirect and loadWifiDirect are merged into one window.loadPartialDirect(id, url), which also runs the partial's inline scripts before Alpine sees the markup (as htmx-config.js does on htmx:afterSwap). The ~10 s "htmx never arrived" path in loadTabContent uses it for every tab instead of four hard-coded ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): store and registry failures no longer wipe the Plugin Manager showError replaced the whole #plugins-content with an error message, so one failed store search, custom-registry install or saved-repository call took the installed list, the store and every control with it, with no way back short of reloading the tab. Those failures are now error notifications. The full-panel message is kept only for a first load of the installed list that failed (nothing to show yet); a failed refresh of an already-rendered list is a notification too. showSuccess's fallback branch, which wrote the message into innerHTML unescaped, is gone: showNotification always exists. The "Please try refreshing your browser" hint tested for the text "Failed to Fetch", which no browser produces (Chrome says "Failed to fetch", Firefox "NetworkError..."), so it never appeared. It now keys on the failure itself: a TypeError from fetch(), or PluginAPI's NETWORK_ERROR wrapper around one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): escape plugin action and install output on every path executePluginAction escaped data.message and data.output when an action failed but put data.message straight into innerHTML when it succeeded, and set the OAuth step-2 button's innerHTML from the manifest's step2_button_text. Plugin actions run plugin code, so that is plugin- or server-controlled markup in the page. Both paths now escape, and the button label is set with textContent. The same pattern sat in the install-from-GitHub-URL status lines (plugin_id, the server's message, and error.message, which can echo a repository URL) and the custom-registry load error; those are escaped too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): file-upload widget owns the image list and schedule editor plugins_manager.js loads after the widget bundle, so its older copies of deleteUploadedFile, updateImageList, hideUploadProgress, formatDate, openImageSchedule, toggleImageScheduleEnabled, updateImageSchedule{Mode, Time,Day} and updateCheckboxGroupData replaced the widget's. They are deleted; the widget files are the only definitions. Before switching over, the two sets were diffed and fixed so nothing regresses: - The old copy labelled the schedule/delete buttons for screen readers and lazy-loaded thumbnails; the widget now does both. - The schedule button did nothing on a card rendered by plugin_config.html whenever the image id is a UUID (every upload): the template turns "-" into "_" in the editor's id, and neither JS copy did. Both now use the template's rule. - The widget's "keep the open editor open" copied the editor's innerHTML into the new list. That dropped its event listeners and showed the old values, so after the first change the editor looked live but ignored input. A schedule edit now saves to the hidden input and updates the card's summary in place without re-rendering the list; a list re-render (upload, delete) rebuilds an open editor from the data. Editor controls are routed by one delegated change listener, so there are no per-element listeners to lose. - The old deleteUploadedFile had a JSON branch that removed a #file_<id> element and skipped the re-render. No template or script renders such an element, and JSON uploads are listed through updateImageList like images, so re-rendering (the widget's behaviour) is the consistent one; the branch was not carried over. - The template always renders the summary line (".image-schedule-summary", "Always shown" when unscheduled) so an edit has a line to update. The inline-handler test evaluated plugins_manager.js's updateImageList; test_file_upload_widget.js now covers the widget's list and editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete the unused handleCredentialsUpload Its last caller went when plugin_config.html switched credential uploads to the file-upload widget's handleSingleFileSelect. Nothing in the web UI, the tests or the plugin monorepo references it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete dead and shadowed front-end code Nothing calls any of these (checked across web_interface/, test/ and the ledmatrix-plugins monorepo, including hx-*/x-*/onclick attributes): - app-shell.js: the Alpine methods refreshPlugins (it called a nonexistent this.searchPluginStore), loadPluginConfig, savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo and formatDateInfo, and the top-level copies of savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo, formatDateInfo and togglePluginFromTab. Plugin config forms save through hx-post in plugin_config.html. - window.reconnectSSE (app-shell.js); window.updateArrayTableAddButtonState (array-table.js). - toggleNestedSection, defined twice (app-shell.js and plugins_manager.js) and called from nowhere. - plugins_manager.js: the window.initializePlugins wrapper around an IIFE-local origInit that was always undefined, and __pluginDomReady, which was written but never read. - display.html's fixInvalidNumberInputs fallback: app-shell.js defines it before any partial loads. - base.html's window.loadCodeMirror and the two CodeMirror stylesheet preloads, and the .CodeMirror rules in plugins.html. The raw JSON editor is a plain textarea. Also deleted: app-shell.js definitions that a later script always replaced, so they never ran: executePluginAction (plugins_manager.js assigns its own), uninstallPlugin and its pollUninstallOperation (plugins_manager.js), and updateAllPlugins (install_manager.js). vendor/codemirror stays: test/test_web_smoke.py still requests codemirror.min.js as a sample static asset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): call showNotification without checking it exists app-shell.js defines window.showNotification (a stand-in that queues until the notification widget loads) and base.html runs it, deferred, before every other script that notifies: app.js, the utilities, the widget bundle, plugins_manager.js, and all partials, which HTMX loads after the page. The 81 `typeof showNotification === 'function'` / `!== 'undefined'` checks, the `window.showNotification || console.log` and `|| alert` fallbacks, and their else branches (alert(), console output, and schedule.html's own hand-built toast) could never take the fallback path. They are removed, as is fonts.html's second copy of the queueing stand-in. The stand-in in app-shell.js keeps its guard (it must not replace the widget's implementation if load order ever changes), and BaseWidget's public notify()/getNotificationFunction() keep their shape for widgets that plugins ship. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one HTML escaper, window.LEDEscape About 30 files each carried their own escapeHtml / escapeAttr / escHtml / _esc / escapeJs. They disagreed: several (notification.js, display.html's escapeHtml, operation_history.html, the app() stub) did not escape quotes, google-calendar-picker.js and tools.html's escHtml left ' alone, and some turned 0 into ''. Most were fine only because the quote-safe widget copies were preferred at runtime. window.LEDEscape now lives at the top of app-early.js, a blocking script in <head>, so it exists before any other script runs: html(v) & < > " ' as entities, null/undefined as '' attr(v) the same, for call sites that want to say "attribute" jsStringAttr(v) a JS string literal safe inside an inline handler Every former copy is now a one-line name for it (kept so call sites do not change), widgets included, with no fallback. plugins_manager.js loses its four escapeJs wrappers (callers use jsStringAttr), the duplicate escapeAttr and escapeHtml inside renderInstalledCards and renderCustomRegistryPlugins, and the window.escapeHtml / window.escapeAttribute exports, which nothing read. addArrayObjectItem's fallback markup (with a sixth hand-written escape chain) is gone too: window.renderArrayObjectItem is defined earlier in the same file, so the fallback could not run. The unused escapeHtml methods on the Alpine app (app-early.js stub and app-shell.js) are deleted. test_html_escaping.js now runs LEDEscape and every remaining name for it, and fails if a hand-rolled escaper reappears anywhere in web_interface/. Suites that evaluate slices of plugins_manager.js or widget files load LEDEscape from app-early.js through test/js/led_escape.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): stop htmx re-running partial scripts after every tab load htmx-config.js runs each swapped-in <script> itself on htmx:afterSwap and meant to turn htmx's own script handling off with htmx.config.allowScriptTags = false. It did that once, while setting up, but base.html loads htmx with a dynamic <script>, so htmx was not defined yet and the setting never applied. On every tab load htmx then tried to run each script again in its settle phase, found it already replaced (no parent node) and threw "Cannot read properties of null (reading 'insertBefore')" into the console, which also skipped the rest of that swap's settle tasks. The setting is now applied in the afterSwap handler, which always runs after htmx exists and before htmx settles the same swap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): show "--" for a system stat the server could not read The stats stream and /system/status now send null for a metric they cannot read (cpu_temp off a Pi, for one) instead of 0. updateSystemStats built the header and Overview text as value + unit, so a null showed as "null°C". CPU, memory and temperature, in the header and on the Overview, now render "--" plus the unit for null or a missing field -- the same placeholder the page starts with, and what tools.html already shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one Alpine accessor and one plugin-list signal window.getApp() (app-early.js) returns the root <body x-data="app()"> component through Alpine's public Alpine.$data, or null before Alpine has initialised it. It replaces the private el._x_dataStack[0] reads in app.js, app-early.js, app-shell.js, settings-search.js, overview.html and plugins_manager.js, the three local getAppComponent/appData/getAppData copies, and the Alpine 2 el.__x.$data fallbacks, which Alpine 3 never provides. Publishing the installed-plugin list: one load set window.installedPlugins and dispatched pluginsUpdated twice (loadInstalledPlugins, then renderInstalledPlugins), then wrote into the Alpine component through _x_dataStack[0] and called its updatePluginTabs() directly, and app-early.js's global listener set window.installedPlugins a third time and called updatePluginTabs() again. Now renderInstalledPlugins is the one publisher: it sets window.installedPlugins and dispatches pluginsUpdated once, and the full app()'s listener (app-shell.js) is the receiver. The app-early.js listener only builds the tab row while the app is not the full implementation yet. The "grid not loaded yet" case is a normal state (Plugin Manager tab not opened), so it logs through pluginLog instead of console.warn. updatePluginTabs had a "Debounce" comment and clearTimeout over a timer nothing ever set, and two identical branches; it now just calls _doUpdatePluginTabs (app-early.js detects the full implementation by that name in its source, which the new comment says). app()'s unused baseComponent lookup is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): reload the plugin list after installs and failed toggles Several callers refreshed the installed list with if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') ... but loadInstalledPlugins is local to the plugin-manager IIFE and window.loadInstalledPlugins is never defined, so from outside that IIFE both tests were false and nothing reloaded: - A failed plugin toggle left the switch drawn in the new state while the data said the old one. It now re-renders from the reverted data. The optimistic in-place edit also has to forget the grid's last-rendered markup, or setGridHtmlIfChanged sees identical HTML and skips the revert. A successful toggle still keeps the switch (and focus) as drawn. - Installing from a GitHub URL (the early handleGitHubPluginInstall), installing or uploading a Starlark app, and toggling a Starlark app on its config tab never refreshed the list, so the new app had no tab or Installed badge until the page was reloaded. They now force a reload through window.pluginManager.loadInstalledPlugins(true), and the Starlark grid redraws when that finishes instead of after a fixed 500 ms. - The Starlark uninstall inside the IIFE reloaded from the 3 s cache, which could still hold the app; it now forces a reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): route debug output through debugLog base.html defines window.debugLog, gated on localStorage.pluginDebug. plugins_manager.js read the same key twice more into its own flags (_PLUGIN_DEBUG_EARLY, and PLUGIN_DEBUG behind a pluginLog() wrapper), and api_client.js's RequestThrottler had a separate `debug` property with a setDebug() that nothing called. All of it now goes through debugLog. The "functions defined" dumps with their ✓ lines, and two per-plugin "enabled=" loops that ran on every render, are dropped; "[PLUGINS STUB]" labels on code that has not been a stub for a long time read "[PLUGINS]". Ungated console.log calls that announced normal events on every page load or action (settings search and tooltips registering, every toast repeated to the console, the schedule pickers initialising, widget registry unregister/clear) go through debugLog too. What remains on console.log is the widget registry's on-demand LEDMatrixWidgets.debug() dump and BaseWidget.notify's no-notifier fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): drop waits and guards that could never fire - handlePluginAction polled up to 10 x 50 ms for window.togglePlugin, configurePlugin, updatePlugin and uninstallPlugin before calling them. All four are defined when the scripts load, before any card can be clicked, so the poll always succeeded at once; it now calls them. The long thinking-aloud comment over the toggle state is replaced by two lines on why the stored state, not the checkbox, decides. - initializePlugins checked typeof on setupGitHubInstallHandlers and applyStoreFiltersAndSort, function declarations in the same IIFE, and wrapped window.checkGitHubAuthStatus(), which returns a promise with its own .catch, in try/catch. - searchPluginStore wrapped each "#store-count" update (a getElementById and an innerHTML assignment) in try/catch four times; one setStoreCount() helper does it. The store's post-render re-attach of the GitHub token handler dropped its try/catch and existence checks for the same reason. - The load-time fallback outside the IIFE tested typeof initializePluginPageWhenReady, which is IIFE-local and so always undefined there; it calls window.initPluginsPage directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete two unused plugin-manager helpers stopOnDemand (IIFE-local; the page's stop button calls window.stopOnDemand from app-shell.js) and debounce had no callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): document the plugin-config handlers templates call validatePluginConfigForm, handleConfigSave, handleToggleResponse, handlePluginUpdate and refreshPluginConfig each get a JSDoc naming the attribute in partials/plugin_config.html that calls it and what the return value means (only validatePluginConfigForm's matters: false cancels the submit). - The `if (!window.__pluginConfigHandlersInitialized)` wrapper is gone: app-shell.js runs once per page, so it was never false. The block is dedented one level; `git diff -w` shows the real change. - The three handlers read xhr.responseJSON first. XMLHttpRequest has no such property (it is jQuery's), so that branch never ran; one xhrJson(xhr) helper parses responseText for all of them, with the same fallbacks as before. - runPluginOnDemand and stopOnDemand checked that plugins_manager.js's openOnDemandModal/requestOnDemandStop exist; plugins_manager.js is on every page, so they call them. - fixInvalidNumberInputs had a stray "Notification helper function" comment on top of its own; a leftover "section toggle ... duplicate definition removed" note is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): one toast per save, and a failed durations save says so app.js's global htmx:afterRequest listener showed the server's message for every htmx request, and every form and button that posts through htmx (plugin config save/toggle/update, Display, Durations, General, Schedule, Dim schedule, the Overview actions) also reports its own result from hx-on after-request. Each save showed two toasts. The global listener now stays quiet for a request whose element, or its form, has its own after-request handler. That exposed the Rotation & Durations form's handler, which read xhr.responseJSON: XMLHttpRequest has no such property, so it always said "Durations saved" in green, even when the save failed (the global toast had been the only place the error showed). display.html already had a correct version (2xx only counts as saved; the server's message wins; its status may refine success but never overturn failure). That is now window.showSaveResult(xhr, savedText, failedText) in app.js, used by the Display, Durations and General forms; General's inline copy of the same logic is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(web): file headers and comments that say what the code does now - plugins_manager.js, app-shell.js, app.js and app-early.js open with a header: what the file owns, how base.html loads it and in what order relative to the others, and the globals it defines. app-early.js's app() stub also says why it exists and that, with app-shell.js now loaded before Alpine, it does not run in practice. - base.html's note on plugins_manager.js said it must load last to win over same-named functions in app.js/app-shell.js; there are none left, so it now gives the real reason (it uses everything loaded before it). - Change-narration and "already defined at the top, no need to redefine" notes are gone or rewritten as present-tense reasons; comments that were wrong are fixed ("Toggle password visibility" over the function that opens the token panel, "Insert before the closing </nav>" over an appendChild, "(from v2)", the export note that still listed escapeHtml). About forty comments that restated the line below them are removed, and a second window.currentPluginConfig = null outside the IIFE is dropped (the IIFE sets it). - The file-upload, checkbox-group and custom-feeds widgets' render() stubs say plainly that the widget is rendered server-side, instead of "for now" / "placeholder for future client-side rendering". test_plugin_action_delegation.js sliced the source up to one of the removed notes; it now ends the slice at the next section header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep the escapeHtml/escapeAttribute globals for plugin pages 6da77363 removed window.escapeHtml and window.escapeAttribute because nothing in core or the plugin monorepo read them. Plugin web UIs served through serve_plugin_web_ui and third-party plugin pages may still call them, so they come back as aliases of window.LEDEscape.html and .attr, defined in app-early.js before any other script runs. test_html_escaping.js checks the aliases exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): web-frontend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): encode the image thumbnail path; match script tags case-insensitively CodeQL flagged the upload widget building an <img> src from a stored path, and the escaper test extracting inline scripts with a case-sensitive regex. Each path segment is now URL-encoded (still a same-origin path, and correct for names with spaces or * fix(web): clear Codacy findings in the escaper, app shell and upload widget - LEDEscape looks entities up in a Map instead of indexing an object. - showNotification is declared as a global for app-shell.js. - openImageSchedule checks the index is a non-negative integer and reads the image with Array.prototype.at. - The schedule editor calls escapeHtml directly and documents why its innerHTML template is safe: every value is escaped or constrained. The remaining rule hits are suppressed on that line with the reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): build the image schedule editor with DOM calls Codacy does not honour inline suppressions, and the editor's innerHTML template kept tripping its XSS rules even though every value was escaped. The editor is now built with a small element helper (createElement and setAttribute), so no value is ever parsed as HTML, and the file's own escapeHtml goes away. Also for Codacy: - LEDEscape.attr is its own function rather than a second name for html. - The tab loader records a failed load on the panel (data-load-failed) from a named handler, instead of a closure over a local flag. The fake DOM in test_file_upload_widget.js gains append/replaceChildren, its hostile-id check now asserts the id arrives as attribute data with no innerHTML anywhere in the editor, and test_html_escaping.js drops the file-upload.js escaper it no longer has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): schedule editor helpers as plain functions Codacy's lint flags arrow functions held in local constants and a forEach callback that returns a value. The editor's pieces are now named function declarations (displayStyle, scheduleModeOption, scheduleRangeTime, scheduleDayTime, scheduleDayRow) taking what they need as arguments, and the element helper loops with for...of. htmx is declared as a global in app-shell.js. Output is unchanged; test_file_upload_widget.js passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1096 lines
58 KiB
HTML
1096 lines
58 KiB
HTML
<!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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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. -->
|
||
<!-- 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>
|
||
<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') }}">
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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 after a main-config save (display
|
||
hardware, rotation order, durations, general settings) — those only
|
||
take effect after the display service restarts. Plugin config saves
|
||
apply live and don't trigger this. Wired in app.js. -->
|
||
<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 — 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 & 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') }}?v=20260307" 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 (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>
|
||
|