mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* feat(tools): manage the MQTT bridge and Pixlet editor from the Tools tab PR #544's change, ported onto the api_v3 package split (#553). Identical behaviour; only the placement of the new code differs. The original added 508 lines to web_interface/blueprints/api_v3.py, which #553 deletes, so every hunk of it would conflict irreconcilably. Ported by AST: 26 new top-level items sorted to where the split puts each kind -- __init__.py 2 imports, 11 constants, 7 helpers starlark.py 4 routes (/starlark/editor/{apps,status,start,stop}) misc.py 2 routes (/integrations/mqtt-bridge{,/config}) Everything outside api_v3.py -- the Tools partial, the installer scripts, the JS tests -- applied unchanged. Routes: 111 from the split plus these 6 = 117, and the url-map snapshot is regenerated to match, which is exactly what test_api_v3_url_map.py is designed to make you do when routes are added. Full Python suite: 4,278 passed, 68 skipped, 0 failed. The JS tests this PR ships could not be run here -- node is not installed on this machine -- so test/js/dom/test_tools_sections.js is unverified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(starlark): don't crash the pixlet editor's start/stop routes, and honor an operator-set PIXLET_EDITOR_HOST The AST-based port of #544 onto the api_v3 package split dropped `time` from starlark.py's import list. start_pixlet_editor() and stop_pixlet_editor() both call time.time()/time.sleep() directly, so every start (NameError building `state['started_at']`) and every stop that has to wait out the EXIT trap crashed with a 500. No test caught it because the route's own tests mock subprocess.Popen but never actually invoked it before now. Also carries over #544's later fix that this port branched before: env['PIXLET_EDITOR_HOST'] = '0.0.0.0' unconditionally overrode an operator who had already pinned PIXLET_EDITOR_HOST to loopback, forcing the unauthenticated `pixlet serve` process onto the LAN regardless (CodeQL CWE-1188). Switched to env.setdefault(...), same as api_v3.starlark.py's siblings already do for _pkg-owned names. Both fixes route the shared _pkg.time reference the rest of the package's route modules already use for anything a test might need to patch, rather than a bare `import time` local to this file. Ported the existing regression test from #544 (TestPixletEditorHostDefaultsButDoesNotOverride) onto this branch's module layout (web_interface.blueprints.api_v3.starlark instead of the old monolithic api_v3 module), which is what caught the NameError. Full suite: 4330 passed, 62 skipped, 2 failed -- identical on this branch and on origin/main (missing tzdata package breaks two timezone-alias tests in test_onboarding_checklist.py, unrelated to this change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): clear the six lint errors this rebase introduced All six were introduced by rebasing this branch onto the merged blueprint split, not by the split itself. Confirmed by diffing pyflakes output against main with line numbers normalised -- everything else it reports is present on main too and is the package's deliberate re-export pattern. starlark.py used _STARLARK_APPS_DIR three times without importing it (F821). The rebase resolved an import-list conflict as a union of both sides, and that symbol was on neither side of the conflict hunk, so it was silently lost. It is defined in __init__.py and is now imported like its neighbours. This was the only one of the six that would fail at runtime rather than merely lint. __init__.py imported contextlib twice (F811): the cherry-pick added one next to the existing import. Removed the duplicate; the original at line 19 is used. __init__.py imported signal purely to re-export it to starlark.py, so pyflakes saw it as unused (F401). signal is stdlib and does not need routing through the blueprint package, so starlark.py imports it directly and __init__.py no longer does. contextlib stays re-exported because this module genuinely uses it. _read_mqtt_bridge_config()'s local `config` shadowed the `config` submodule this module imports at the bottom for its route side effects (F811). Renamed to `settings`, with a comment saying why, since the name is otherwise the obvious one to reach for. Verified: pyflakes now reports nothing on this branch that main does not, the package imports, all nine route modules load, and 117 routes register, matching the pinned URL-map snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): reject MQTT bridge bodies the endpoint cannot apply Two CodeRabbit findings on the bridge settings endpoint, both of which returned 200 while doing something other than what the caller asked. `request.get_json(silent=True) or {}` turned a missing or unparseable body -- and the JSON literals null, [] and false -- into an empty dict, which then satisfied the isinstance(data, dict) guard on the very next line. The guard was there to reject exactly those bodies. Dropping the `or {}` lets None fail it. The same `or {}` on /errors/clear is left alone: its docstring documents the body as optional, so an absent body legitimately means "use the defaults". The difference is that saving settings has nothing sensible to do with no body. `if data.get('clear_password'):` accepted any truthy value, and the string "false" is truthy in Python -- so a client echoing the field back as a string wiped a password it meant to keep. Now coerced through the package's existing _coerce_to_bool, which already maps 'true'/'on'/'1'/'yes' and nothing else. test_mqtt_bridge_config_endpoint.py covers both: five unusable body shapes plus a missing body, and clear_password across truthy and falsy spellings. Verified against the unfixed code -- reverting the body guard fails 5, reverting the coercion fails 3. Not changed here: CodeRabbit also asks this endpoint to reject MQTT credentials when TLS is off (CWE-319). That is a policy decision about the feature rather than a defect -- unencrypted MQTT on a trusted LAN is common and often deliberate -- so it is raised on the PR for a maintainer call instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: work through the remaining review findings on the editor and bridge allow_insecure_mqtt (CWE-319, requested): a password with TLS disabled crosses the network in cleartext. Refused now rather than merely warned about -- but refused, not forbidden, because unencrypted MQTT on a trusted LAN is a normal deliberate setup. allow_insecure_mqtt is the explicit acknowledgement, defaults false, and is coerced like the other booleans so the string "false" cannot switch the guard off. starlark.py:796 -- the supported service runs Flask threaded, so two start requests could each see running=False, each launch an editor, and the second state write replace the first PID, orphaning a process that holds the display down with nothing recording it. The check-launch-write sequence now takes a module-level lock. starlark.py:848 -- if the state write failed the route returned success with an editor running and no PID recorded: status and stop both reported no session while the display stayed down until the timeout expired. It now terminates the process group and returns an error. starlark.py:890 -- SIGKILL gives the script's EXIT trap no chance to run, so nothing hands the display back, yet the response said "the display is restarting". After an escalation the display is now restarted explicitly, and a failure to do so returns an error naming the manual step instead of a success. pixlet_config_editor.sh:184 -- find_pixlet supports Darwin but macOS ships no timeout(1); GNU coreutils installs it as gtimeout. Resolved up front so the failure lands before the display is stopped rather than after. pixlet_config_editor.sh:154 -- wildcard, loopback and an explicit interface address are three cases, not two. Collapsing the last two printed a URL saying "localhost" whenever PIXLET_EDITOR_HOST named a LAN address. tools.html:1254 -- escHtml does not encode single quotes, and the app id was interpolated into an inline onclick="startPixletEditor('...')", so a directory containing an apostrophe could break out of the JS string and run script. The handler binds with addEventListener and reads the id from dataset, where it is only ever parsed as an HTML attribute. Tests: test_mqtt_bridge_config_endpoint.py grows to 23 cases covering the opt-in in both directions. The tools DOM suite gains three guards asserting the edit buttons carry no inline onclick and pass the id via dataset -- those need jsdom and did not run here, so CI verifies them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): log the traceback on the editor state-write failure The 848 fix answers 500 when the session state cannot be written, and logged that at error level -- but without exc_info, so the traceback never reached the log. test_web_error_detail.py guards exactly this: a handler returning 5xx must write an error-level record *with* the traceback and return the sanitized detail, because checking that merely something was logged is too weak. Caught by Core unit tests on the previous commit, not locally: the guard parses every module under web_interface/blueprints/api_v3 as one source, so it only fires once the whole package is read together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1350 lines
72 KiB
HTML
1350 lines
72 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 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 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 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 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 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 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 items-start justify-between gap-4">
|
|
<div>
|
|
<p 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"
|
|
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 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 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 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 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 String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ── 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) {
|
|
return r.json()
|
|
.then(d => Promise.reject(new Error(d.message || `HTTP ${r.status}`)))
|
|
.catch(() => Promise.reject(new Error(`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
|
|
);
|
|
})
|
|
.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()
|
|
.then(d => Promise.reject(d.message || `HTTP ${r.status}`))
|
|
.catch(() => Promise.reject(`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 String(s).replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
}
|
|
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: initial load + periodic refresh. Guard against duplicate
|
|
// timers if this partial is re-swapped in by HTMX; the handler re-resolves
|
|
// DOM nodes by id each tick.
|
|
refreshPluginHealth(false);
|
|
if (!window._pluginHealthTimer) {
|
|
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
|
|
}
|
|
|
|
// System diagnostics: load now, then refresh every 10s. Clear any prior
|
|
// interval so re-swapping the partial doesn't stack. The recurring poll is
|
|
// gated on visibility — the partial stays in the DOM (hidden via x-show)
|
|
// when another tab is active, so without this it would keep hitting
|
|
// /api/v3/system/status every 10s and churn the Pi while off-screen.
|
|
if (window._diagPollInterval) clearInterval(window._diagPollInterval);
|
|
window.loadSystemDiagnostics();
|
|
window._diagPollInterval = setInterval(function () {
|
|
const panel = document.getElementById('diag-panel');
|
|
if (!panel || document.hidden || panel.offsetParent === null) return;
|
|
window.loadSystemDiagnostics();
|
|
}, 10000);
|
|
|
|
// 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)}
|
|
${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());
|
|
}
|
|
|
|
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;
|
|
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));
|
|
}
|
|
|
|
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 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>
|