mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-25 20:38:15 +00:00
* feat(web): show available memory in Tools diagnostics System Diagnostics reported memory as used-percent plus used/total GB. Neither distinguishes a healthy board from one about to fail, because page cache counts as used and is reclaimable on demand -- a Pi can read 70% used and be fine, or read the same and be minutes from trouble. MemAvailable is the kernel's own estimate of what a new allocation can actually obtain, and it is the number that tracked the failure on a 1GB Pi 3B+: healthy running sat above 500MB, and the crash came at 73MB. By that point fork() was failing, so sshd could not spawn a session and systemd could not respawn the display, while the kernel carried on answering pings at 0% loss. Used-percent gave no warning at any point on the way there; available memory fell steadily for hours. /api/v3/system/status now returns memory_available_mb from psutil.virtual_memory().available, and Tools renders it as its own tile, coloured against the thresholds that failure implies: red under 150MB, amber under 300MB, green above. The existing memory tile is left alone -- used/total is still what you want when sizing a workload; this answers the different question of how much room is left right now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): round available memory once, so the tile agrees with itself The colour was classified from the raw value while the label was rounded, and the API sends one decimal place. At the boundaries the two disagreed: 149.6 rendered as "150 MB" in red, and 299.6 as "300 MB" in amber -- each contradicting the threshold its own colour claims to apply ("red under 150MB"). A reader checking the tile against the documented thresholds would conclude the readout was broken. Rounding once and using that number for both restores agreement. It moves those two boundary cases up a band, which does not matter: the thresholds come from a measured failure at 73MB, so which side of the line a spare 0.4MB falls on carries no information. The tile agreeing with itself does. Null handling and the thresholds themselves are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
967 lines
52 KiB
HTML
967 lines
52 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>
|
|
|
|
<!-- 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();
|
|
})();
|
|
</script>
|