mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-21 18:39:06 +00:00
* fix(web): stop /config/main handing out every credential it holds
The endpoint returned the raw config to anyone who could reach the port, and
this web interface has no authentication of any kind. An unauthenticated
request against a live rig returned:
github.api_token 40 chars
incoming-packages.ha_token 183 chars
jellyfin-now-playing.api_key 32 chars
ledmatrix-weather.api_key 32 chars
on-air.mqtt_password 8 chars
youtube.api_key 20 chars
youtube-stats.api_key 39 chars
A GitHub token and a Home Assistant long-lived token among them. Anything on
that LAN could read them.
The x-secret masking the plugin config endpoints use does not reach here: this
route never consults a schema, and core keys such as github.api_token have no
schema to carry the marker. Several of the fields above *are* tagged x-secret
in their plugin's schema and were still returned in full, which is what rules
out the schema route as the fix for this endpoint.
Credential-named fields are now blanked. Matching on the name is blunt, and
for a whole-config dump that is the right default: anything named like a
credential should not leave the process, and a new plugin adding a
differently-shaped secret is covered without anyone remembering to tag it.
Blanked rather than removed, and safe to blank: POST /config/main merges into
the freshly loaded config and writes only the keys it was given, so a client
that round-trips this response cannot erase a secret it never saw. The web API
suites confirm it -- 81 passing, unchanged.
On the test that matters: the first version of this suite exercised the two
helpers and nothing else, and reverting the single line that wires the
redactor into the route passed all thirty of them. A property asserted on a
helper is not a property asserted on the endpoint, and it is the endpoint that
is exposed to the network. The added test goes through the view function, and
it does fail on that revert.
This also corrects an earlier claim of mine. I reported that GET /api/v3/config
did not expose these values; that path 404s, so the check proved nothing. The
real route is /config/main and it exposed all of them.
* fix(web): stop an unrelated config edit from erasing a plugin's secret
Saving any field on a plugin's config form destroyed that plugin's stored
credential. On a rig with a weather API key, changing the city silently
emptied the key, and the plugin stopped working at the next fetch with no
indication why.
The path had no guard at any step. The config partial masks secrets before
rendering (pages_v3.py:740), so the browser posts them back blank; _parse_value
deliberately preserves "" for optional string fields; separate_secrets routes
that "" into secrets_config, which is a truthy dict; deep_merge writes it over
the stored value; save_raw_file_content persists it.
The blank does not even need the round-trip. merge_with_defaults injects the
schema's api_key default ("") into every save, so a client that never sends
the field at all still erases it. test_secret_count_message_counts_top_level_keys
was counting exactly that injected blank as a saved secret field -- the visible
edge of the bug, pinned as expected behaviour.
remove_empty_secrets() already existed for this, with seven unit tests and a
docstring describing this precise scenario ("clients will send those empty
strings back ... so that existing stored secrets are not overwritten with
blanks"). It was never wired into a call site. This wires it into both save
paths that merge into the secrets file.
A blank now means "unchanged" rather than "delete", which is the same contract
the helper's tests already describe. The cost is that a secret can no longer be
cleared by emptying the field; clearing needs its own affordance, since a
control that erases credentials as a side effect of ordinary edits is not one.
Verified by reverting the guard: the new round-trip test then fails with the
stored key read back as ''. 262 web tests pass with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
* fix(web): stop dumping the config and request headers to the journal
save_main_config logged its entire POST body and the full request headers at
ERROR on every save. The body is the configuration itself, and the headers
carry the session cookie, so a routine settings change wrote both to the
journal -- at a level that guarantees they survive any sane log filter.
The lines are leftover debug output: they say "DEBUG:" in the message while
calling logging.error, and they went through the root logger rather than the
module logger, bypassing the level configured for this blueprint.
Replaced with a debug-level line recording the shape of the request, which is
the part with diagnostic value. The local `import logging` went with them; it
shadowed a module-level import that was already there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
* fix(web): stop /config/secrets handing out every credential it holds
GET /api/v3/config/secrets returned config_secrets.json in full to anyone who
could reach the port, and this interface has no authentication. Probed against
a real rig it produced six populated credential fields: a 40-character GitHub
token, a 183-character Home Assistant token, and Jellyfin and weather API keys.
This is the second door onto the same credentials; #477 closes the first.
Masking the response alone would have been worse than the leak. The only
client fetches every secret, edits one field and posts all of them back, and
save_raw_file_content replaces the file wholesale -- so a masked GET followed
by the client's own save would write the mask over every credential the user
had not touched. That is why this was left open when the leak was found; it
needs both halves.
Read side: mask_all_secret_values(), which already existed for exactly this
endpoint -- its docstring names it -- and had never been wired to a call site.
It leaves empty values and YOUR_* placeholders alone, so a client can still
tell "set" from "not set" without being told the secret.
Write side: strip the echoed mask and blanks from the submission, then merge
onto what is stored, so "unchanged" means unchanged. The cost is that a secret
can no longer be cleared by blanking it; that wants its own affordance, since
a control that erases credentials as a side effect of saving an unrelated one
is not one.
Browser side: the token field is now left empty rather than filled from the
response. Filling it with the mask would have stored eight bullet characters
as the token the next time the user pressed Save, and filling it with the real
value is the thing being fixed. It reports whether a token is saved instead.
Verified end to end through the Flask endpoints, not the helpers. Reverting
the masking fails the leak tests; reverting the merge fails the preservation
tests; both halves are independently guarded. 278 web tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
* fix(web): stop reporting "no update" when the update check could not run
check-update returned update_available=False whenever git failed. The banner
is the only route to the update button, so a checkout git refuses to touch
looked exactly like a current one -- permanently, with nothing on screen to
act on and only a log line recording why.
The common cause is an install performed as root. scripts/install/one-shot-install.sh
clones into ${HOME}/LEDMatrix, never consults SUDO_USER, and contains no chown
at all, while its own error text suggests running the whole thing under sudo.
The result is a root-owned checkout, and on a rig this is what every git
command in it does:
fatal: detected dubious ownership in repository at '...'
including the fetch this endpoint runs. Verified on real hardware rather than
assumed.
A failed check now reports check_failed with a message the user can act on --
for dubious ownership, the chown that fixes it. The banner shows that message
instead of hiding itself, with the update button suppressed since updating
cannot work until the cause is fixed. The success path is untouched.
This does not fix the installer, which is the real cause; it stops the symptom
being invisible. The installer needs SUDO_USER handling and a chown, and its
suggestion to run as root should go.
Reverting the endpoint change fails four of the five new tests; the fifth
guards the success path and correctly does not move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
* fix(web): stop the installer chmod stripping exec bits on every update
git tracks five scripts as mode 644 that first_time_install.sh then chmods to
755 (start_display.sh, stop_display.sh, the two install_*_service.sh, and
one-shot-install.sh does the same to first_time_install.sh). With
core.fileMode true, the default on Linux, git reports all five as modified
from then on, in files the user never touched.
The update button stashes local changes before pulling, so it is not blocked
by this. But it never pops that stash -- stash pop and stash apply appear
nowhere in the update flow -- so the mode change is stashed away and left
there, and the files revert:
=== file modes after the update button's stash ===
664 first_time_install.sh <- installer had made these 755
664 start_display.sh
664 stop_display.sh
664 scripts/install/install_service.sh
So every web-UI update silently strips the executable bit from the installer's
own scripts, and leaves a stash entry holding the difference. start_display.sh
and stop_display.sh stop working from the shell afterwards.
A manual `git pull --rebase` over SSH fails outright, since nothing stashes for
it: "cannot pull with rebase: You have unstaged changes". That is the likely
source of the reports, since plenty of people update that way.
Tracking the five as 755 -- what they should always have been, as the
installer chmodding them attests -- removes the spurious mode change
entirely: nothing to stash, nothing stripped, no stash entry, and manual
pulls work.
The pull also passes --autostash, for the case the code explicitly tolerates:
when the stash fails it logs a warning and pulls anyway, and that pull is what
then fails. Autostash also pops what it stashes, which the manual stash does
not.
Note that `git add -A` after `git update-index --chmod=+x` silently reverts
the index to the on-disk mode, so the modes here were set by chmodding the
files themselves.
Regression test asserts the five stay tracked executable; reverting any one
of them fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
* fix(web): ask for the restart that makes an update take effect
The update button pulls new code and restarts nothing. There is no systemctl,
restart, reload or reboot anywhere in the 172-line git_pull handler -- it
stashes, pulls, installs changed requirements, re-removes plugins the user had
uninstalled, and returns "Code updated successfully."
Meanwhile both services go on running the code they loaded at boot. So the
display keeps rendering the old build, the web interface keeps serving the old
build, and the user is told the update worked. Nothing on screen suggests
otherwise, and the next reboot is what actually applies it -- whenever that is.
The affordance for this already exists: the restart-pending banner, raised
after main-config saves, with a Restart Now button wired to the display
service. A code update is a stronger reason to show it than a config save is.
The response now reports restart_required, and applyUpdate raises the banner
with wording for a code update rather than a config save. The banner's message
became a parameter and is persisted next to the flag, since it outlives the
page that raised it.
restart_required is only true when the pull actually moved HEAD. "Already up
to date" is a success too, and prompting after a no-op would train users to
dismiss the prompt unread.
This covers the display service, which is what the Restart Now button drives
and what users notice. The web interface still picks up its own new code on
its next restart; restarting it from inside a request it is serving is a
larger change than this one.
Reverting the flag fails the test that a pull which moved HEAD asks for a
restart. 290 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
* Mask list-shaped secrets element-wise, close two vacuous tests
mask_all_secret_values treated any non-empty list as a scalar, so a
secrets file holding
"accounts": [{"name": "a", "token": "tok-a"}, {...}]
came back as a single "••••••••". The caller could not see how many
entries existed, and the raw editor was handed a string where the file
holds an array. Recurse into lists in both _mask_value and _contains_mask.
Lists merge by replacement, not key-wise, so strip_masked_values now
drops a list outright if any element still carries the mask -- storing a
half-masked list would discard the untouched entries.
Two tests could pass without exercising what they claim to check:
- test_git_pull_resolution asserted modes only for paths git ls-files
returned. A renamed or deleted installer target is simply absent from
that output, so its mode was never checked. Assert every CHMODDED path
is tracked first.
- test_config_secrets_masking never checked the POST status. A 500
leaves the old file in place, which satisfies every assertion that
follows. Assert 200 before reading the file back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1193 lines
64 KiB
HTML
1193 lines
64 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 sseSrc = '/static/v3/js/htmx-sse.js';
|
|
const jsonEncSrc = '/static/v3/js/htmx-json-enc.js';
|
|
const htmxCdn = 'https://unpkg.com/htmx.org@1.9.10';
|
|
const sseCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/sse.js';
|
|
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 extensions after core loads
|
|
loadScript(sseSrc, sseCdn);
|
|
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>
|
|
|
|
<!-- Fallback for loading plugins if HTMX fails -->
|
|
<script>
|
|
function loadPluginsDirect() {
|
|
const content = document.getElementById('plugins-content');
|
|
if (content && !content.hasAttribute('data-loaded')) {
|
|
content.setAttribute('data-loaded', 'true');
|
|
debugLog('Loading plugins directly via fetch (HTMX fallback)...');
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
fetch('/v3/partials/plugins', { signal: controller.signal })
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
|
return r.text();
|
|
})
|
|
.then(html => {
|
|
clearTimeout(timeout);
|
|
content.innerHTML = html;
|
|
if (typeof htmx !== 'undefined') {
|
|
htmx.process(content);
|
|
}
|
|
// Trigger full initialization chain
|
|
if (window.pluginManager) {
|
|
window.pluginManager.initialized = false;
|
|
window.pluginManager.initializing = false;
|
|
}
|
|
if (window.initPluginsPage) {
|
|
window.initPluginsPage();
|
|
}
|
|
})
|
|
.catch(err => {
|
|
clearTimeout(timeout);
|
|
console.error('Failed to load plugins:', err);
|
|
content.removeAttribute('data-loaded');
|
|
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fallback if HTMX doesn't load within 5 seconds
|
|
var _pluginsFallbackTimer = setTimeout(() => {
|
|
if (typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
|
// Load plugins tab content directly regardless of active tab,
|
|
// so it's ready when the user navigates to it
|
|
loadPluginsDirect();
|
|
}
|
|
}, 5000);
|
|
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { 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. -->
|
|
<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.x.x/dist/cdn.min.js';
|
|
document.head.appendChild(s);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
|
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}"></noscript>
|
|
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}"></noscript>
|
|
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
|
|
<script>
|
|
// Lazy load CodeMirror when needed
|
|
window.loadCodeMirror = function() {
|
|
if (window.CodeMirror) return Promise.resolve();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// Vendored CodeMirror 5.65.2 (note: the previously listed CDN
|
|
// mode/json/json.min.js never existed — it 404'd on every
|
|
// editor open; JSON highlighting comes from the javascript
|
|
// mode, so it's simply dropped here)
|
|
const scripts = [
|
|
'/static/v3/vendor/codemirror/codemirror.min.js',
|
|
'/static/v3/vendor/codemirror/mode/javascript/javascript.min.js',
|
|
'/static/v3/vendor/codemirror/addon/edit/closebrackets.min.js',
|
|
'/static/v3/vendor/codemirror/addon/edit/matchbrackets.min.js'
|
|
];
|
|
|
|
let loaded = 0;
|
|
scripts.forEach((src, index) => {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.defer = true;
|
|
script.onload = () => {
|
|
loaded++;
|
|
if (loaded === scripts.length) resolve();
|
|
};
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
});
|
|
};
|
|
</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">
|
|
<!-- 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">
|
|
<!-- 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-xl font-bold text-gray-900">
|
|
<i class="fas fa-tv text-blue-600 mr-2"></i>
|
|
LED Matrix Control
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Connection status and theme toggle -->
|
|
<div id="header-widgets" class="flex items-center 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">
|
|
<i class="fas fa-microchip"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="memory-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-memory"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="temp-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-thermometer-half"></i>
|
|
<span>--°C</span>
|
|
</span>
|
|
<span id="power-stat" class="hidden items-center space-x-1" title="">
|
|
<i class="fas fa-bolt"></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"></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"></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"></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"></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"></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"></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"></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"></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 -->
|
|
<nav class="mb-8">
|
|
<!-- First row - System tabs -->
|
|
<div class="border-b border-gray-200 mb-4">
|
|
<nav 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"></i>Overview
|
|
</button>
|
|
<button @click="activeTab = 'general'"
|
|
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-sliders-h"></i>General
|
|
</button>
|
|
<button @click="activeTab = 'wifi'"
|
|
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-wifi"></i>WiFi
|
|
</button>
|
|
<button @click="activeTab = 'schedule'"
|
|
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-clock"></i>Schedule
|
|
</button>
|
|
<button @click="activeTab = 'display'"
|
|
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-desktop"></i>Display
|
|
</button>
|
|
<button @click="activeTab = 'durations'"
|
|
:class="activeTab === 'durations' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-rotate"></i>Rotation
|
|
</button>
|
|
<button @click="activeTab = 'config-editor'"
|
|
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-code"></i>Config Editor
|
|
</button>
|
|
<button @click="activeTab = 'backup-restore'"
|
|
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-save"></i>Backup & Restore
|
|
</button>
|
|
<button @click="activeTab = 'fonts'"
|
|
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-font"></i>Fonts
|
|
</button>
|
|
<button @click="activeTab = 'logs'"
|
|
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-alt"></i>Logs
|
|
</button>
|
|
<button @click="activeTab = 'cache'"
|
|
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-database"></i>Cache
|
|
</button>
|
|
<button @click="activeTab = 'operation-history'"
|
|
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-history"></i>Operation History
|
|
</button>
|
|
<button @click="activeTab = 'tools'"
|
|
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-tools"></i>Tools
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Second row - Plugin tabs (populated dynamically) -->
|
|
<div id="plugin-tabs-row" class="border-b border-gray-200">
|
|
<nav 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(() => loadPluginsDirect()); }"
|
|
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-plug"></i>Plugin Manager
|
|
</button>
|
|
<!-- Installed plugin tabs will be added here dynamically -->
|
|
</nav>
|
|
</div>
|
|
</nav>
|
|
</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"></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"></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"></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"></i>
|
|
</button>
|
|
|
|
|
|
<!-- Tab content -->
|
|
<div id="tab-content" class="space-y-6">
|
|
<!-- 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::htmx:response-error="loadOverviewDirect()">
|
|
<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>
|
|
|
|
<script>
|
|
// Fallback: Load overview content directly if HTMX fails
|
|
function loadOverviewDirect() {
|
|
const overviewContent = document.getElementById('overview-content');
|
|
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/overview')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
overviewContent.innerHTML = html;
|
|
overviewContent.setAttribute('data-loaded', 'true');
|
|
if (typeof htmx !== 'undefined') {
|
|
htmx.process(overviewContent);
|
|
}
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(overviewContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load overview content:', err);
|
|
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load overview page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Listen for HTMX load failure
|
|
window.addEventListener('htmx-load-failed', function() {
|
|
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
|
|
// Try to load content directly after a delay
|
|
setTimeout(() => {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}, 2000);
|
|
});
|
|
|
|
// Also try direct load if HTMX doesn't load within 5 seconds
|
|
var _overviewFallbackTimer = setTimeout(() => {
|
|
if (typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}
|
|
}, 5000);
|
|
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
|
</script>
|
|
|
|
<!-- 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::htmx:response-error="loadWifiDirect()">
|
|
<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>
|
|
|
|
<script>
|
|
// Fallback: Load WiFi content directly if HTMX fails
|
|
function loadWifiDirect() {
|
|
const wifiContent = document.getElementById('wifi-content');
|
|
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/wifi')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
wifiContent.innerHTML = html;
|
|
wifiContent.setAttribute('data-loaded', 'true');
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(wifiContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load WiFi content:', err);
|
|
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load WiFi setup page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
|
|
setTimeout(() => {
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
if (isAPMode && typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded, using direct fetch for WiFi content');
|
|
loadWifiDirect();
|
|
}
|
|
}, 3000);
|
|
</script>
|
|
|
|
<!-- 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="loadPluginsDirect()">
|
|
<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>
|
|
|
|
<!-- SSE connection for real-time updates -->
|
|
<script src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
|
|
|
|
<!-- 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/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/store_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
|
|
|
<!-- Load config utilities -->
|
|
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
|
|
|
|
<!-- Widget Registry System -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/registry.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/base-widget.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/notification.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-order-list.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload.js') }}?v=20260307" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-oauth.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload-single.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-file-manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>
|
|
<!-- Basic input widgets -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/text-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/slider.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/color-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/email-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/url-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/password-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
|
|
|
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
|
|
|
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
|
|
window.* implementations for several plugin actions (installPlugin,
|
|
uninstallPlugin, executePluginAction, ...) and intentionally wins
|
|
over same-named definitions in js/app.js and js/app-shell.js. Do not
|
|
remove or reorder without resolving that overlap first. -->
|
|
<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">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
|
|
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></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"></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"></i> Update Now';
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></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.
|
|
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
|
window.showRestartPending(
|
|
'Update installed \u2014 restart the display to run the new code');
|
|
}
|
|
}
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Update complete', data.status || 'success');
|
|
}
|
|
})
|
|
.catch(function() {
|
|
btn.innerHTML = originalHTML;
|
|
btn.disabled = false;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Update failed — check your connection', 'error');
|
|
}
|
|
});
|
|
};
|
|
|
|
// Initial check shortly after page load, then periodic
|
|
setTimeout(checkForUpdate, 2000);
|
|
setInterval(checkForUpdate, CHECK_INTERVAL);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|