mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web-ui): let the MQTT bridge form save a password without TLS
PUT /api/v3/integrations/mqtt-bridge/config refuses a stored password
while mqtt_tls is off unless allow_insecure_mqtt is set (the CWE-319
guard in api_v3/misc.py). The Tools tab form neither rendered a control
for that flag nor sent it, so a password-protected broker on a LAN
without TLS could never be saved from the UI, and once such a password
was in bridge_config.json every later save from the form was refused.
The form now shows "Allow without TLS (trusted network)" while "Use
TLS" is unchecked, prefilled from the GET's config.allow_insecure_mqtt,
and mqttBody() sends its state as allow_insecure_mqtt. The box is off
until the user ticks it, so the server's guard still refuses a
cleartext password by default.
Tests: the Tools DOM suite checks the control, its show/hide with the
TLS box, the prefill and the value saved; a Flask test pins that the
GET reports the opt-in (false until saved on).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): stop the Overview reconciliation poll from running forever
The reconciliation banner script in partials/overview.html re-asked
/api/v3/plugins/reconciliation-status every 2 s until the answer said
done, with no limit. The route answers done: false whenever
ledmatrix_reconciliation.json is missing or unreadable, which happens
when _run_startup_reconciliation raises before writing it or when /tmp
is cleaned under a long-running web service (reconciliation runs once
per process). The browser then sent that request every 2 s for as long
as the page stayed open, on every tab, since the poll was never tied to
the Overview being visible.
The poll now gives up after 30 tries (a minute) and runs only while the
Overview is the active, visible tab, registered with LEDVisibility under
its own key like the other partials' pollers. Dismissing the banner
ends it too.
Test: test/js/unit/test_overview_reconciliation_poll.js runs the shipped
script in a vm with fake timers and fetch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): drop the Display tab's lookup of a removed brightness label
The brightness slider's input handler in partials/display.html set the
text of both #brightness-value and #brightness-display. #387
(978a03b42) removed the "LED brightness: N%" line that carried
#brightness-display, so getElementById returned null and every step of
the slider threw "Cannot set properties of null" into the console. The
visible label still updated, because it is written first.
The dead lookup is removed.
Test: test/js/unit/test_display_partial_ids.js checks every literal
getElementById() in the partial's inline scripts against the ids its
markup renders, and runs the shipped script in a vm to move the slider.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web-ui): a created API token leaves the General tab's form clean
app.js marks a form data-dirty on any input inside it and removes the
mark only after a successful htmx request; its beforeunload handler
asks "Leave site?" while a visible form is still dirty. The API token
form in partials/general.html posts through window.webLogin.createToken
with fetch, so the mark survived the token being created and a reload
of the page with the General tab open prompted about a change that had
already been saved.
createToken now removes data-dirty after a successful create, next to
the form.reset() it already did. A refused request keeps the mark.
Test: test/js/unit/test_general_web_login_token.js runs the shipped
script in a vm with a fake fetch and DOM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(js): match <script> tags the way CodeQL's tag-filter rule expects
The three new suites pull the inline scripts out of their partials with
/<script>([\s\S]*?)<\/script>/g. CodeQL flags that shape as a bad HTML
filtering regexp (js/bad-tag-filter: misses upper case and tags with
attributes or whitespace), four high alerts that blocked the PR. These are
our own templates read by tests, not user input, but the stricter pattern
costs nothing: /<script\b[^>]*>(...)<\/script[^>]*>/gi, as
test_html_escaping.js already uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(js): slice the Display partial's markup around its scripts
CodeQL read the script-stripping replace() as an incomplete HTML sanitizer
(js/incomplete-multi-character-sanitization). The test only reads our own
template, but slicing between the matched blocks gives the same markup
without the pattern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
727 lines
36 KiB
HTML
727 lines
36 KiB
HTML
<!-- Automatic update banner: a weekly update that failed, was blocked or was rolled back -->
|
|
<div id="auto-update-banner" class="bg-red-50 border border-red-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
|
|
<div class="flex-shrink-0 mr-3 mt-0.5">
|
|
<i class="fas fa-exclamation-circle text-red-500"></i>
|
|
</div>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-red-800">Automatic Update Needs Attention</p>
|
|
<p class="text-sm text-red-700 mt-1" id="auto-update-banner-text"></p>
|
|
<p class="text-xs text-red-600 mt-1">Details are under Automatic Updates on the General tab.</p>
|
|
</div>
|
|
<button type="button" onclick="window.dismissAutoUpdateBanner()" class="ml-4 flex-shrink-0 text-red-500 hover:text-red-600" aria-label="Dismiss">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
fetch('/api/v3/system/auto-update')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (resp) {
|
|
var d = resp.data || {};
|
|
if (!d.alert) return;
|
|
document.getElementById('auto-update-banner-text').textContent = d.alert;
|
|
var banner = document.getElementById('auto-update-banner');
|
|
banner.dataset.alertId = d.alert_id || '';
|
|
banner.style.setProperty('display', 'flex', 'important');
|
|
})
|
|
.catch(function () {});
|
|
|
|
// Dismissal is stored server-side, so the banner stays gone on every
|
|
// device until a new failure raises a new alert.
|
|
window.dismissAutoUpdateBanner = function () {
|
|
var banner = document.getElementById('auto-update-banner');
|
|
banner.style.setProperty('display', 'none', 'important');
|
|
fetch('/api/v3/system/auto-update/dismiss', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({alert_id: banner.dataset.alertId})
|
|
}).catch(function () {});
|
|
};
|
|
}());
|
|
</script>
|
|
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
|
|
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
|
|
<div class="flex-shrink-0 mr-3 mt-0.5">
|
|
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
|
</div>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
|
|
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
|
|
</div>
|
|
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
|
// Startup reconciliation is done within seconds of the web service
|
|
// starting. The route also answers done: false when its status file is
|
|
// missing (reconciliation raised before writing it, or /tmp was cleaned
|
|
// under a long-running service), which used to keep this polling every
|
|
// 2 s for as long as the page was open, on every tab. So: give up after
|
|
// a minute, and poll only while the Overview is on screen.
|
|
var POLL_MS = 2000;
|
|
var MAX_POLLS = 30;
|
|
var _recon_timer = null;
|
|
var _polls = 0;
|
|
var _finished = false; // done, given up, or dismissed
|
|
var _active = false;
|
|
var _inFlight = false;
|
|
|
|
function checkReconciliation() {
|
|
_recon_timer = null;
|
|
_inFlight = true;
|
|
fetch('/api/v3/plugins/reconciliation-status')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (resp) {
|
|
_inFlight = false;
|
|
if (_finished) return;
|
|
var d = resp.data || {};
|
|
if (!d.done) {
|
|
// Reconciliation still running (or it never wrote its
|
|
// status): ask again shortly, a bounded number of times.
|
|
if (++_polls >= MAX_POLLS) {
|
|
_finished = true;
|
|
return;
|
|
}
|
|
if (_active) _recon_timer = setTimeout(checkReconciliation, POLL_MS);
|
|
return;
|
|
}
|
|
_finished = true;
|
|
if (!d.unresolved || d.unresolved.length === 0) return;
|
|
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
|
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
|
// These are opposite problems and need opposite advice. Lumping
|
|
// them together told users to delete config.json entries for
|
|
// plugins that were installed and correctly configured.
|
|
function idsOfType(t) {
|
|
return d.unresolved.filter(function (i) { return i.type === t; })
|
|
.map(function (i) { return i.plugin_id; });
|
|
}
|
|
var notConfigured = idsOfType('plugin_missing_in_config');
|
|
var notInstalled = idsOfType('plugin_missing_on_disk');
|
|
var other = d.unresolved.filter(function (i) {
|
|
return i.type !== 'plugin_missing_in_config'
|
|
&& i.type !== 'plugin_missing_on_disk';
|
|
}).map(function (i) { return i.plugin_id; });
|
|
|
|
var parts = [];
|
|
if (notConfigured.length) {
|
|
parts.push('Installed but missing from config: ' + notConfigured.join(', ')
|
|
+ '. Configure them in the Plugin Store - do not remove anything from config.json.');
|
|
}
|
|
if (notInstalled.length) {
|
|
parts.push('In config but not installed: ' + notInstalled.join(', ')
|
|
+ '. Reinstall via the Plugin Store, or remove these entries from config.json.');
|
|
}
|
|
if (other.length) {
|
|
parts.push('Needs attention: ' + other.join(', ') + '.');
|
|
}
|
|
document.getElementById('reconciliation-banner-text').textContent = parts.join(' ');
|
|
var banner = document.getElementById('reconciliation-banner');
|
|
banner.dataset.dismissKey = key;
|
|
banner.style.setProperty('display', 'flex', 'important');
|
|
})
|
|
.catch(function () { _inFlight = false; });
|
|
}
|
|
|
|
function startReconciliationPoll() {
|
|
_active = true;
|
|
if (!_finished && _recon_timer === null && !_inFlight) checkReconciliation();
|
|
}
|
|
function stopReconciliationPoll() {
|
|
_active = false;
|
|
if (_recon_timer !== null) {
|
|
clearTimeout(_recon_timer);
|
|
_recon_timer = null;
|
|
}
|
|
}
|
|
// Keyed apart from any other Overview registration, which a shared key
|
|
// would replace.
|
|
if (window.LEDVisibility) {
|
|
window.LEDVisibility.onActive('overview', startReconciliationPoll,
|
|
stopReconciliationPoll, 'overview-reconciliation');
|
|
} else {
|
|
startReconciliationPoll();
|
|
}
|
|
|
|
window.dismissReconciliationBanner = function () {
|
|
var banner = document.getElementById('reconciliation-banner');
|
|
banner.style.setProperty('display', 'none', 'important');
|
|
_finished = true;
|
|
if (_recon_timer !== null) {
|
|
clearTimeout(_recon_timer);
|
|
_recon_timer = null;
|
|
}
|
|
// Persist dismissal immediately so the banner won't reappear on reload
|
|
// even if the background sync fetch below fails.
|
|
var key = banner.dataset.dismissKey;
|
|
if (key) {
|
|
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
|
|
}
|
|
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
|
|
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
|
|
};
|
|
}());
|
|
</script>
|
|
|
|
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
|
|
auto-check from existing config/endpoints — no new persisted state.
|
|
The timezone step is verified against the browser's own zone rather than
|
|
compared to the shipped default; see the data-check="timezone" block below
|
|
for why. -->
|
|
{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %}
|
|
{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %}
|
|
{% set _loc = main_config.location if main_config and main_config.location else {} %}
|
|
{% set _tz = (main_config.timezone if main_config else '') or '' %}
|
|
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
|
|
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
|
|
<ul class="list-none pl-0 space-y-1 text-sm" id="getting-started-items">
|
|
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
|
|
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
|
|
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
|
|
<i class="far fa-square mr-2"></i>Set your timezone{% if _tz %} — currently {{ _tz }}{% if _loc.city %}, {{ _loc.city }}{% endif %}{% endif %} (General tab)<span data-gs-tz-note class="text-xs"></span></button></li>
|
|
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
|
|
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
|
|
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
|
|
<i class="far fa-square mr-2"></i>Enable a plugin</button></li>
|
|
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="configured" data-tab="plugins">
|
|
<i class="far fa-square mr-2"></i>Configure it (each plugin gets its own tab)</button></li>
|
|
</ul>
|
|
</div>
|
|
<button type="button" onclick="window.dismissGettingStarted()" class="ml-4 flex-shrink-0 text-blue-400 hover:text-blue-600" aria-label="Dismiss getting started checklist">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
var KEY = 'ledmatrix-getting-started-dismissed';
|
|
var MANUAL_KEY = 'ledmatrix-getting-started-manual';
|
|
var card = document.getElementById('getting-started-card');
|
|
if (!card) return;
|
|
try { if (localStorage.getItem(KEY) === '1') return; } catch (e) {}
|
|
card.style.display = 'block';
|
|
|
|
// Manual per-item overrides: the auto-detection is heuristic (a value
|
|
// saved AT its default — e.g. a user genuinely in the default timezone —
|
|
// reads as "not done"), so clicking an item's checkbox marks it done by
|
|
// hand, persisted per browser.
|
|
var manual = {};
|
|
try { manual = JSON.parse(localStorage.getItem(MANUAL_KEY) || '{}') || {}; } catch (e) {}
|
|
function saveManual() {
|
|
try { localStorage.setItem(MANUAL_KEY, JSON.stringify(manual)); } catch (e) {}
|
|
}
|
|
|
|
function setDone(btn, done) {
|
|
btn.dataset.done = done ? '1' : '0';
|
|
var icon = btn.querySelector('i');
|
|
if (icon) { icon.className = done ? 'fas fa-check-square mr-2 text-green-600' : 'far fa-square mr-2'; }
|
|
btn.classList.toggle('text-gray-500', done);
|
|
btn.classList.toggle('line-through', done);
|
|
}
|
|
|
|
// Once every step is done (auto-detected or manually checked), the card
|
|
// has served its purpose — hide it without requiring an explicit dismiss.
|
|
function maybeAutoHide() {
|
|
var items = card.querySelectorAll('.gs-item');
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (items[i].dataset.done !== '1') return;
|
|
}
|
|
card.style.display = 'none';
|
|
}
|
|
|
|
function markDone(btn) {
|
|
if (!btn) return;
|
|
setDone(btn, true);
|
|
maybeAutoHide();
|
|
}
|
|
|
|
// Apply server-derived + manual states, wire deep links (same app-data
|
|
// access pattern as settings-search.js). Clicking the checkbox icon
|
|
// toggles manual done; clicking the text deep-links to the tab.
|
|
Array.prototype.forEach.call(card.querySelectorAll('.gs-item'), function (btn, idx) {
|
|
if (manual[idx] === 1) btn.dataset.done = '1';
|
|
if (btn.dataset.done === '1') setDone(btn, true);
|
|
btn.addEventListener('click', function (ev) {
|
|
var icon = btn.querySelector('i');
|
|
if (icon && ev.target === icon) {
|
|
var nowDone = btn.dataset.done !== '1';
|
|
setDone(btn, nowDone);
|
|
manual[idx] = nowDone ? 1 : 0;
|
|
saveManual();
|
|
if (nowDone) maybeAutoHide();
|
|
return;
|
|
}
|
|
var data = window.getApp();
|
|
if (data) {
|
|
data.activeTab = btn.dataset.tab;
|
|
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
|
}
|
|
});
|
|
});
|
|
maybeAutoHide();
|
|
|
|
// Timezone: verified against the browser's own zone.
|
|
//
|
|
// This step used to tick when the saved timezone differed from the value
|
|
// config.template.json ships (America/New_York), with the saved city
|
|
// OR-ed in. Two things were wrong with that. "Differs from the default"
|
|
// answers "did somebody edit this?", but what the checklist needs to know
|
|
// is whether the value is RIGHT — so anyone who genuinely lives in the
|
|
// default zone could never satisfy it and the card nagged forever. And
|
|
// the city has no bearing on whether the timezone is set: because the two
|
|
// were OR-ed, saving a city ticked the step off with the timezone still
|
|
// wrong, which is the direction that actually breaks displays (event
|
|
// times render in the wrong zone).
|
|
//
|
|
// The browser already knows its zone, so compare against that: no new
|
|
// persisted state, no network, and it catches the reverse case too — a
|
|
// panel still set to the old zone after a move now stays unticked, where
|
|
// the old test ticked it the moment the value stopped being the default.
|
|
function sameZone(a, b) {
|
|
if (a === b) return true;
|
|
// Compare the wall-clock time each zone yields, not the identifiers:
|
|
// aliases (Asia/Calcutta vs Asia/Kolkata, Europe/Kiev vs Europe/Kyiv)
|
|
// name one zone and must not read as a mismatch.
|
|
//
|
|
// Sampled at three instants, all of which have to agree. Checking only
|
|
// now is not enough: America/New_York and America/Lima hold the same
|
|
// offset all winter, so a panel set to the wrong one of those would
|
|
// tick in January and then run an hour off from March. Mid-January and
|
|
// mid-July sit either side of DST in both hemispheres, so only zones
|
|
// that agree year-round match -- while Toronto still matches New York,
|
|
// which is right, since either renders the same times.
|
|
try {
|
|
var now = new Date();
|
|
var year = now.getUTCFullYear();
|
|
var instants = [now,
|
|
new Date(Date.UTC(year, 0, 15, 12)),
|
|
new Date(Date.UTC(year, 6, 15, 12))];
|
|
var stamp = function (tz, at) {
|
|
// Explicit numeric fields rather than dateStyle/timeStyle:
|
|
// those are late additions to Intl (Firefox shipped them in
|
|
// 91), and an implementation that does not know them ignores
|
|
// them and formats the date alone. That would compare
|
|
// New York, Chicago and Madrid as equal and tick the step for
|
|
// a timezone that is plainly wrong -- the exact failure this
|
|
// check exists to catch. These options have been in Intl
|
|
// since ECMA-402 v1.
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz, year: 'numeric', month: '2-digit',
|
|
day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
hour12: false
|
|
}).format(at);
|
|
};
|
|
for (var i = 0; i < instants.length; i++) {
|
|
if (stamp(a, instants[i]) !== stamp(b, instants[i])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
// An unparseable zone in the config is worth surfacing, not hiding.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
(function () {
|
|
var tzBtn = card.querySelector('[data-check="timezone"]');
|
|
if (!tzBtn) return;
|
|
var configured = tzBtn.dataset.tz || '';
|
|
if (!configured) return; // nothing saved yet: leave it open
|
|
var local = '';
|
|
try {
|
|
local = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
|
|
} catch (e) {
|
|
return; // no Intl: leave it to the manual tick
|
|
}
|
|
if (!local) return;
|
|
if (sameZone(configured, local)) {
|
|
markDone(tzBtn);
|
|
return;
|
|
}
|
|
// Unticked on its own says "wrong" without saying why; name the zone
|
|
// the browser is in so the step is actionable.
|
|
var note = tzBtn.querySelector('[data-gs-tz-note]');
|
|
if (note) note.textContent = ' — this browser is in ' + local;
|
|
}());
|
|
|
|
// Plugin-derived states from the existing installed-plugins endpoint.
|
|
fetch('/api/v3/plugins/installed')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (resp) {
|
|
var plugins = (resp.data && resp.data.plugins) || [];
|
|
if (plugins.length > 0) markDone(card.querySelector('[data-check="installed"]'));
|
|
var enabled = plugins.filter(function (p) { return p.enabled; });
|
|
if (enabled.length > 0) {
|
|
markDone(card.querySelector('[data-check="enabled"]'));
|
|
// "Configured" heuristic: the first enabled plugin has at least
|
|
// one saved value that differs from its schema defaults.
|
|
var pid = enabled[0].id;
|
|
Promise.all([
|
|
fetch('/api/v3/plugins/config?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); }),
|
|
fetch('/api/v3/plugins/schema?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); })
|
|
]).then(function (res) {
|
|
// GET /plugins/config returns the config dict directly in .data
|
|
var cfg = res[0].data || {};
|
|
var props = (res[1].data && res[1].data.schema && res[1].data.schema.properties) || {};
|
|
for (var k in cfg) {
|
|
if (k === 'enabled' || !(k in props)) continue;
|
|
if (props[k] && 'default' in props[k] &&
|
|
JSON.stringify(cfg[k]) !== JSON.stringify(props[k]['default'])) {
|
|
markDone(card.querySelector('[data-check="configured"]'));
|
|
return;
|
|
}
|
|
}
|
|
}).catch(function () {});
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
|
|
window.dismissGettingStarted = function () {
|
|
card.style.display = 'none';
|
|
try { localStorage.setItem(KEY, '1'); } catch (e) {}
|
|
};
|
|
}());
|
|
</script>
|
|
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
|
<p class="mt-1 text-sm text-gray-600">Monitor system status and manage your LED matrix display.</p>
|
|
</div>
|
|
|
|
<!-- System Stats Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-6 mb-8">
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<i class="fas fa-microchip text-blue-600 text-xl"></i>
|
|
</div>
|
|
<div class="ml-3 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">CPU Usage</dt>
|
|
<dd class="text-lg font-medium text-gray-900" id="cpu-usage">--%</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<i class="fas fa-memory text-green-600 text-xl"></i>
|
|
</div>
|
|
<div class="ml-3 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">Memory Usage</dt>
|
|
<dd class="text-lg font-medium text-gray-900" id="memory-usage">--%</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<i class="fas fa-thermometer-half text-red-600 text-xl"></i>
|
|
</div>
|
|
<div class="ml-3 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">CPU Temperature</dt>
|
|
<dd class="text-lg font-medium text-gray-900" id="cpu-temp">--°C</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<i class="fas fa-desktop text-purple-600 text-xl"></i>
|
|
</div>
|
|
<div class="ml-3 w-0 flex-1">
|
|
<dl>
|
|
<dt class="text-sm font-medium text-gray-500 truncate">Display Status</dt>
|
|
<dd class="text-lg font-medium text-gray-900" id="display-status">Unknown</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Version Info -->
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-code-branch text-blue-600 text-xl mr-3"></i>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600">LEDMatrix Version</dt>
|
|
<dd class="text-lg font-semibold text-gray-900" id="ledmatrix-version">Loading...</dd>
|
|
</div>
|
|
</div>
|
|
<button onclick="checkForUpdates(this)"
|
|
class="inline-flex items-center px-3 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50">
|
|
<i class="fas fa-sync-alt mr-2"></i>
|
|
Check Updates
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
<h3 class="text-md font-medium text-gray-900 mb-4">Quick Actions</h3>
|
|
<div class="flex flex-wrap gap-3" hx-ext="json-enc">
|
|
<button hx-post="/api/v3/system/action"
|
|
hx-vals='{"action": "start_display"}'
|
|
hx-swap="none"
|
|
hx-on:htmx:after-request="var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
|
|
<i class="fas fa-play mr-2"></i>
|
|
Start Display
|
|
</button>
|
|
|
|
<button hx-post="/api/v3/system/action"
|
|
hx-vals='{"action": "stop_display"}'
|
|
hx-swap="none"
|
|
hx-on:htmx:after-request="var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
|
|
<i class="fas fa-stop mr-2"></i>
|
|
Stop Display
|
|
</button>
|
|
|
|
<button hx-post="/api/v3/system/action"
|
|
hx-vals='{"action": "git_pull"}'
|
|
hx-confirm="This will stash any local changes and update the code. Continue?"
|
|
hx-swap="none"
|
|
hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; if (d.restart_required) window.notifyCodeUpdateRestart(); } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
|
<i class="fas fa-download mr-2"></i>
|
|
Update Code
|
|
</button>
|
|
|
|
<button hx-post="/api/v3/system/action"
|
|
hx-vals='{"action": "reboot_system"}'
|
|
hx-confirm="Are you sure you want to reboot the system?"
|
|
hx-swap="none"
|
|
hx-on:htmx:after-request="var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
|
<i class="fas fa-power-off mr-2"></i>
|
|
Reboot System
|
|
</button>
|
|
|
|
<button hx-post="/api/v3/system/action"
|
|
hx-vals='{"action": "shutdown_system"}'
|
|
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
|
hx-swap="none"
|
|
hx-on:htmx:after-request="var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
|
|
<i class="fas fa-power-off mr-2"></i>
|
|
Shutdown System
|
|
</button>
|
|
|
|
<button hx-post="/api/v3/system/action"
|
|
hx-vals='{"action": "restart_display_service"}'
|
|
hx-swap="none"
|
|
hx-on:htmx:after-request="var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
|
<i class="fas fa-redo mr-2"></i>
|
|
Restart Display Service
|
|
</button>
|
|
|
|
<button hx-post="/api/v3/system/action"
|
|
hx-vals='{"action": "restart_web_service"}'
|
|
hx-swap="none"
|
|
hx-on:htmx:after-request="var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
|
<i class="fas fa-redo mr-2"></i>
|
|
Restart Web Service
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Display Preview -->
|
|
<div>
|
|
<h3 class="text-md font-medium text-gray-900 mb-4">
|
|
<i class="fas fa-desktop"></i> Live Display Preview
|
|
</h3>
|
|
<!-- overflow-x-auto: on narrow screens a wide preview scrolls at its
|
|
true pixel-perfect size instead of being squeezed (fractional
|
|
downscaling of pixel art reads as blur) -->
|
|
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700 overflow-x-auto" style="position: relative;">
|
|
<div id="previewStage" class="preview-stage" style="display:none; position:relative; display:inline-block;">
|
|
<div id="previewMeta" style="position:absolute; top:-28px; left:0; color:#ddd; font-size:12px; opacity:0.85;"></div>
|
|
<img id="displayImage" style="image-rendering: pixelated; display: block;" alt="LED Matrix Display">
|
|
<canvas id="ledCanvas" style="position:absolute; top:0; left:0; pointer-events:none; display:none; z-index: 10;"></canvas>
|
|
<canvas id="gridOverlay" style="position:absolute; top:0; left:0; pointer-events:none; z-index: 20;"></canvas>
|
|
</div>
|
|
<div id="displayPlaceholder" class="text-center text-gray-400 py-8">
|
|
<i class="fas fa-spinner fa-spin text-4xl mb-3"></i>
|
|
<p>Connecting to display...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Display Controls -->
|
|
<div class="mt-4 flex flex-wrap items-center gap-3">
|
|
<button onclick="takeScreenshot()" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
|
<i class="fas fa-camera mr-2"></i> Screenshot
|
|
</button>
|
|
|
|
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm">
|
|
<span>Scale:</span>
|
|
<input type="range" id="scaleRange" min="2" max="16" value="8" class="w-20">
|
|
<span id="scaleValue" class="font-medium">8x</span>
|
|
</label>
|
|
|
|
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm cursor-pointer">
|
|
<input type="checkbox" id="toggleGrid" class="rounded">
|
|
<span>Show pixel grid</span>
|
|
</label>
|
|
|
|
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm cursor-pointer">
|
|
<input type="checkbox" id="toggleLedDots" checked class="rounded">
|
|
<span>LED dot mode</span>
|
|
</label>
|
|
|
|
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm">
|
|
<span>Dot fill:</span>
|
|
<input type="range" id="dotFillRange" min="40" max="95" value="75" class="w-16">
|
|
<span id="dotFillValue" class="font-medium">75%</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats are updated via the global updateSystemStats function in base.html -->
|
|
|
|
<script>
|
|
// Load LEDMatrix version
|
|
(function() {
|
|
fetch('/api/v3/system/version')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const versionEl = document.getElementById('ledmatrix-version');
|
|
if (versionEl && data.status === 'success') {
|
|
versionEl.textContent = data.data.version;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
const versionEl = document.getElementById('ledmatrix-version');
|
|
if (versionEl) versionEl.textContent = 'Unknown';
|
|
});
|
|
})();
|
|
|
|
// A pull only replaces files on disk; the running services keep the code
|
|
// they loaded at boot. Same wording as the update banner in base.html.
|
|
window.notifyCodeUpdateRestart = function() {
|
|
if (typeof window.showRestartPending === 'function') {
|
|
window.showRestartPending(
|
|
'Update installed \u2014 restart the display and web ' +
|
|
'services to run the new code');
|
|
}
|
|
};
|
|
|
|
// Check for updates function: the same git_pull (and the same confirmation)
|
|
// as the Update Code quick action below.
|
|
window.checkForUpdates = function(btn) {
|
|
if (!confirm('This will stash any local changes and update the code. Continue?')) {
|
|
return;
|
|
}
|
|
if (btn) {
|
|
const originalContent = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Checking...';
|
|
|
|
fetch('/api/v3/system/action', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'git_pull' })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
btn.innerHTML = originalContent;
|
|
btn.disabled = false;
|
|
|
|
if (data.status === 'success') {
|
|
showNotification(data.message || 'Code updated', 'success');
|
|
if (data.restart_required) window.notifyCodeUpdateRestart();
|
|
// Reload version after a short delay
|
|
setTimeout(() => {
|
|
fetch('/api/v3/system/version')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const versionEl = document.getElementById('ledmatrix-version');
|
|
if (versionEl && data.status === 'success') {
|
|
versionEl.textContent = data.data.version;
|
|
}
|
|
});
|
|
}, 1000);
|
|
} else {
|
|
showNotification(data.message || 'Update failed', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
btn.innerHTML = originalContent;
|
|
btn.disabled = false;
|
|
showNotification('Error checking for updates: ' + error.message, 'error');
|
|
});
|
|
}
|
|
};
|
|
|
|
// Setup display preview controls (runs when this partial loads)
|
|
(function() {
|
|
const scaleRange = document.getElementById('scaleRange');
|
|
const scaleValue = document.getElementById('scaleValue');
|
|
const dotFillRange = document.getElementById('dotFillRange');
|
|
const dotFillValue = document.getElementById('dotFillValue');
|
|
const toggleGrid = document.getElementById('toggleGrid');
|
|
const toggleLedDots = document.getElementById('toggleLedDots');
|
|
|
|
if (scaleRange && scaleValue) {
|
|
scaleRange.addEventListener('input', function() {
|
|
scaleValue.textContent = this.value + 'x';
|
|
// Re-render the preview with new scale
|
|
const img = document.getElementById('displayImage');
|
|
if (img && img.src) {
|
|
const data = {
|
|
image: img.src.replace('data:image/png;base64,', ''),
|
|
width: img.naturalWidth,
|
|
height: img.naturalHeight
|
|
};
|
|
updateDisplayPreview(data);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (dotFillRange && dotFillValue) {
|
|
dotFillRange.addEventListener('input', function() {
|
|
dotFillValue.textContent = this.value + '%';
|
|
renderLedDots();
|
|
});
|
|
}
|
|
|
|
if (toggleGrid) {
|
|
toggleGrid.addEventListener('change', function() {
|
|
const canvas = document.getElementById('gridOverlay');
|
|
const img = document.getElementById('displayImage');
|
|
if (canvas && img && img.src) {
|
|
const scale = parseInt(scaleRange?.value || '8');
|
|
drawGrid(canvas, img.naturalWidth, img.naturalHeight, scale);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (toggleLedDots) {
|
|
toggleLedDots.addEventListener('change', function() {
|
|
renderLedDots();
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
|