mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Optional web login, off by default: a device that sets no password behaves exactly as before. Set under General > Security; then every page and API route needs a session login or an API token (Authorization: Bearer). Loopback, the Wi-Fi setup flow in AP mode, static files, captive-portal probes and a reduced /api/v3/health stay open. Secrets live in the web_auth section of config_secrets.json and no API returns them. scripts/reset_web_password.py turns login off. Stacked on #674. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1389 lines
75 KiB
HTML
1389 lines
75 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 & 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…</span>
|
|
</div>
|
|
<p class="mt-1 text-sm text-gray-600">
|
|
Exposes the display to Home Assistant over MQTT — 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…</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 —
|
|
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 — the
|
|
same footing as this page. Sessions end by themselves after 30 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…</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.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.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 > 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>
|
|
|
|
<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)}<KEY></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 & 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());
|
|
}
|
|
|
|
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,
|
|
};
|
|
// 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 →</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>
|