mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
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>
This commit is contained in:
@@ -7252,6 +7252,74 @@ def set_auto_enable_ap_mode():
|
||||
'message': 'An error occurred; see logs for details'
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||
def get_wifi_radio():
|
||||
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
state = wifi_manager.get_wifi_radio_state()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': state
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||
def set_wifi_radio():
|
||||
"""Turn the WiFi radio on or off.
|
||||
|
||||
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
|
||||
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||
of this web interface.
|
||||
"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'enabled' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'enabled is required'
|
||||
}), 400
|
||||
|
||||
# Parse defensively: bool("false") is True, so mirror the string-aware
|
||||
# coercion used for `force` — the endpoint is a public contract, not just
|
||||
# the shipped UI (which always sends real JSON booleans).
|
||||
_enabled_raw = data['enabled']
|
||||
enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes'))
|
||||
_force_raw = data.get('force', False)
|
||||
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes'))
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message,
|
||||
'data': wifi_manager.get_wifi_radio_state()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message,
|
||||
'reason': reason
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/cache/list', methods=['GET'])
|
||||
def list_cache_files():
|
||||
"""List all cache files with metadata"""
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
<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">
|
||||
@@ -123,6 +141,46 @@
|
||||
</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">
|
||||
@@ -157,6 +215,68 @@
|
||||
</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">
|
||||
@@ -429,6 +549,198 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
@@ -498,11 +810,29 @@
|
||||
|
||||
// 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);
|
||||
// Refresh periodically. Guard against duplicate timers if this partial is
|
||||
// re-swapped in by HTMX; the handler re-resolves DOM nodes by id each tick.
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user