Files
bd9f461f70 Add system diagnostics, power controls, and WiFi radio toggle to Tools tab (#389)
* 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>
2026-07-09 09:22:11 -04:00

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 &amp; Updates</h2>
<p class="mt-1 text-sm text-gray-600">Inspect the current git state and pull or reset to the latest remote code.</p>
</div>
<!-- Git status info -->
<div id="git-info-panel" class="mb-6 bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
<div class="animate-pulse text-gray-400">Loading git info…</div>
</div>
<div class="space-y-4">
<!-- 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>