mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
* Add system diagnostics, power controls, and WiFi radio toggle to Tools tab Expands the web UI Tools tab with safe, purpose-built controls so users can manage the Pi without SSHing in, instead of an arbitrary-command terminal (the web UI has no auth and CSRF is disabled, so a shell would be unsafe). - System Diagnostics card: renders the existing but previously-unused GET /api/v3/system/status endpoint (CPU, memory, temp, disk, uptime), with a manual refresh and a 10s poll. - System Power section: reboot/shutdown buttons wired to the existing reboot_system / shutdown_system actions, behind a confirm step, with a dedicated powerAction() helper that treats the dropped connection as the expected "going offline" outcome rather than an error. - Network Radio section: WiFi on/off toggle backed by new GET/POST /api/v3/wifi/radio endpoints and WiFiManager.set_wifi_radio() / get_wifi_radio_state(). Disabling WiFi is refused unless a wired connection is present (reusing the existing lockout guards), with an explicit force-off confirmation for advanced users. No new privileged commands: uses nmcli radio wifi on|off (already sudo-allowlisted) and the existing reboot/poweroff grants, so the sudoers-alignment guard test stays green. Bluetooth toggle intentionally omitted since the installer removes the BlueZ stack for LED timing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE * Harden WiFi radio endpoint and diagnostics poll (review feedback) Addresses code review feedback on the Tools tab additions: - api_v3.py: parse the `enabled` POST field with the same string-aware coercion as `force`. A plain bool() cast turned {"enabled":"false"} into True (enabling instead of disabling) for any non-UI API caller. - wifi_manager.set_wifi_radio() now returns a reason code alongside (success, message); the /wifi/radio error response includes it. The Tools UI only shows the force-off confirmation when reason == 'no_ethernet', so a genuine nmcli failure surfaces its real error instead of a misleading "no wired connection" prompt that would just retry into the same failure. - tools.html: gate the 10s diagnostics poll on panel visibility (document.hidden / offsetParent), so switching to another tab stops the recurring /api/v3/system/status calls instead of churning the Pi off-screen. The initial load and manual Refresh remain unconditional. Verified: {"enabled":"false"} now disables (refused w/ reason:no_ethernet), {"enabled":"true"} enables; inline JS passes node --check; py_compile clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE * Narrow WiFi-disable fallback except and add stack trace (review) Addresses a CodeRabbit nitpick: the fallback handler in set_wifi_radio()'s disable path caught bare Exception and logged without a traceback. Narrow it to (OSError, subprocess.SubprocessError) — the errors subprocess.run realistically raises — and log with exc_info=True for full context on the Pi. Anything genuinely unexpected now propagates to the endpoint's outer handler (500), matching the codebase's specific-exception convention. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE --------- Co-authored-by: Claude <noreply@anthropic.com>
839 lines
45 KiB
HTML
839 lines
45 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">
|
|
<!-- 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.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>`;
|
|
});
|
|
}
|
|
|
|
// ── 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';
|
|
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-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();
|
|
|
|
// 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>
|