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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 12:51:40 +03:00
parent 27884282a7
commit 6a22757755
8 changed files with 76 additions and 2 deletions

View File

@@ -406,6 +406,26 @@ async def get_device_state(
raise HTTPException(status_code=404, detail=str(e)) 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 ===== # ===== WLED BRIGHTNESS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) @router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])

View File

@@ -1080,6 +1080,13 @@ class ProcessorManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {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 ===== # ===== HELPERS =====
def has_device(self, device_id: str) -> bool: def has_device(self, device_id: str) -> bool:

View File

@@ -147,6 +147,11 @@ h2 {
to { transform: rotate(360deg); } 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 */ /* WLED device health indicator */
.health-dot { .health-dot {
display: inline-block; display: inline-block;

View File

@@ -31,7 +31,7 @@ import {
import { import {
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal, showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness, saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
turnOffDevice, removeDevice, loadDevices, turnOffDevice, pingDevice, removeDevice, loadDevices,
updateSettingsBaudFpsHint, copyWsUrl, updateSettingsBaudFpsHint, copyWsUrl,
} from './features/devices.js'; } from './features/devices.js';
import { import {
@@ -211,6 +211,7 @@ Object.assign(window, {
updateBrightnessLabel, updateBrightnessLabel,
saveCardBrightness, saveCardBrightness,
turnOffDevice, turnOffDevice,
pingDevice,
removeDevice, removeDevice,
loadDevices, loadDevices,
updateSettingsBaudFpsHint, updateSettingsBaudFpsHint,

View File

@@ -11,7 +11,7 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode }
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.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 { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.js';
@@ -133,6 +133,9 @@ export function createDeviceCard(device) {
</div>` : ''} </div>` : ''}
${renderTagChips(device.tags)}`, ${renderTagChips(device.tags)}`,
actions: ` actions: `
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
${ICON_REFRESH}
</button>
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}"> <button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS} ${ICON_SETTINGS}
</button>`, </button>`,
@@ -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) { export function attachDeviceListeners(deviceId) {
// Add any specific event listeners here if needed // Add any specific event listeners here if needed
} }

View File

@@ -186,6 +186,10 @@
"device.button.remove": "Remove", "device.button.remove": "Remove",
"device.button.webui": "Open Device Web UI", "device.button.webui": "Open Device Web UI",
"device.button.power_off": "Turn Off", "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.power.off_success": "Device turned off",
"device.status.connected": "Connected", "device.status.connected": "Connected",
"device.status.disconnected": "Disconnected", "device.status.disconnected": "Disconnected",

View File

@@ -186,6 +186,10 @@
"device.button.remove": "Удалить", "device.button.remove": "Удалить",
"device.button.webui": "Открыть веб-интерфейс устройства", "device.button.webui": "Открыть веб-интерфейс устройства",
"device.button.power_off": "Выключить", "device.button.power_off": "Выключить",
"device.button.ping": "Пинг устройства",
"device.ping.online": "Онлайн ({ms}мс)",
"device.ping.offline": "Устройство недоступно",
"device.ping.error": "Ошибка пинга",
"device.power.off_success": "Устройство выключено", "device.power.off_success": "Устройство выключено",
"device.status.connected": "Подключено", "device.status.connected": "Подключено",
"device.status.disconnected": "Отключено", "device.status.disconnected": "Отключено",

View File

@@ -186,6 +186,10 @@
"device.button.remove": "移除", "device.button.remove": "移除",
"device.button.webui": "打开设备 Web UI", "device.button.webui": "打开设备 Web UI",
"device.button.power_off": "关闭", "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.power.off_success": "设备已关闭",
"device.status.connected": "已连接", "device.status.connected": "已连接",
"device.status.disconnected": "已断开", "device.status.disconnected": "已断开",