From 6a227577559e7a1cb1f3cbb8c78150a218743199 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 11 Mar 2026 12:51:40 +0300 Subject: [PATCH] Add manual ping/health check button to device cards Adds a refresh icon button on each device card that triggers an immediate health check via POST /devices/{id}/ping, showing online status with latency or offline result as a toast notification. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/routes/devices.py | 20 ++++++++++++ .../core/processing/processor_manager.py | 7 +++++ .../src/wled_controller/static/css/layout.css | 5 +++ server/src/wled_controller/static/js/app.js | 3 +- .../static/js/features/devices.js | 31 ++++++++++++++++++- .../wled_controller/static/locales/en.json | 4 +++ .../wled_controller/static/locales/ru.json | 4 +++ .../wled_controller/static/locales/zh.json | 4 +++ 8 files changed, 76 insertions(+), 2 deletions(-) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index f943ceb..69703e5 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -406,6 +406,26 @@ async def get_device_state( raise HTTPException(status_code=404, detail=str(e)) +@router.post("/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"]) +async def ping_device( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Force an immediate health check on a device.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + + try: + state = await manager.force_device_health_check(device_id) + state["device_type"] = device.device_type + return DeviceStateResponse(**state) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # ===== WLED BRIGHTNESS ENDPOINTS ===== @router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 56ee49c..398c5b2 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -1080,6 +1080,13 @@ class ProcessorManager: except Exception as e: logger.error(f"Failed to sync LED count for {device_id}: {e}") + async def force_device_health_check(self, device_id: str) -> dict: + """Run an immediate health check for a device and return the result.""" + if device_id not in self._devices: + raise ValueError(f"Device {device_id} not found") + await self._check_device_health(device_id) + return self.get_device_health_dict(device_id) + # ===== HELPERS ===== def has_device(self, device_id: str) -> bool: diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index e53a7e0..a3223b1 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -147,6 +147,11 @@ h2 { to { transform: rotate(360deg); } } +/* Device card ping button spinning animation */ +.card-ping-btn.spinning svg { + animation: conn-spin 0.8s linear infinite; +} + /* WLED device health indicator */ .health-dot { display: inline-block; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 65f03ae..38e6b0d 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -31,7 +31,7 @@ import { import { showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal, saveDeviceSettings, updateBrightnessLabel, saveCardBrightness, - turnOffDevice, removeDevice, loadDevices, + turnOffDevice, pingDevice, removeDevice, loadDevices, updateSettingsBaudFpsHint, copyWsUrl, } from './features/devices.js'; import { @@ -211,6 +211,7 @@ Object.assign(window, { updateBrightnessLabel, saveCardBrightness, turnOffDevice, + pingDevice, removeDevice, loadDevices, updateSettingsBaudFpsHint, diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index f9bd6d8..103d37c 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -11,7 +11,7 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; -import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js'; +import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; @@ -133,6 +133,9 @@ export function createDeviceCard(device) { ` : ''} ${renderTagChips(device.tags)}`, actions: ` + `, @@ -157,6 +160,32 @@ export async function turnOffDevice(deviceId) { } } +export async function pingDevice(deviceId) { + const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`); + if (btn) btn.classList.add('spinning'); + try { + const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' }); + if (resp.ok) { + const data = await resp.json(); + const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?'; + showToast(data.device_online + ? t('device.ping.online', { ms }) + : t('device.ping.offline'), data.device_online ? 'success' : 'error'); + // Refresh device cards to update health dot + devicesCache.invalidate(); + await window.loadDevices(); + } else { + const err = await resp.json(); + showToast(err.detail || 'Ping failed', 'error'); + } + } catch (error) { + if (error.isAuth) return; + showToast(t('device.ping.error'), 'error'); + } finally { + if (btn) btn.classList.remove('spinning'); + } +} + export function attachDeviceListeners(deviceId) { // Add any specific event listeners here if needed } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 56b112d..d0088ba 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -186,6 +186,10 @@ "device.button.remove": "Remove", "device.button.webui": "Open Device Web UI", "device.button.power_off": "Turn Off", + "device.button.ping": "Ping Device", + "device.ping.online": "Online ({ms}ms)", + "device.ping.offline": "Device offline", + "device.ping.error": "Ping failed", "device.power.off_success": "Device turned off", "device.status.connected": "Connected", "device.status.disconnected": "Disconnected", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index b6ae97a..7326732 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -186,6 +186,10 @@ "device.button.remove": "Удалить", "device.button.webui": "Открыть веб-интерфейс устройства", "device.button.power_off": "Выключить", + "device.button.ping": "Пинг устройства", + "device.ping.online": "Онлайн ({ms}мс)", + "device.ping.offline": "Устройство недоступно", + "device.ping.error": "Ошибка пинга", "device.power.off_success": "Устройство выключено", "device.status.connected": "Подключено", "device.status.disconnected": "Отключено", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index ce86db3..1066fac 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -186,6 +186,10 @@ "device.button.remove": "移除", "device.button.webui": "打开设备 Web UI", "device.button.power_off": "关闭", + "device.button.ping": "Ping 设备", + "device.ping.online": "在线 ({ms}ms)", + "device.ping.offline": "设备离线", + "device.ping.error": "Ping 失败", "device.power.off_success": "设备已关闭", "device.status.connected": "已连接", "device.status.disconnected": "已断开",