mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* feat(web): weekly automatic updates with health check and rollback A General-tab toggle (off by default) checks for and installs LEDMatrix and plugin updates once a week, overnight in the configured timezone. - Pre-update checks skip (and report) instead of forcing: local edits or commits, merge/live rebase, no upstream, low disk, missing health check, or a version that was already rolled back. An abandoned rebase (HEAD back on a branch) is cleared, since it would otherwise block every pull. - The pull reuses the Update Code path (now perform_core_update(), which reports dependency install failures as data). - ledmatrix-update-verify.service, started via a .path unit from a request file, restarts the services from its own cgroup, requires them to come up and stay up, and otherwise resets to the previous commit and reinstalls the previous requirements. It runs a copy of the checker taken before the pull. - No SSH needed: switching the toggle on restarts the display service, which (as root) installs the two units from the repo templates for the web user. first_time_install.sh installs them too and takes --enable-auto-update / LEDMATRIX_AUTO_UPDATE (passed through by one-shot-install.sh). - Plugins update after the code passes its check; failures, blocks and rollbacks raise an Overview banner and show under the toggle. Tested end to end on a Pi: web-UI setup, a good update, a broken web service and a broken display (both rolled back), a blocked local edit, and an abandoned rebase found on the device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(auto-update): address static-analysis findings - Replace the subprocess.CompletedProcess the verifier fabricated for a command that could not start with a plain namedtuple; nothing is executed there, but the scanner flags any CompletedProcess built from variables. - Mark the subprocess imports with the repo's standard B404 annotation (all calls are list-form argv, no shell). - Mark the rollback-failed message as not SQL (B608 matched its wording). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auto-update): CI failures on Linux - Keep the setup result when chown fails. CI runs as a non-root user, where chown to the web user raises; that discarded the result file, so the General tab would never learn whether setup worked. Regression test added. - Register the two new /api/v3/system/auto-update routes in the URL map snapshot. - Use utility classes app.css defines (space-y-1, hover:text-red-600). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auto-update): address review feedback - Health check: a failed restart command no longer lets the check run against the still-running old process; it counts as a failure (and after a rollback, as a failed rollback). An unreadable restart count is never treated as stable, since a crash loop looks healthy between attempts. - Installer writes the auto_update setting to a temp file and swaps it in, keeping mode and owner, so a running config watcher never reads a truncated config.json. - Verify unit quotes its command-line paths (install folders with spaces); setup refuses folder names systemd would reinterpret (%, quotes, backslashes, control characters) and says so on the General tab. - The auto-update status route no longer returns exception text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auto-update): keep error detail in the status route's 500 test_web_error_detail requires every 5xx handler to log the traceback and return describe_exception(e), which redacts credentials, so failures are diagnosable from the web UI. Dropping it for CodeQL broke that policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auto-update): dismiss route rejects non-object JSON with 400 A JSON array or scalar body made `.get('alert_id')` raise, returning 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auto-update): let the app-wide handler answer status-route errors CodeQL (py/stack-trace-exposure, #709) flagged the route's own except, which returned describe_exception(e). web_interface/app.py's error handler already logs the traceback and returns the same redacted detail for any unhandled exception, so the local copy is removed: same response, no new exception-to-response flow, and test_web_error_detail's policy still holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
676 lines
35 KiB
HTML
676 lines
35 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';
|
|
var _recon_timer = null;
|
|
|
|
function checkReconciliation() {
|
|
fetch('/api/v3/plugins/reconciliation-status')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (resp) {
|
|
var d = resp.data || {};
|
|
if (!d.done) {
|
|
// Reconciliation still running — poll again shortly
|
|
_recon_timer = setTimeout(checkReconciliation, 2000);
|
|
return;
|
|
}
|
|
_recon_timer = null;
|
|
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 () {});
|
|
}
|
|
checkReconciliation();
|
|
|
|
window.dismissReconciliationBanner = function () {
|
|
var banner = document.getElementById('reconciliation-banner');
|
|
banner.style.setProperty('display', 'none', 'important');
|
|
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 appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
// Same two-tier resolution as settings-search.js's getAppData():
|
|
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
|
|
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
|
|
(appEl.__x && appEl.__x.$data));
|
|
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()"
|
|
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="if (typeof showNotification !== 'undefined') { 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="if (typeof showNotification !== 'undefined') { 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="if (typeof showNotification !== 'undefined') { var m='Code update completed',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-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="if (typeof showNotification !== 'undefined') { 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="if (typeof showNotification !== 'undefined') { 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="if (typeof showNotification !== 'undefined') { 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="if (typeof showNotification !== 'undefined') { 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';
|
|
});
|
|
})();
|
|
|
|
// Check for updates function
|
|
window.checkForUpdates = function() {
|
|
const btn = event.target.closest('button');
|
|
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('Update successful: ' + (data.stdout || 'Code updated'), 'success');
|
|
// 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('Update failed: ' + (data.stderr || data.message || 'Unknown error'), '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>
|
|
|