Files
LEDMatrix/web_interface/templates/v3/partials/tools.html
ChuckandClaude Opus 5.5 2236ff3081 fix(web-ui): MQTT password without TLS, Overview poll that never stopped, brightness slider error, token form left dirty (#745)
* 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>
2026-10-03 22:30:51 -04:00

1414 lines
77 KiB
HTML

<div class="space-y-6" id="tools-root">
<!-- System Diagnostics -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6 flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">System Diagnostics</h2>
<p class="mt-1 text-sm text-gray-600">Live CPU, memory, temperature, disk, and uptime for this Raspberry Pi.</p>
</div>
<button id="btn-diag-refresh" onclick="loadSystemDiagnostics()"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</button>
</div>
<div id="diag-panel" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="animate-pulse text-gray-400 col-span-full">Loading diagnostics…</div>
</div>
</div>
<!-- Git & Updates -->
<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">Git &amp; Updates</h2>
<p class="mt-1 text-sm text-gray-600">Inspect the current git state and pull or reset to the latest remote code.</p>
</div>
<!-- Git status info -->
<div id="git-info-panel" class="mb-6 bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
<div class="animate-pulse text-gray-400">Loading git info…</div>
</div>
<div class="space-y-4">
<!-- Switch branch -->
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Branch</p>
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
</div>
<div class="shrink-0 flex items-center gap-2">
<select id="branch-select" class="text-sm border border-gray-300 rounded-md px-2 py-2 bg-white max-w-[14rem]">
<option value="">Loading branches…</option>
</select>
<button id="btn-checkout-branch" onclick="checkoutBranch(false)"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-code-branch mr-2"></i>Switch
</button>
</div>
</div>
<div id="result-checkout-branch" class="hidden"></div>
<!-- Pull latest -->
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
<p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p>
</div>
<button id="btn-git-pull" onclick="toolsAction('git_pull', 'btn-git-pull', 'result-git-pull')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i>Pull Latest
</button>
</div>
<div id="result-git-pull" class="hidden"></div>
<!-- Force reset -->
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<button id="btn-force-reset-confirm" onclick="showForceResetConfirm()"
class="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50">
<i class="fas fa-exclamation-triangle mr-2"></i>Force Reset…
</button>
<div id="force-reset-confirm-row" class="hidden flex items-center gap-2">
<span class="text-xs text-red-700 font-medium">This discards all local changes. Sure?</span>
<button onclick="toolsAction('force_git_reset', 'btn-force-reset-confirm', 'result-force-reset'); hideForceResetConfirm()"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
Yes, reset
</button>
<button onclick="hideForceResetConfirm()"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
<div id="result-force-reset" class="hidden"></div>
</div>
</div>
<!-- Python Dependencies -->
<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">Python Dependencies</h2>
<p class="mt-1 text-sm text-gray-600">Re-run <code class="bg-gray-100 px-1 rounded">pip install</code> to fix missing or broken packages.</p>
</div>
<div class="space-y-4">
<!-- Base requirements -->
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
</div>
<button id="btn-base-reqs" onclick="toolsAction('install_base_requirements', 'btn-base-reqs', 'result-base-reqs', true)"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-box mr-2"></i>Reinstall Base
</button>
</div>
<div id="result-base-reqs" class="hidden"></div>
<!-- Plugin requirements -->
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
</div>
<button id="btn-plugin-reqs" onclick="toolsAction('install_plugin_requirements', 'btn-plugin-reqs', 'result-plugin-reqs', false, true)"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-puzzle-piece mr-2"></i>Reinstall Plugin Deps
</button>
</div>
<div id="result-plugin-reqs" class="hidden"></div>
</div>
</div>
<!-- Maintenance -->
<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">Maintenance</h2>
<p class="mt-1 text-sm text-gray-600">Housekeeping operations that don't affect config or plugins.</p>
</div>
<div class="space-y-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
</div>
<button id="btn-clear-pycache" onclick="toolsAction('clear_pycache', 'btn-clear-pycache', 'result-clear-pycache')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-broom mr-2"></i>Clear Cache
</button>
</div>
<div id="result-clear-pycache" class="hidden"></div>
</div>
</div>
<!-- Power Supply Diagnostics -->
<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">Power Supply</h2>
<p class="mt-1 text-sm text-gray-600">Raspberry Pi under-voltage/throttling status (via <code class="bg-gray-100 px-1 rounded">vcgencmd get_throttled</code>). A marginal power supply is a common cause of visible flicker or dimming on LED panels.</p>
</div>
<div id="power-info-panel" class="text-sm">
<div class="animate-pulse text-gray-400">Waiting for live stats…</div>
</div>
</div>
<!-- Network Radio -->
<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">Network Radio</h2>
<p class="mt-1 text-sm text-gray-600">Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the <span class="font-medium">WiFi</span> tab.</p>
</div>
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p id="wifi-radio-label" class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-note" class="text-xs text-gray-500 mt-0.5">Checking current state…</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<!-- Toggle switch -->
<button id="wifi-radio-toggle" type="button" role="switch" aria-checked="false"
aria-labelledby="wifi-radio-label" aria-describedby="wifi-radio-note"
onclick="onWifiRadioToggleClick()" disabled
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 opacity-60">
<span id="wifi-radio-knob" aria-hidden="true"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out translate-x-0"></span>
</button>
<!-- Force-off confirm row (shown only when disabling without Ethernet is refused) -->
<div id="wifi-radio-force-row" class="hidden flex-col items-end gap-2">
<span class="text-xs text-red-700 font-medium text-right max-w-xs">No wired connection detected — turning WiFi off will disconnect you from this page. Continue anyway?</span>
<div class="flex items-center gap-2">
<button onclick="forceDisableWifiRadio()"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
Turn WiFi off anyway
</button>
<button onclick="hideWifiForceRow()"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
</div>
<div id="result-wifi-radio" class="hidden"></div>
</div>
<!-- Services -->
<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">Services</h2>
<p class="mt-1 text-sm text-gray-600">Quick access to service restarts.</p>
</div>
<div class="space-y-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Restart display service</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
</div>
<button id="btn-restart-display" onclick="toolsAction('restart_display_service', 'btn-restart-display', 'result-restart-display')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-sync-alt mr-2"></i>Restart Display
</button>
</div>
<div id="result-restart-display" class="hidden"></div>
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
</div>
<button id="btn-restart-web" onclick="toolsAction('restart_web_service', 'btn-restart-web', 'result-restart-web')"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-globe mr-2"></i>Restart Web
</button>
</div>
<div id="result-restart-web" class="hidden"></div>
</div>
</div>
<!-- MQTT Bridge -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-900">MQTT Bridge</h2>
<span id="mqtt-bridge-state" class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">checking&hellip;</span>
</div>
<p class="mt-1 text-sm text-gray-600">
Exposes the display to Home Assistant over MQTT &mdash; force a mode on demand, set
brightness, toggle power. Runs as <code class="bg-gray-100 px-1 rounded">ledmatrix-mqtt-bridge.service</code>.
</p>
</div>
<div id="mqtt-bridge-body" class="space-y-4">
<p class="text-sm text-gray-500">Loading settings&hellip;</p>
</div>
</div>
<!-- Pixlet Config Editor -->
<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">Pixlet Config Editor</h2>
<p class="mt-1 text-sm text-gray-600">
Opens a Starlark app in Pixlet's own config form, which runs the app for real &mdash;
so dropdowns that build their options at runtime work, and you see the render update
as you type.
</p>
<p class="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
The display stops while a session is open, and the editor has no password &mdash; the
same footing as this page. Sessions end by themselves after 30&nbsp;minutes, so the
panel is never left dark.
</p>
</div>
<div id="pixlet-editor-body" class="space-y-4">
<p class="text-sm text-gray-500">Loading apps&hellip;</p>
</div>
</div>
<!-- System Power -->
<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 Power</h2>
<p class="mt-1 text-sm text-gray-600">Reboot or shut down the Raspberry Pi. The web interface will go offline.</p>
</div>
<div class="space-y-4">
<!-- Reboot -->
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reboot</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo reboot</code>. The Pi will restart and come back online in a minute or two.</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<button id="btn-reboot" onclick="showPowerConfirm('reboot')"
class="inline-flex items-center px-3 py-2 border border-amber-300 text-sm font-medium rounded-md text-amber-700 bg-white hover:bg-amber-50">
<i class="fas fa-power-off mr-2"></i>Reboot…
</button>
<div id="reboot-confirm-row" class="hidden flex items-center gap-2">
<span class="text-xs text-amber-700 font-medium">Reboot now?</span>
<button onclick="powerAction('reboot_system', 'btn-reboot', 'result-reboot', 'Reboot command sent — the Pi is restarting. This page will go offline and should return in a minute or two.'); hidePowerConfirm('reboot')"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700">
Yes, reboot
</button>
<button onclick="hidePowerConfirm('reboot')"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
<div id="result-reboot" class="hidden"></div>
<!-- Shutdown -->
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Shut down</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo poweroff</code>. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<button id="btn-shutdown" onclick="showPowerConfirm('shutdown')"
class="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50">
<i class="fas fa-plug mr-2"></i>Shut Down…
</button>
<div id="shutdown-confirm-row" class="hidden flex items-center gap-2">
<span class="text-xs text-red-700 font-medium">Power off now?</span>
<button onclick="powerAction('shutdown_system', 'btn-shutdown', 'result-shutdown', 'Shutdown command sent — the Pi is powering off. You will need to power-cycle it to turn it back on.'); hidePowerConfirm('shutdown')"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
Yes, shut down
</button>
<button onclick="hidePowerConfirm('shutdown')"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
<div id="result-shutdown" class="hidden"></div>
</div>
</div>
<!-- Plugin Health -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6 flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Plugin Health</h2>
<p class="mt-1 text-sm text-gray-600">Circuit-breaker status and per-plugin update timings recorded by the display service. A plugin whose <code class="bg-gray-100 px-1 rounded">update()</code> keeps failing is paused ("Circuit open") and retried automatically after a cooldown.</p>
</div>
<button id="btn-plugin-health-refresh" onclick="refreshPluginHealth(true)"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</button>
</div>
<div id="plugin-health-message" class="hidden mb-4 text-sm text-gray-500"></div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plugin</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg update</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Max update</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Updates</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last error</th>
</tr>
</thead>
<tbody id="plugin-health-tbody" class="bg-white divide-y divide-gray-200">
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
(function () {
// ── helpers ──────────────────────────────────────────────────────────────
function setBusy(btnId, busy) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = busy;
btn.style.opacity = busy ? '0.6' : '';
btn.style.cursor = busy ? 'wait' : '';
const icon = btn.querySelector('i');
if (icon) {
if (busy) {
icon.dataset.origClass = icon.className;
icon.className = 'fas fa-spinner fa-spin mr-2';
} else if (icon.dataset.origClass) {
icon.className = icon.dataset.origClass;
}
}
}
function showResult(resultId, ok, message, output, pluginDetails) {
const el = document.getElementById(resultId);
if (!el) return;
el.classList.remove('hidden');
const color = ok ? 'green' : 'red';
const icon = ok ? 'fa-check-circle' : 'fa-times-circle';
let html = `
<div class="mt-3 rounded-md p-3 bg-${color}-50 border border-${color}-200">
<div class="flex items-start gap-2">
<i class="fas ${icon} text-${color}-600 mt-0.5"></i>
<span class="text-sm text-${color}-800">${escHtml(message)}</span>
</div>`;
if (output) {
html += `
<details class="mt-2">
<summary class="text-xs text-${color}-700 cursor-pointer hover:underline">Show output</summary>
<pre class="mt-2 text-xs bg-gray-900 text-gray-100 rounded p-3 overflow-x-auto whitespace-pre-wrap">${escHtml(output)}</pre>
</details>`;
}
if (pluginDetails && pluginDetails.length > 0) {
html += `<ul class="mt-3 space-y-1">`;
for (const d of pluginDetails) {
const dc = d.ok ? 'green' : 'red';
const di = d.ok ? 'fa-check' : 'fa-times';
html += `<li class="text-xs flex items-start gap-1">
<i class="fas ${di} text-${dc}-600 mt-0.5 w-3"></i>
<span class="text-gray-700">${escHtml(d.plugin)}</span>`;
if (d.output) {
html += ` <details class="inline"><summary class="cursor-pointer text-gray-400 hover:underline ml-1">output</summary>
<pre class="mt-1 text-xs bg-gray-900 text-gray-100 rounded p-2 overflow-x-auto whitespace-pre-wrap">${escHtml(d.output)}</pre></details>`;
}
html += `</li>`;
}
html += `</ul>`;
}
html += `</div>`;
el.innerHTML = html;
}
function escHtml(s) { return window.LEDEscape.html(s); }
// ── main action dispatcher ────────────────────────────────────────────────
window.toolsAction = function(action, btnId, resultId, showOutput, showPluginDetails) {
setBusy(btnId, true);
const el = document.getElementById(resultId);
if (el) el.classList.add('hidden');
fetch('/api/v3/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action})
})
.then(r => {
if (!r.ok) {
// Only a non-JSON body falls back to the status code; the
// rejection carrying the server's message must not be caught.
return r.json()
.catch(() => null)
.then(d => Promise.reject(new Error((d && d.message) || `HTTP ${r.status}`)));
}
return r.json();
})
.then(data => {
const ok = data.status === 'success';
showResult(
resultId, ok,
data.message || (ok ? 'Done' : 'Failed'),
showOutput ? (data.output || '') : '',
showPluginDetails ? (data.details || []) : null
);
// A pull replaces files on disk; the services keep running the
// code they loaded at boot until restarted.
if (ok && action === 'git_pull' && data.restart_required &&
typeof window.showRestartPending === 'function') {
window.showRestartPending(
'Update installed \u2014 restart the display and web ' +
'services to run the new code');
}
})
.catch(err => {
showResult(resultId, false, 'Request failed: ' + err.message);
})
.finally(() => setBusy(btnId, false));
};
// ── force-reset confirm helpers ───────────────────────────────────────────
window.showForceResetConfirm = function() {
document.getElementById('force-reset-confirm-row').classList.remove('hidden');
document.getElementById('btn-force-reset-confirm').classList.add('hidden');
};
window.hideForceResetConfirm = function() {
document.getElementById('force-reset-confirm-row').classList.add('hidden');
document.getElementById('btn-force-reset-confirm').classList.remove('hidden');
};
// ── git info panel ────────────────────────────────────────────────────────
function loadGitInfo() {
const panel = document.getElementById('git-info-panel');
if (!panel) return;
fetch('/api/v3/system/git-info')
.then(r => {
if (!r.ok) return r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`));
return r.json();
})
.then(d => {
if (d.status === 'error') {
panel.innerHTML = `<span class="text-sm text-red-600">${escHtml(d.message || 'Git info unavailable.')}</span>`;
return;
}
const dirtyBadge = d.dirty
? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">dirty</span>'
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">clean</span>';
let html = `<div class="space-y-2">
<div class="flex items-center gap-2">
<i class="fas fa-code-branch text-gray-400"></i>
<span class="font-mono text-gray-800">${escHtml(d.detached ? (d.version || 'detached') : (d.branch || 'unknown'))}</span>
${dirtyBadge}
</div>`;
if (d.dirty && d.status) {
html += `<pre class="text-xs bg-yellow-50 border border-yellow-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-yellow-900">${escHtml(d.status)}</pre>`;
}
if (d.recent_commits) {
html += `<div class="mt-2">
<p class="text-xs text-gray-500 mb-1">Recent commits</p>
<pre class="text-xs bg-gray-50 border border-gray-200 rounded p-2 overflow-x-auto whitespace-pre-wrap text-gray-700">${escHtml(d.recent_commits)}</pre>
</div>`;
}
if (d.detached && d.current_release) {
// The stable update channel sits on release tags, with no branch.
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-tag mr-1"></i>On release ${escHtml(d.current_release)}, not a branch (stable update channel). Pull Latest follows the update channel set on the General tab.</p>`;
} else if (d.detached) {
// Detached but not on a release: say what the channel does
// with it, in the General tab's words.
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-tag mr-1"></i>Not on a branch or a release. ${escHtml(d.channel_message || '')} Pull Latest follows the update channel set on the General tab.</p>`;
} else if (d.upstream) {
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-link mr-1"></i>tracking <span class="font-mono">${escHtml(d.upstream)}</span></p>`;
} else if (d.can_pull) {
html += `<p class="text-xs text-blue-700 mt-1"><i class="fas fa-info-circle mr-1"></i>No upstream set; Pull Latest will use <span class="font-mono">origin/${escHtml(d.branch || '')}</span> and set it.</p>`;
} else {
html += `<p class="text-xs text-amber-700 mt-1"><i class="fas fa-triangle-exclamation mr-1"></i>No upstream and no matching branch on origin — Pull Latest cannot run. Switch to a branch that exists on the remote.</p>`;
}
if (d.remote_url) {
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
}
html += `</div>`;
panel.innerHTML = html;
})
.catch(err => {
panel.innerHTML = `<span class="text-sm text-red-600">Could not load git info: ${escHtml(String(err))}</span>`;
});
}
// ── branch picker ─────────────────────────────────────────────────────
// A pi can end up on a branch with no tracking information (checked out by
// name, restored from a backup), where `git pull` refuses to run. Being
// able to see and change the branch from here beats needing SSH.
function loadBranches() {
const sel = document.getElementById('branch-select');
if (!sel) return;
fetch('/api/v3/system/git-branches')
.then(r => r.ok ? r.json() : r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`)))
.then(d => {
if (d.status === 'error') {
sel.innerHTML = `<option value="">${escHtml(d.message || 'unavailable')}</option>`;
sel.disabled = true;
return;
}
sel.innerHTML = '';
const add = (name, suffix) => {
const o = document.createElement('option');
o.value = name;
o.textContent = name + (suffix || '');
if (name === d.current) o.selected = true;
sel.appendChild(o);
};
(d.local || []).forEach(b => add(b, b === d.current ? ' (current)' : ''));
// Remote-only branches are checked out on demand.
(d.remote_only || []).forEach(b => add(b, ' (remote)'));
if (!sel.options.length) add('', 'no branches found');
})
.catch(err => {
sel.innerHTML = `<option value="">${escHtml(String(err))}</option>`;
sel.disabled = true;
});
}
window.checkoutBranch = function(stash) {
const sel = document.getElementById('branch-select');
const branch = sel && sel.value;
if (!branch) return;
setBusy('btn-checkout-branch', true);
const el = document.getElementById('result-checkout-branch');
if (el) el.classList.add('hidden');
fetch('/api/v3/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'checkout_branch', branch: branch, stash: !!stash})
})
.then(r => r.json().catch(() => ({status: 'error', message: `HTTP ${r.status}`})))
.then(d => {
const ok = d.status === 'success';
// Show git's own list of blocking files, then offer the single
// action that clears it. Stashing is never done unasked.
showResult('result-checkout-branch', ok, d.message || '', d.detail || '');
if (!ok && d.can_retry_with_stash && el) {
const retry = document.createElement('div');
retry.className = 'mt-2 flex items-center gap-2';
const label = document.createElement('span');
label.className = 'text-xs text-gray-700';
label.textContent = 'Stash these changes and switch anyway?';
const btn = document.createElement('button');
btn.className = 'inline-flex items-center px-2 py-1 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50';
btn.textContent = 'Stash and switch';
btn.onclick = function() { window.checkoutBranch(true); };
retry.appendChild(label);
retry.appendChild(btn);
el.appendChild(retry);
}
// Both panels describe the checkout, so refresh them together.
loadGitInfo();
loadBranches();
})
.catch(err => showResult('result-checkout-branch', false, String(err)))
.finally(() => setBusy('btn-checkout-branch', false));
};
// ── power supply diagnostics panel ────────────────────────────────────────
// Reuses the same SSE stream (window.statsSource, set up in base.html)
// that already drives the header badge/banner and Overview card, instead
// of polling a separate endpoint.
const FLAG_LABELS = [
['under_voltage_now', 'Under-voltage (right now)'],
['throttled_now', 'Throttled (right now)'],
['freq_capped_now', 'ARM frequency capped (right now)'],
['soft_temp_limit_now', 'Soft temperature limit active (right now)'],
['under_voltage_occurred', 'Under-voltage occurred (since boot)'],
['throttled_occurred', 'Throttled occurred (since boot)'],
['freq_capped_occurred', 'ARM frequency capped occurred (since boot)'],
['soft_temp_limit_occurred', 'Soft temperature limit occurred (since boot)'],
];
function renderPowerInfo(power) {
const panel = document.getElementById('power-info-panel');
if (!panel) return;
if (!power) {
panel.innerHTML = '<span class="text-gray-500">Not available on this platform (no <code class="bg-gray-100 px-1 rounded">vcgencmd</code> found — this isn\'t a Raspberry Pi, or it\'s not on PATH).</span>';
return;
}
const activeNow = power.under_voltage_now || power.throttled_now ||
power.freq_capped_now || power.soft_temp_limit_now;
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
power.freq_capped_occurred || power.soft_temp_limit_occurred;
const summaryColor = activeNow ? 'red' : (occurredEarlier ? 'yellow' : 'green');
// _activePowerConditionLabels is defined in base.html's inline script;
// both are classic (non-module) scripts sharing the global scope.
const activeLabels = (typeof _activePowerConditionLabels === 'function')
? _activePowerConditionLabels(power) : [];
const summaryText = activeNow
? `Actively ${activeLabels.length ? activeLabels.join('/') : 'under-voltage/throttled'} right now`
: (occurredEarlier ? 'OK right now (but occurred earlier this boot)' : 'OK — no issues detected');
let html = `
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-${summaryColor}-100 text-${summaryColor}-800">
<i class="fas fa-bolt mr-1"></i>${escHtml(summaryText)}
</span>
</div>
<ul class="space-y-1">`;
for (const [key, label] of FLAG_LABELS) {
const set = !!power[key];
html += `<li class="flex items-center gap-2">
<i class="fas ${set ? 'fa-exclamation-circle text-red-500' : 'fa-check text-green-500'} w-4"></i>
<span class="text-gray-700">${escHtml(label)}</span>
</li>`;
}
html += `</ul>`;
if (activeNow || occurredEarlier) {
html += `<p class="mt-3 text-xs text-gray-500">Consider a higher-amperage 5V supply wired directly to the panel(s), or check for a loose power connector. See the README's Power Supply section for sizing guidance.</p>`;
}
panel.innerHTML = html;
}
if (window.statsSource) {
window.statsSource.addEventListener('message', function(event) {
try {
const data = JSON.parse(event.data);
if (data && 'power' in data) renderPowerInfo(data.power);
} catch (e) { /* ignore malformed frames */ }
});
}
// ── system diagnostics panel ──────────────────────────────────────────────
// Reads the existing /api/v3/system/status JSON endpoint (10s cached) for
// richer metrics (disk, uptime, memory MB) than the SSE stats stream carries.
function diagTile(icon, iconColor, label, value, sub) {
return `
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0"><i class="fas ${icon} ${iconColor} text-xl"></i></div>
<div class="ml-3 w-0 flex-1">
<dt class="text-sm font-medium text-gray-500 truncate">${escHtml(label)}</dt>
<dd class="text-lg font-medium text-gray-900">${escHtml(value)}</dd>
${sub ? `<dd class="text-xs text-gray-400 mt-0.5">${escHtml(sub)}</dd>` : ''}
</div>
</div>
</div>`;
}
window.loadSystemDiagnostics = function() {
const panel = document.getElementById('diag-panel');
if (!panel) return;
fetch('/api/v3/system/status')
.then(r => {
if (!r.ok) return r.json()
.catch(() => null)
.then(d => Promise.reject((d && d.message) || `HTTP ${r.status}`));
return r.json();
})
.then(res => {
const d = (res && res.data) || {};
const mUsedGb = d.memory_used_mb != null ? (d.memory_used_mb / 1024).toFixed(1) : null;
const mTotGb = d.memory_total_mb != null ? (d.memory_total_mb / 1024).toFixed(1) : null;
const temp = d.cpu_temp != null ? d.cpu_temp + '°C' : 'N/A';
// Available memory is the number that predicts trouble. When it
// runs out the board does not fail cleanly: fork() starts
// returning ENOMEM, so sshd cannot spawn a session and systemd
// cannot respawn the display, while the kernel keeps answering
// pings. Thresholds are drawn from that failure -- it was
// measured at 73MB free, and healthy running sits well above.
// Round ONCE, then colour and label off the same number.
// The API sends one decimal place, so classifying the raw
// value and displaying the rounded one disagreed at the
// boundaries: 149.6 rendered as "150 MB" in red, and 299.6 as
// "300 MB" in amber, both contradicting the threshold the
// colour claims to apply. Which side of the line a spare
// 0.4MB falls on does not matter; the tile agreeing with
// itself does.
const availMb = d.memory_available_mb == null
? null : Math.round(d.memory_available_mb);
const availColor = availMb == null ? 'text-gray-400'
: availMb < 150 ? 'text-red-600'
: availMb < 300 ? 'text-amber-500'
: 'text-green-600';
panel.innerHTML =
diagTile('fa-microchip', 'text-blue-600', 'CPU Usage',
(d.cpu_percent != null ? d.cpu_percent : '--') + '%', null) +
diagTile('fa-memory', 'text-green-600', 'Memory',
(d.memory_used_percent != null ? d.memory_used_percent : '--') + '%',
(mUsedGb && mTotGb) ? `${mUsedGb} / ${mTotGb} GB` : null) +
diagTile('fa-memory', availColor, 'Available Memory',
availMb != null ? `${availMb} MB` : '--',
mTotGb ? `of ${mTotGb} GB total` : null) +
diagTile('fa-thermometer-half', 'text-red-600', 'CPU Temp', temp, null) +
diagTile('fa-hdd', 'text-indigo-600', 'Disk',
(d.disk_used_percent != null ? d.disk_used_percent : '--') + '%',
(d.disk_used_gb != null && d.disk_total_gb != null) ? `${d.disk_used_gb} / ${d.disk_total_gb} GB` : null) +
diagTile('fa-clock', 'text-purple-600', 'Uptime', d.uptime || '--', null) +
diagTile('fa-desktop', d.service_active ? 'text-green-600' : 'text-gray-400',
'Display Service', d.service_active ? 'Active' : 'Inactive', null);
})
.catch(err => {
panel.innerHTML = `<div class="col-span-full text-sm text-red-600">Diagnostics unavailable: ${escHtml(String(err))}</div>`;
});
};
// ── system power (reboot / shutdown) ──────────────────────────────────────
// Like toolsAction, but a dropped connection is the expected, successful
// outcome (the Pi is going down), so it is reported as info, not an error.
window.powerAction = function(action, btnId, resultId, offlineMsg) {
setBusy(btnId, true);
const el = document.getElementById(resultId);
if (el) el.classList.add('hidden');
fetch('/api/v3/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action})
})
.then(r => r.json().catch(() => ({status: 'success'})))
.then(data => {
if (data.status === 'error') {
showResult(resultId, false, data.message || 'Command failed');
} else {
showResult(resultId, true, offlineMsg);
}
})
.catch(() => {
// Connection dropped mid-request — expected when the Pi reboots/powers off.
showResult(resultId, true, offlineMsg);
})
.finally(() => setBusy(btnId, false));
};
window.showPowerConfirm = function(kind) {
document.getElementById(kind + '-confirm-row').classList.remove('hidden');
document.getElementById('btn-' + kind).classList.add('hidden');
};
window.hidePowerConfirm = function(kind) {
document.getElementById(kind + '-confirm-row').classList.add('hidden');
document.getElementById('btn-' + kind).classList.remove('hidden');
};
// ── WiFi radio toggle ─────────────────────────────────────────────────────
function renderWifiRadio(state) {
const toggle = document.getElementById('wifi-radio-toggle');
const knob = document.getElementById('wifi-radio-knob');
const note = document.getElementById('wifi-radio-note');
if (!toggle || !knob || !note) return;
const setKnob = (on) => {
if (on) {
knob.classList.remove('translate-x-0'); knob.classList.add('translate-x-5');
toggle.classList.remove('bg-gray-200'); toggle.classList.add('bg-blue-600');
} else {
knob.classList.remove('translate-x-5'); knob.classList.add('translate-x-0');
toggle.classList.remove('bg-blue-600'); toggle.classList.add('bg-gray-200');
}
};
if (!state || state.available === false) {
toggle.disabled = true;
toggle.classList.add('opacity-60');
toggle.setAttribute('aria-checked', 'false');
setKnob(false);
note.textContent = 'WiFi radio control is not available on this system (nmcli not found).';
note.className = 'text-xs text-gray-500 mt-0.5';
return;
}
const on = state.enabled === true;
toggle.disabled = false;
toggle.classList.remove('opacity-60');
toggle.setAttribute('aria-checked', on ? 'true' : 'false');
setKnob(on);
const eth = state.ethernet_connected
? 'Wired connection detected — safe to turn WiFi off.'
: 'No wired connection — turning WiFi off will disconnect this page.';
note.textContent = `Radio is ${on ? 'on' : 'off'}. ${eth}`;
note.className = 'text-xs mt-0.5 ' + (state.ethernet_connected ? 'text-gray-500' : 'text-amber-600');
}
window.loadWifiRadio = function() {
fetch('/api/v3/wifi/radio')
.then(r => r.json())
.then(res => renderWifiRadio(res && res.data))
.catch(() => renderWifiRadio(null));
};
window.onWifiRadioToggleClick = function() {
const toggle = document.getElementById('wifi-radio-toggle');
if (!toggle || toggle.disabled) return;
const currentlyOn = toggle.getAttribute('aria-checked') === 'true';
setWifiRadio(!currentlyOn, false);
};
function setWifiRadio(enabled, force) {
const toggle = document.getElementById('wifi-radio-toggle');
const resultEl = document.getElementById('result-wifi-radio');
if (toggle) toggle.disabled = true;
if (resultEl) resultEl.classList.add('hidden');
hideWifiForceRow();
fetch('/api/v3/wifi/radio', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled, force})
})
.then(r => r.json().then(d => ({ok: r.ok, d})))
.then(({ok, d}) => {
if (ok && d.status === 'success') {
if (d.data) renderWifiRadio(d.data); else window.loadWifiRadio();
showResult('result-wifi-radio', true, d.message || 'Done');
} else if (!enabled && !force && d.reason === 'no_ethernet') {
// Disable refused for safety (no wired fallback) — offer the force path.
showWifiForceRow();
window.loadWifiRadio();
} else {
showResult('result-wifi-radio', false, d.message || 'Failed to change WiFi radio.');
window.loadWifiRadio();
}
})
.catch(err => {
showResult('result-wifi-radio', false, 'Request failed: ' + err.message);
window.loadWifiRadio();
})
.finally(() => { if (toggle) toggle.disabled = false; });
}
window.forceDisableWifiRadio = function() {
hideWifiForceRow();
setWifiRadio(false, true);
};
function showWifiForceRow() {
const row = document.getElementById('wifi-radio-force-row');
if (row) { row.classList.remove('hidden'); row.classList.add('flex'); }
}
window.hideWifiForceRow = function() {
const row = document.getElementById('wifi-radio-force-row');
if (row) { row.classList.add('hidden'); row.classList.remove('flex'); }
};
// ── plugin health panel ──────────────────────────────────────────────────
function phEscape(s) { return window.LEDEscape.html(s); }
function phFmtSecs(v) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(3) + 's';
}
function phStatus(h) {
if (!h) return { label: 'Unknown', cls: 'warning' };
if (h.circuit_state === 'open') return { label: 'Circuit open', cls: 'error' };
if (h.circuit_state === 'half_open') return { label: 'Recovering', cls: 'warning' };
if (h.degraded) return { label: 'Degraded', cls: 'warning' };
if (h.is_healthy) return { label: 'Healthy', cls: 'success' };
return { label: 'Unknown', cls: 'warning' };
}
async function refreshPluginHealth(force) {
const tbody = document.getElementById('plugin-health-tbody');
const msg = document.getElementById('plugin-health-message');
if (!tbody || !window.PluginAPI) return;
try {
if (force && PluginAPI.clearCache) PluginAPI.clearCache();
const results = await Promise.all([
PluginAPI.getPluginHealth(),
PluginAPI.getPluginMetrics()
]);
const health = results[0] || {};
const metrics = results[1] || {};
const ids = Array.from(new Set(Object.keys(health).concat(Object.keys(metrics)))).sort();
if (!ids.length) {
if (msg) {
msg.textContent = 'No plugin health data yet — it appears once the display service has run plugins.';
msg.classList.remove('hidden');
}
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No data</td></tr>';
return;
}
if (msg) msg.classList.add('hidden');
let rows = '';
ids.forEach(function (id) {
const h = health[id] || {};
const m = metrics[id] || {};
const st = phStatus(h);
const lastErr = h.degraded_reason || h.last_error || '';
const calls = (typeof m.call_count === 'number') ? m.call_count : '—';
const errCell = lastErr
? '<span title="' + phEscape(lastErr) + '">' + phEscape(lastErr) + '</span>'
: '<span class="text-gray-400">—</span>';
rows += '<tr>' +
'<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">' + phEscape(id) + '</td>' +
'<td class="px-4 py-3 whitespace-nowrap"><span class="status-indicator ' + st.cls + '">' + st.label + '</span></td>' +
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.avg_execution_time) + '</td>' +
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + phFmtSecs(m.max_execution_time) + '</td>' +
'<td class="px-4 py-3 whitespace-nowrap text-right text-sm text-gray-600">' + calls + '</td>' +
'<td class="px-4 py-3 text-sm text-red-600 max-w-xs truncate">' + errCell + '</td>' +
'</tr>';
});
tbody.innerHTML = rows;
} catch (e) {
const emsg = (e && e.message) ? e.message : String(e);
tbody.innerHTML = '<tr><td colspan="6" class="px-4 py-6 text-center text-red-500">Failed to load plugin health: ' + phEscape(emsg) + '</td></tr>';
}
}
window.refreshPluginHealth = refreshPluginHealth;
// Load on first render; HTMX will have already swapped us in by this point.
loadGitInfo();
loadBranches();
// Plugin health (15s) and system diagnostics (10s): the partial stays in
// the DOM (hidden via x-show) when another tab is active, so both polls
// run only while the Tools tab is active and the page is visible — each
// restarts with an immediate refresh. LEDVisibility keys the registration,
// so a partial re-swapped in by HTMX replaces (not stacks) the timers.
function stopToolsPolling() {
if (window._pluginHealthTimer) { clearInterval(window._pluginHealthTimer); window._pluginHealthTimer = null; }
if (window._diagPollInterval) { clearInterval(window._diagPollInterval); window._diagPollInterval = null; }
}
function startToolsPolling() {
stopToolsPolling();
refreshPluginHealth(false);
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
window.loadSystemDiagnostics();
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
window.loadSystemDiagnostics();
}, 10000);
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('tools', startToolsPolling, stopToolsPolling);
} else {
startToolsPolling();
}
// WiFi radio current state.
window.loadWifiRadio();
// ── MQTT bridge ───────────────────────────────────────────────────────────
const MQTT_BRIDGE_URL = '/api/v3/integrations/mqtt-bridge';
function mqttBadge(service, configExists) {
if (!service.installed) return ['bg-gray-100 text-gray-600', 'not installed'];
if (service.active) return ['bg-green-100 text-green-700', 'running'];
if (!configExists) return ['bg-amber-100 text-amber-700', 'not configured'];
return ['bg-amber-100 text-amber-700', 'stopped'];
}
function renderMqttBridge(data) {
const badge = document.getElementById('mqtt-bridge-state');
const [cls, label] = mqttBadge(data.service, data.config_exists);
if (badge) {
badge.className = 'text-xs px-2 py-1 rounded-full ' + cls;
badge.textContent = label;
}
const c = data.config || {};
const field = (id, label, value, type, extra) => `
<label class="block">
<span class="text-xs font-medium text-gray-700">${escHtml(label)}</span>
<input id="${id}" type="${type || 'text'}" value="${value === null || value === undefined ? '' : escHtml(String(value))}"
${extra || ''}
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
</label>`;
document.getElementById('mqtt-bridge-body').innerHTML = `
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
${field('mqtt-host', 'Broker host', c.mqtt_host)}
${field('mqtt-port', 'Port', c.mqtt_port, 'number', 'min="1" max="65535"')}
${field('mqtt-username', 'Username (optional)', c.mqtt_username)}
<label class="block">
<span class="text-xs font-medium text-gray-700">Password</span>
<input id="mqtt-password" type="password" value=""
placeholder="${data.password_set ? 'unchanged — leave blank to keep' : 'none set'}"
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
<span class="text-xs text-gray-500">
Never sent back to the browser.
${data.password_set ? '<button type="button" id="mqtt-clear-password" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
</span>
</label>
${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)}
${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)}
<label class="block">
<span class="text-xs font-medium text-gray-700">LEDMatrix API token</span>
<input id="mqtt-api-token" type="password" value="" autocomplete="off"
placeholder="${data.api_token_set ? 'unchanged — leave blank to keep' : 'none set'}"
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
<span class="text-xs text-gray-500">
Only when web login is on and the bridge runs on another machine (General &gt; Security).
${data.api_token_set ? '<button type="button" id="mqtt-clear-api-token" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
</span>
</label>
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')}
${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
<label class="block">
<span class="text-xs font-medium text-gray-700">Log level</span>
<select id="mqtt-log-level" class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md bg-white">
${['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l =>
`<option value="${l}" ${c.log_level === l ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</label>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="mqtt-tls" type="checkbox" ${c.mqtt_tls ? 'checked' : ''}
class="rounded border-gray-300">
Use TLS
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
</label>
<label id="mqtt-allow-insecure-row" class="${c.mqtt_tls ? 'hidden' : 'flex'} items-center gap-2 text-sm text-gray-700">
<input id="mqtt-allow-insecure" type="checkbox" ${c.allow_insecure_mqtt ? 'checked' : ''}
class="rounded border-gray-300">
Allow without TLS (trusted network)
<span class="text-xs text-gray-500">(needed to save a password while TLS is off)</span>
</label>
<div class="flex items-center justify-between gap-4 pt-2">
<p class="text-xs text-gray-500">
Saved to <code class="bg-gray-100 px-1 rounded">${escHtml(data.config_path)}</code>.
Any field can also be set as
<code class="bg-gray-100 px-1 rounded">${escHtml(data.env_override_prefix)}&lt;KEY&gt;</code>,
which wins over the file.
</p>
<button id="btn-mqtt-save" onclick="saveMqttBridge()"
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
<i class="fas fa-save mr-2"></i>Save settings
</button>
</div>
<div id="result-mqtt-save" class="hidden"></div>
<div class="flex flex-wrap items-center gap-2 pt-4 border-t border-gray-100">
${data.service.installed ? `
<button id="btn-mqtt-restart" onclick="toolsAction('mqtt_bridge_restart','btn-mqtt-restart','result-mqtt-service')"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-sync-alt mr-2"></i>Restart
</button>
<button id="btn-mqtt-startstop"
onclick="toolsAction('${data.service.active ? 'mqtt_bridge_stop' : 'mqtt_bridge_start'}','btn-mqtt-startstop','result-mqtt-service')"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-${data.service.active ? 'stop' : 'play'} mr-2"></i>${data.service.active ? 'Stop' : 'Start'}
</button>` : `
<button id="btn-mqtt-install" onclick="installMqttBridge()"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-download mr-2"></i>Install &amp; start bridge
</button>
<span class="text-xs text-gray-500">Installs dependencies, enables the unit and starts it.</span>`}
</div>
<div id="result-mqtt-service" class="hidden"></div>
`;
const clearBtn = document.getElementById('mqtt-clear-password');
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
const clearTokenBtn = document.getElementById('mqtt-clear-api-token');
if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken());
// The cleartext opt-in only means something while TLS is off.
const tlsBox = document.getElementById('mqtt-tls');
const allowRow = document.getElementById('mqtt-allow-insecure-row');
if (tlsBox && allowRow) {
tlsBox.addEventListener('change', () => {
allowRow.classList.toggle('hidden', tlsBox.checked);
allowRow.classList.toggle('flex', !tlsBox.checked);
});
}
}
window.loadMqttBridge = function() {
fetch(MQTT_BRIDGE_URL)
.then(r => r.json())
.then(d => {
if (d.status !== 'success') throw new Error(d.message || 'Failed');
renderMqttBridge(d.data);
})
.catch(err => {
document.getElementById('mqtt-bridge-body').innerHTML =
`<p class="text-sm text-red-600">Could not load bridge settings: ${escHtml(err.message)}</p>`;
});
};
function mqttBody() {
const val = id => (document.getElementById(id) || {}).value;
const body = {
mqtt_host: val('mqtt-host'),
mqtt_port: val('mqtt-port'),
mqtt_username: val('mqtt-username'),
mqtt_topic: val('mqtt-topic'),
mqtt_client_id: val('mqtt-client-id'),
ledmatrix_api_base: val('mqtt-api-base'),
request_timeout: val('mqtt-timeout'),
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
log_level: val('mqtt-log-level'),
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
// The server refuses a password with TLS off unless this is set
// (CWE-319); it is off until the user ticks it.
allow_insecure_mqtt: !!(document.getElementById('mqtt-allow-insecure') || {}).checked,
};
// Only send a password when one was typed; blank means "leave it alone".
const pw = val('mqtt-password');
if (pw) body.mqtt_password = pw;
const apiToken = val('mqtt-api-token');
if (apiToken) body.ledmatrix_api_token = apiToken;
return body;
}
window.saveMqttBridge = function() {
setBusy('btn-mqtt-save', true);
fetch(MQTT_BRIDGE_URL + '/config', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(mqttBody())
})
.then(r => r.json().then(d => ({ok: r.ok, d})))
.then(({ok, d}) => {
showResult('result-mqtt-save', ok && d.status === 'success',
d.message || (ok ? 'Saved' : 'Failed'));
if (ok && d.status === 'success') loadMqttBridge();
})
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message))
.finally(() => setBusy('btn-mqtt-save', false));
};
function clearMqttPassword() {
if (!confirm('Remove the stored MQTT password?')) return;
fetch(MQTT_BRIDGE_URL + '/config', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({clear_password: true})
})
.then(r => r.json())
.then(d => {
showResult('result-mqtt-save', d.status === 'success', d.message || 'Password cleared');
loadMqttBridge();
})
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
}
function clearMqttApiToken() {
if (!confirm('Remove the stored LEDMatrix API token from the bridge settings?')) return;
fetch(MQTT_BRIDGE_URL + '/config', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({clear_api_token: true})
})
.then(r => r.json())
.then(d => {
showResult('result-mqtt-save', d.status === 'success', d.message || 'Token cleared');
loadMqttBridge();
})
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
}
window.installMqttBridge = function() {
// Installing pulls dependencies, so it is slower than the other actions
// and worth saying so rather than leaving a spinner unexplained.
showResult('result-mqtt-service', true, 'Installing — this pulls Python dependencies and can take a minute…');
setBusy('btn-mqtt-install', true);
fetch('/api/v3/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'mqtt_bridge_install'})
})
.then(r => r.json())
.then(d => {
showResult('result-mqtt-service', d.status === 'success',
d.message || (d.status === 'success' ? 'Installed' : 'Failed'), d.output || '');
loadMqttBridge();
})
.catch(err => showResult('result-mqtt-service', false, 'Request failed: ' + err.message))
.finally(() => setBusy('btn-mqtt-install', false));
};
// ── Pixlet config editor ──────────────────────────────────────────────────
const PIXLET_EDITOR_URL = '/api/v3/starlark/editor';
let pixletPollTimer = null;
function fmtRemaining(seconds) {
if (seconds === null || seconds === undefined) return '';
const m = Math.floor(seconds / 60), s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function editorHost(port) {
// The session binds 0.0.0.0, so whatever host reached this page reaches
// the editor too -- no tunnel, and no guessing the device's address.
return `${window.location.protocol}//${window.location.hostname}:${port}/`;
}
function renderPixletEditor(status, apps, pixletAvailable, appsDir) {
const body = document.getElementById('pixlet-editor-body');
if (!body) return;
if (!pixletAvailable) {
body.innerHTML = `
<p class="text-sm text-gray-700">
Pixlet is not installed, so there is nothing to edit with. Install it from the
Starlark Apps section of the Plugins tab, or run
<code class="bg-gray-100 px-1 rounded">./scripts/download_pixlet.sh</code>.
</p>`;
return;
}
const active = status.running ? `
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-amber-900">
<i class="fas fa-circle-notch fa-spin mr-1"></i>
Editing <code class="bg-amber-100 px-1 rounded">${escHtml(status.app_id)}</code> — the display is stopped
</p>
<p class="text-xs text-amber-800 mt-1">
<a href="${editorHost(status.port)}" target="_blank" rel="noopener"
class="underline font-medium">Open the editor &rarr;</a>
<span class="ml-2">stops on its own in
<span id="pixlet-countdown">${fmtRemaining(status.seconds_remaining)}</span></span>
</p>
</div>
<button id="btn-pixlet-stop" onclick="stopPixletEditor()"
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i>Stop now
</button>
</div>
</div>` : '';
const rows = apps.length ? apps.map(a => `
<div class="flex items-center justify-between gap-4 py-2 border-t border-gray-100 first:border-t-0">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">${escHtml(a.name)}</p>
<p class="text-xs text-gray-500 truncate">${escHtml(a.id)}${a.editable ? '' : ' — no .star file'}</p>
</div>
<button id="btn-pixlet-edit-${escHtml(a.id)}"
${(!a.editable || status.running) ? 'disabled' : ''}
data-pixlet-edit data-app-id="${escHtml(a.id)}"
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-sliders-h mr-2"></i>Edit
</button>
</div>`).join('') : `
<p class="text-sm text-gray-500">
No Starlark apps installed. Install one from the Plugins tab first
(looked in <code class="bg-gray-100 px-1 rounded">${escHtml(appsDir)}</code>).
</p>`;
body.innerHTML = active + `<div class="mt-2">${rows}</div>
<div id="result-pixlet" class="hidden"></div>`;
// Bound here rather than via an inline onclick: escHtml does not encode
// single quotes, and the id used to be interpolated into a single-quoted
// JS string, so an app directory containing an apostrophe could break
// out of it and run script. Through dataset the value is only ever
// parsed as an HTML attribute, never as JavaScript.
body.querySelectorAll('[data-pixlet-edit]').forEach(btn => {
btn.addEventListener('click', () => startPixletEditor(btn.dataset.appId));
});
}
window.loadPixletEditor = function() {
Promise.all([
fetch(PIXLET_EDITOR_URL + '/status').then(r => r.json()),
fetch(PIXLET_EDITOR_URL + '/apps').then(r => r.json())
])
.then(([s, a]) => {
const status = (s.data || {});
const apps = (a.data || {}).apps || [];
renderPixletEditor(status, apps, (a.data || {}).pixlet_available, (a.data || {}).apps_dir || '');
schedulePixletPoll(status.running);
})
.catch(err => {
const body = document.getElementById('pixlet-editor-body');
if (body) body.innerHTML = `<p class="text-sm text-red-600">Could not load: ${escHtml(err.message)}</p>`;
});
};
function schedulePixletPoll(running) {
// Poll only while a session is open: that is the state worth watching,
// and it is what makes the banner survive a page reload.
if (pixletPollTimer) { clearInterval(pixletPollTimer); pixletPollTimer = null; }
if (!running) return;
pixletPollTimer = setInterval(() => {
fetch(PIXLET_EDITOR_URL + '/status')
.then(r => r.json())
.then(d => {
const st = d.data || {};
if (!st.running) { loadPixletEditor(); return; }
const el = document.getElementById('pixlet-countdown');
if (el) el.textContent = fmtRemaining(st.seconds_remaining);
})
.catch(() => {});
}, 5000);
}
window.startPixletEditor = function(appId) {
const btnId = 'btn-pixlet-edit-' + appId;
setBusy(btnId, true);
fetch(PIXLET_EDITOR_URL + '/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({app_id: appId})
})
.then(r => r.json().then(d => ({ok: r.ok, d})))
.then(({ok, d}) => {
if (!ok || d.status !== 'success') {
showResult('result-pixlet', false, d.message || 'Could not start the editor');
return;
}
loadPixletEditor();
})
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
.finally(() => setBusy(btnId, false));
};
window.stopPixletEditor = function() {
setBusy('btn-pixlet-stop', true);
fetch(PIXLET_EDITOR_URL + '/stop', {method: 'POST'})
.then(r => r.json())
.then(d => { showResult('result-pixlet', d.status === 'success', d.message || 'Stopped'); loadPixletEditor(); })
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
.finally(() => setBusy('btn-pixlet-stop', false));
};
// Initial load for both sections. Down here rather than at the shared init
// site above, because these are assignments to window rather than hoisted
// declarations -- calling them earlier in the file throws.
loadMqttBridge();
loadPixletEditor();
})();
</script>