Add WLED health monitoring, calibration test mode, and UI improvements
Some checks failed
Validate / validate (push) Failing after 8s

- Add background health checks (GET /json/info) with configurable interval per device
- Auto-detect LED count from WLED device on add (remove led_count from create API)
- Add calibration test mode: toggle edges on/off with colored LEDs via PUT endpoint
- Show WLED firmware version badge and LED count badge on device cards
- Add modal dirty tracking with discard confirmation on close/backdrop click
- Fix layout jump when modals open by compensating for scrollbar width
- Add state_check_interval to settings API and UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 23:44:29 +03:00
parent 579821a69b
commit d4261d76d8
10 changed files with 1047 additions and 315 deletions

View File

@@ -5,6 +5,31 @@ let apiKey = null;
// Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
// Calibration test mode state
const calibrationTestState = {}; // deviceId -> Set of active edge names
// Modal dirty tracking - stores initial values when modals open
let settingsInitialValues = {};
let calibrationInitialValues = {};
const EDGE_TEST_COLORS = {
top: [255, 0, 0],
right: [0, 255, 0],
bottom: [0, 100, 255],
left: [255, 255, 0]
};
// Modal body lock helpers - prevent layout jump when scrollbar disappears
function lockBody() {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = scrollbarWidth + 'px';
document.body.classList.add('modal-open');
}
function unlockBody() {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
}
// Locale management
let currentLocale = 'en';
let translations = {};
@@ -238,7 +263,7 @@ async function loadServerInfo() {
const response = await fetch('/health');
const data = await response.json();
document.getElementById('version-number').textContent = data.version;
document.getElementById('version-number').textContent = `v${data.version}`;
document.getElementById('server-status').textContent = '●';
document.getElementById('server-status').className = 'status-badge online';
} catch (error) {
@@ -270,32 +295,6 @@ async function loadDisplays() {
return;
}
// Render display cards with enhanced information
container.innerHTML = data.displays.map(display => `
<div class="display-card">
<div class="display-header">
<div class="display-index">${display.name}</div>
${display.is_primary ? `<span class="badge badge-primary">${t('displays.badge.primary')}</span>` : `<span class="badge badge-secondary">${t('displays.badge.secondary')}</span>`}
</div>
<div class="info-row">
<span class="info-label">${t('displays.resolution')}</span>
<span class="info-value">${display.width} × ${display.height}</span>
</div>
<div class="info-row">
<span class="info-label">${t('displays.refresh_rate')}</span>
<span class="info-value">${display.refresh_rate}Hz</span>
</div>
<div class="info-row">
<span class="info-label">${t('displays.position')}</span>
<span class="info-value">(${display.x}, ${display.y})</span>
</div>
<div class="info-row">
<span class="info-label">${t('displays.index')}</span>
<span class="info-value">${display.index}</span>
</div>
</div>
`).join('');
// Render visual layout
renderDisplayLayout(data.displays);
} catch (error) {
@@ -349,9 +348,12 @@ function renderDisplayLayout(displays) {
<div class="layout-display ${display.is_primary ? 'primary' : 'secondary'}"
style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;"
title="${display.name}\n${display.width}×${display.height}\nPosition: (${display.x}, ${display.y})">
<div class="layout-position-label">(${display.x}, ${display.y})</div>
<div class="layout-index-label">#${display.index}</div>
<div class="layout-display-label">
<strong>${display.name}</strong>
<small>${display.width}×${display.height}</small>
<small>${display.refresh_rate}Hz</small>
</div>
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
</div>
@@ -466,28 +468,56 @@ function createDeviceCard(device) {
const settings = device.settings || {};
const isProcessing = state.processing || false;
const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100);
const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle';
const status = isProcessing ? 'processing' : 'idle';
// WLED device health indicator
const wledOnline = state.wled_online || false;
const wledLatency = state.wled_latency_ms;
const wledName = state.wled_name;
const wledVersion = state.wled_version;
const wledLastChecked = state.wled_last_checked;
let healthClass, healthTitle, healthLabel;
if (wledLastChecked === null || wledLastChecked === undefined) {
healthClass = 'health-unknown';
healthTitle = t('device.health.checking');
healthLabel = '';
} else if (wledOnline) {
healthClass = 'health-online';
healthTitle = `${t('device.health.online')}`;
if (wledName) healthTitle += ` - ${wledName}`;
if (wledVersion) healthTitle += ` v${wledVersion}`;
healthLabel = wledLatency !== null && wledLatency !== undefined
? `<span class="health-latency">${Math.round(wledLatency)}ms</span>` : '';
} else {
healthClass = 'health-offline';
healthTitle = t('device.health.offline');
if (state.wled_error) healthTitle += `: ${state.wled_error}`;
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
}
return `
<div class="card" data-device-id="${device.id}">
<div class="card-header">
<div class="card-title">${device.name || device.id}</div>
<span class="badge ${status}">${t(statusKey)}</span>
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${wledVersion ? `<span class="wled-version">v${wledVersion}</span>` : ''}
${healthLabel}
</div>
<div class="card-header-badges">
<span class="display-badge" title="${t('device.display')} ${settings.display_index !== undefined ? settings.display_index : 0}">🖥️${settings.display_index !== undefined ? settings.display_index : 0}</span>
${state.wled_led_count ? `<span class="led-count-badge" title="${t('device.led_count')} ${state.wled_led_count}">💡${state.wled_led_count}</span>` : ''}
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div>
</div>
<div class="card-content">
<div class="info-row">
<span class="info-label">${t('device.url')}</span>
<span class="info-value">${device.url || 'N/A'}</span>
</div>
<div class="info-row">
<span class="info-label">${t('device.led_count')}</span>
<span class="info-value">${device.led_count || 0}</span>
</div>
<div class="info-row">
<span class="info-label">${t('device.display')}</span>
<span class="info-value">${settings.display_index !== undefined ? settings.display_index : 0}</span>
</div>
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
@@ -509,6 +539,13 @@ function createDeviceCard(device) {
</div>
` : ''}
</div>
<div class="brightness-control">
<input type="range" class="brightness-slider" min="0" max="100"
value="${brightnessPercent}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${brightnessPercent}%">
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopProcessing('${device.id}')" title="${t('device.button.stop')}">
@@ -647,17 +684,20 @@ async function showSettings(deviceId) {
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
document.getElementById('settings-device-led-count').value = device.led_count;
// Set health check interval
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
// Set brightness (convert from 0.0-1.0 to 0-100)
const brightnessPercent = Math.round((device.settings.brightness || 1.0) * 100);
document.getElementById('settings-device-brightness').value = brightnessPercent;
document.getElementById('brightness-value').textContent = brightnessPercent + '%';
// Snapshot initial values for dirty checking
settingsInitialValues = {
name: device.name,
url: device.url,
state_check_interval: String(device.settings.state_check_interval || 30),
};
// Show modal
const modal = document.getElementById('device-settings-modal');
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lockBody();
// Focus first input
setTimeout(() => {
@@ -670,36 +710,51 @@ async function showSettings(deviceId) {
}
}
function closeDeviceSettingsModal() {
function isSettingsDirty() {
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
);
}
function forceCloseDeviceSettingsModal() {
const modal = document.getElementById('device-settings-modal');
const error = document.getElementById('settings-error');
modal.style.display = 'none';
error.style.display = 'none';
document.body.classList.remove('modal-open');
unlockBody();
settingsInitialValues = {};
}
async function closeDeviceSettingsModal() {
if (isSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseDeviceSettingsModal();
}
async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const led_count = parseInt(document.getElementById('settings-device-led-count').value);
const brightnessPercent = parseInt(document.getElementById('settings-device-brightness').value);
const brightness = brightnessPercent / 100.0; // Convert to 0.0-1.0
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const error = document.getElementById('settings-error');
// Validation
if (!name || !url || !led_count || led_count < 1) {
if (!name || !url) {
error.textContent = 'Please fill in all fields correctly';
error.style.display = 'block';
return;
}
try {
// Update device info (name, url, led_count)
// Update device info (name, url)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url, led_count })
body: JSON.stringify({ name, url })
});
if (deviceResponse.status === 401) {
@@ -714,11 +769,11 @@ async function saveDeviceSettings() {
return;
}
// Update settings (brightness)
// Update settings (health check interval)
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness })
body: JSON.stringify({ state_check_interval })
});
if (settingsResponse.status === 401) {
@@ -728,7 +783,7 @@ async function saveDeviceSettings() {
if (settingsResponse.ok) {
showToast('Device settings updated', 'success');
closeDeviceSettingsModal();
forceCloseDeviceSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
@@ -742,21 +797,40 @@ async function saveDeviceSettings() {
}
}
// Card brightness controls
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
if (slider) slider.title = value + '%';
}
async function saveCardBrightness(deviceId, value) {
const brightness = parseInt(value) / 100.0;
try {
await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness })
});
} catch (err) {
console.error('Failed to update brightness:', err);
showToast('Failed to update brightness', 'error');
}
}
// Add device form handler
async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value;
const url = document.getElementById('device-url').value;
const led_count = parseInt(document.getElementById('device-led-count').value);
console.log(`Adding device: ${name} (${url}, ${led_count} LEDs)`);
console.log(`Adding device: ${name} (${url})`);
try {
const response = await fetch(`${API_BASE}/devices`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name, url, led_count })
body: JSON.stringify({ name, url })
});
if (response.status === 401) {
@@ -825,14 +899,14 @@ function showConfirm(message, title = null) {
noBtn.textContent = t('confirm.no');
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lockBody();
});
}
function closeConfirmModal(result) {
const modal = document.getElementById('confirm-modal');
modal.style.display = 'none';
document.body.classList.remove('modal-open');
unlockBody();
if (confirmResolve) {
confirmResolve(result);
@@ -881,13 +955,27 @@ async function showCalibration(deviceId) {
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
document.getElementById('cal-left-leds').value = edgeCounts.left;
// Snapshot initial values for dirty checking
calibrationInitialValues = {
start_position: calibration.start_position,
layout: calibration.layout,
offset: String(calibration.offset || 0),
top: String(edgeCounts.top),
right: String(edgeCounts.right),
bottom: String(edgeCounts.bottom),
left: String(edgeCounts.left),
};
// Initialize test mode state for this device
calibrationTestState[device.id] = new Set();
// Update preview
updateCalibrationPreview();
// Show modal
const modal = document.getElementById('calibration-modal');
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lockBody();
} catch (error) {
console.error('Failed to load calibration:', error);
@@ -895,55 +983,128 @@ async function showCalibration(deviceId) {
}
}
function closeCalibrationModal() {
function isCalibrationDirty() {
return (
document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position ||
document.getElementById('cal-layout').value !== calibrationInitialValues.layout ||
document.getElementById('cal-offset').value !== calibrationInitialValues.offset ||
document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom ||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left
);
}
function forceCloseCalibrationModal() {
const deviceId = document.getElementById('calibration-device-id').value;
if (deviceId) {
clearTestMode(deviceId);
}
const modal = document.getElementById('calibration-modal');
const error = document.getElementById('calibration-error');
modal.style.display = 'none';
error.style.display = 'none';
document.body.classList.remove('modal-open');
unlockBody();
calibrationInitialValues = {};
}
async function closeCalibrationModal() {
if (isCalibrationDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCalibrationModal();
}
function updateCalibrationPreview() {
// Update edge counts in preview
document.getElementById('preview-top-count').textContent = document.getElementById('cal-top-leds').value;
document.getElementById('preview-right-count').textContent = document.getElementById('cal-right-leds').value;
document.getElementById('preview-bottom-count').textContent = document.getElementById('cal-bottom-leds').value;
document.getElementById('preview-left-count').textContent = document.getElementById('cal-left-leds').value;
// Calculate total
// Calculate total from edge inputs
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
parseInt(document.getElementById('cal-right-leds').value || 0) +
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0);
document.getElementById('cal-total-leds').textContent = total;
// Update starting position indicator
// Update corner dot highlights for start position
const startPos = document.getElementById('cal-start-position').value;
const indicator = document.getElementById('start-indicator');
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
if (cornerEl) {
if (corner === startPos) {
cornerEl.classList.add('active');
} else {
cornerEl.classList.remove('active');
}
}
});
const positions = {
'bottom_left': { bottom: '10px', left: '10px', top: 'auto', right: 'auto' },
'bottom_right': { bottom: '10px', right: '10px', top: 'auto', left: 'auto' },
'top_left': { top: '10px', left: '10px', bottom: 'auto', right: 'auto' },
'top_right': { top: '10px', right: '10px', bottom: 'auto', left: 'auto' }
};
// Update direction toggle display
const direction = document.getElementById('cal-layout').value;
const dirIcon = document.getElementById('direction-icon');
const dirLabel = document.getElementById('direction-label');
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
const pos = positions[startPos];
indicator.style.top = pos.top;
indicator.style.right = pos.right;
indicator.style.bottom = pos.bottom;
indicator.style.left = pos.left;
// Update edge highlight states
const deviceId = document.getElementById('calibration-device-id').value;
const activeEdges = calibrationTestState[deviceId] || new Set();
['top', 'right', 'bottom', 'left'].forEach(edge => {
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
if (!edgeEl) return;
if (activeEdges.has(edge)) {
const [r, g, b] = EDGE_TEST_COLORS[edge];
edgeEl.classList.add('active');
edgeEl.style.background = `rgba(${r}, ${g}, ${b}, 0.7)`;
edgeEl.style.boxShadow = `0 0 8px rgba(${r}, ${g}, ${b}, 0.5)`;
} else {
edgeEl.classList.remove('active');
edgeEl.style.background = '';
edgeEl.style.boxShadow = '';
}
});
}
async function testCalibrationEdge(edge) {
function setStartPosition(position) {
document.getElementById('cal-start-position').value = position;
updateCalibrationPreview();
}
function toggleDirection() {
const select = document.getElementById('cal-layout');
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
updateCalibrationPreview();
}
async function toggleTestEdge(edge) {
const deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error');
if (!calibrationTestState[deviceId]) {
calibrationTestState[deviceId] = new Set();
}
// Toggle edge
if (calibrationTestState[deviceId].has(edge)) {
calibrationTestState[deviceId].delete(edge);
} else {
calibrationTestState[deviceId].add(edge);
}
// Build edges dict for API
const edges = {};
calibrationTestState[deviceId].forEach(e => {
edges[e] = EDGE_TEST_COLORS[e];
});
// Update visual state immediately
updateCalibrationPreview();
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'POST',
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edge, color: [255, 0, 0] }) // Red color
body: JSON.stringify({ edges })
});
if (response.status === 401) {
@@ -951,25 +1112,45 @@ async function testCalibrationEdge(edge) {
return;
}
if (response.ok) {
showToast(`Testing ${edge} edge (2 seconds)`, 'info');
} else {
if (!response.ok) {
const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to test edge:', err);
error.textContent = 'Failed to test edge';
console.error('Failed to toggle test edge:', err);
error.textContent = 'Failed to toggle test edge';
error.style.display = 'block';
}
}
async function clearTestMode(deviceId) {
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) {
return;
}
calibrationTestState[deviceId] = new Set();
try {
await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edges: {} })
});
} catch (err) {
console.error('Failed to clear test mode:', err);
}
}
async function saveCalibration() {
const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count').textContent);
const error = document.getElementById('calibration-error');
// Clear test mode before saving
await clearTestMode(deviceId);
updateCalibrationPreview();
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
@@ -1036,7 +1217,7 @@ async function saveCalibration() {
if (response.ok) {
showToast('Calibration saved', 'success');
closeCalibrationModal();
forceCloseCalibrationModal();
loadDevices();
} else {
const errorData = await response.json();
@@ -1085,6 +1266,40 @@ function shouldReverse(edge, startPosition, layout) {
return rules ? rules[edge] : false;
}
// Close modals on backdrop click
document.addEventListener('click', (e) => {
if (!e.target.classList.contains('modal')) return;
const modalId = e.target.id;
// Confirm modal: backdrop click acts as Cancel
if (modalId === 'confirm-modal') {
closeConfirmModal(false);
return;
}
// Login modal: close only if cancel button is visible (not required login)
if (modalId === 'api-key-modal') {
const cancelBtn = document.getElementById('modal-cancel-btn');
if (cancelBtn && cancelBtn.style.display !== 'none') {
closeApiKeyModal();
}
return;
}
// Settings modal: dirty check
if (modalId === 'device-settings-modal') {
closeDeviceSettingsModal();
return;
}
// Calibration modal: dirty check
if (modalId === 'calibration-modal') {
closeCalibrationModal();
return;
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {