mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
- Operation History: the plugin filter lists the installed plugin ids
instead of one option, "plugins" (Object.keys of {plugins: [...]}).
- Ctrl/Cmd+S submits the active tab's first visible form with
requestSubmit() (validation and onsubmit guards run) instead of a bare
Event on the first form in the document; skipped inside a modal dialog
and on tabs without a form.
- Overview "Check Updates" confirms like "Update Code", takes its button
explicitly (no implicit global event) and shows the server's message.
Both, and the Tools tab git pull, raise the restart-pending banner on
restart_required.
- Tools: toolsAction and diagnostics show the server's error message;
only a non-JSON body falls back to HTTP <status>.
- Installed list after uninstall: PluginAPI writes clear the throttler's
GET cache, a forced loadInstalledPlugins clears it too, and the
post-uninstall reload goes through refreshInstalledPlugins().
- Plugin widgets load from /static/plugin-widgets/ only (the other two
paths have no route).
- Raw JSON editor escapes the parse error; slider escapes value/min/max/step.
- Removed the unreferenced array-of-objects and key-value helpers from
plugins_manager.js, the textarea auto-resize and Ctrl+R handlers in
app.js, and a redundant ?v= on the plugins_manager.js script tag.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
686 lines
34 KiB
HTML
686 lines
34 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 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>
|
|
|