From 29b43b028d97e4d75b49fa42d34ba66fcbaf1f02 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 17 Mar 2026 01:29:17 +0300 Subject: [PATCH] Fix automation badge overflow, dashboard crosslinks, compact numbers, icon grids, OpenRGB brightness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI fixes: - Automation card badge moved to flex layout — title truncates, badge stays visible - Automation condition pills max-width increased to 280px - Dashboard crosslinks fixed: pass correct sub-tab key (led-targets not led) - navigateToCard only skips data load when tab already has cards in DOM - Badge gets white-space:nowrap + flex-shrink:0 to prevent wrapping New features: - formatCompact() for large frame/error counters (1.2M, 45.2K) with hover title - Log filter and log level selects replaced with IconSelect grids - OpenRGB devices now support software brightness control OpenRGB improvements: - Added brightness_control capability (uses software brightness fallback) - Change-threshold dedup compares raw pixels before brightness scaling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/devices/openrgb_client.py | 11 ++-- .../core/devices/openrgb_provider.py | 2 +- .../static/css/automations.css | 5 ++ .../src/wled_controller/static/css/cards.css | 10 ++++ .../wled_controller/static/css/streams.css | 2 + .../static/js/core/navigation.js | 9 ++-- .../src/wled_controller/static/js/core/ui.js | 16 ++++++ .../static/js/features/automations.js | 2 +- .../static/js/features/dashboard.js | 8 +-- .../static/js/features/kc-targets.js | 8 +-- .../static/js/features/settings.js | 53 ++++++++++++++++++- .../static/js/features/targets.js | 8 +-- .../wled_controller/static/locales/en.json | 9 ++++ .../wled_controller/static/locales/ru.json | 9 ++++ .../wled_controller/static/locales/zh.json | 9 ++++ .../templates/modals/settings.html | 17 +++--- 16 files changed, 143 insertions(+), 35 deletions(-) diff --git a/server/src/wled_controller/core/devices/openrgb_client.py b/server/src/wled_controller/core/devices/openrgb_client.py index bb5acf2..7c3324b 100644 --- a/server/src/wled_controller/core/devices/openrgb_client.py +++ b/server/src/wled_controller/core/devices/openrgb_client.py @@ -288,16 +288,13 @@ class OpenRGBLEDClient(LEDClient): Builds raw OpenRGB UpdateZoneLeds packets directly with struct.pack, bypassing RGBColor object creation to avoid GC pressure. """ - # Apply brightness scaling - if brightness < 255: - pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8) - # Truncate to match target LED count n_target = self._device_led_count if len(pixel_array) > n_target: pixel_array = pixel_array[:n_target] - # Change-threshold dedup — skip if average per-LED color change < 2 + # Change-threshold dedup — compare RAW pixels before brightness scaling + # so low brightness doesn't crush differences below the threshold. # GPU I2C/SMBus writes cause system-wide stalls; minimizing writes is critical. if self._last_sent_pixels is not None and self._last_sent_pixels.shape == pixel_array.shape: diff = np.mean(np.abs(pixel_array.astype(np.int16) - self._last_sent_pixels.astype(np.int16))) @@ -305,6 +302,10 @@ class OpenRGBLEDClient(LEDClient): return self._last_sent_pixels = pixel_array.copy() + # Apply brightness scaling after dedup + if brightness < 255: + pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8) + # Separate mode: resample full pixel array independently per zone if self._zone_mode == "separate" and len(self._target_zones) > 1: n_src = len(pixel_array) diff --git a/server/src/wled_controller/core/devices/openrgb_provider.py b/server/src/wled_controller/core/devices/openrgb_provider.py index 5aed60b..29e0ee3 100644 --- a/server/src/wled_controller/core/devices/openrgb_provider.py +++ b/server/src/wled_controller/core/devices/openrgb_provider.py @@ -27,7 +27,7 @@ class OpenRGBDeviceProvider(LEDDeviceProvider): @property def capabilities(self) -> set: - return {"health_check", "auto_restore", "static_color"} + return {"health_check", "auto_restore", "static_color", "brightness_control"} def create_client(self, url: str, **kwargs) -> LEDClient: return OpenRGBLEDClient( diff --git a/server/src/wled_controller/static/css/automations.css b/server/src/wled_controller/static/css/automations.css index 7bd16d9..a80282b 100644 --- a/server/src/wled_controller/static/css/automations.css +++ b/server/src/wled_controller/static/css/automations.css @@ -27,6 +27,11 @@ padding: 0 4px; } +/* Automation condition pills need more room than the default 180px */ +[data-automation-id] .stream-card-prop { + max-width: 280px; +} + /* Automation condition editor rows */ .automation-condition-row { border: 1px solid var(--border-color); diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 2ed5d44..17a8330 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -419,6 +419,16 @@ body.cs-drag-active .card-drag-handle { white-space: nowrap; text-overflow: ellipsis; min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.card-title-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .card-title > .icon { diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index 87fc916..95c7a06 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -95,6 +95,8 @@ font-size: 0.75rem; font-weight: bold; text-transform: uppercase; + white-space: nowrap; + flex-shrink: 0; } .template-description { diff --git a/server/src/wled_controller/static/js/core/navigation.js b/server/src/wled_controller/static/js/core/navigation.js index d67bdde..fe86279 100644 --- a/server/src/wled_controller/static/js/core/navigation.js +++ b/server/src/wled_controller/static/js/core/navigation.js @@ -17,10 +17,11 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) { // Push current location to history so browser back returns here history.pushState(null, '', location.hash || '#'); - // Activate tab visually without triggering a data reload — - // the command palette already fetched fresh data, and a reload - // would re-render all cards, destroying the highlight. - switchTab(tab, { skipLoad: true }); + // Switch to the target tab, allowing data load if needed. + // skipLoad only when tab data is already in the DOM (cards exist). + const tabPanel = document.getElementById(`tab-${tab}`); + const hasCards = tabPanel && tabPanel.querySelector('.card'); + switchTab(tab, { skipLoad: !!hasCards }); requestAnimationFrame(() => { if (subTab) { diff --git a/server/src/wled_controller/static/js/core/ui.js b/server/src/wled_controller/static/js/core/ui.js index 759d160..59e9948 100644 --- a/server/src/wled_controller/static/js/core/ui.js +++ b/server/src/wled_controller/static/js/core/ui.js @@ -362,6 +362,22 @@ export function setTabRefreshing(containerId, refreshing) { } } +/** Format a large number compactly: 999 → "999", 1200 → "1.2K", 2500000 → "2.5M" */ +export function formatCompact(n) { + if (n == null || n < 0) return '-'; + if (n < 1000) return String(n); + if (n < 1_000_000) { + const v = n / 1000; + return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'K'; + } + if (n < 1_000_000_000) { + const v = n / 1_000_000; + return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'M'; + } + const v = n / 1_000_000_000; + return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B'; +} + export function formatUptime(seconds) { if (!seconds || seconds <= 0) return '-'; const h = Math.floor(seconds / 3600); diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 3750b5c..e93b20e 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -202,7 +202,7 @@ function createAutomationCard(automation, sceneMap = new Map()) { content: `
- ${escapeHtml(automation.name)} + ${escapeHtml(automation.name)} ${statusText}
diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 9fe3ead..1eb42cd 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -5,7 +5,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; -import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js'; +import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { @@ -189,7 +189,7 @@ function _updateRunningMetrics(enrichedRunning) { } const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`); - if (errorsEl) errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`; + if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; errorsEl.title = String(errors); } // Update health dot — prefer streaming reachability when processing const isLed = target.target_type === 'led' || target.target_type === 'wled'; @@ -542,7 +542,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap const isLed = target.target_type === 'led' || target.target_type === 'wled'; const icon = ICON_TARGET; const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc'); - const navSubTab = isLed ? 'led' : 'key_colors'; + const navSubTab = isLed ? 'led-targets' : 'kc-targets'; const navSection = isLed ? 'led-targets' : 'kc-targets'; const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id'; const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${target.id}')}`; @@ -607,7 +607,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
${ICON_CLOCK} ${uptime}
-
${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}
+
${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}
diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index 7ec4b66..0d5d99b 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -13,7 +13,7 @@ import { } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; -import { lockBody, showToast, showConfirm, formatUptime, desktopFocus } from '../core/ui.js'; +import { lockBody, showToast, showConfirm, formatUptime, formatCompact, desktopFocus } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { getValueSourceIcon, getPictureSourceIcon, @@ -153,13 +153,13 @@ export function patchKCTargetMetrics(target) { if (fpsTarget) fpsTarget.textContent = state.fps_target || 0; const frames = card.querySelector('[data-tm="frames"]'); - if (frames) frames.textContent = metrics.frames_processed || 0; + if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); } const keepalive = card.querySelector('[data-tm="keepalive"]'); - if (keepalive) keepalive.textContent = state.frames_keepalive ?? '-'; + if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); } const errors = card.querySelector('[data-tm="errors"]'); - if (errors) errors.textContent = metrics.errors_count || 0; + if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); } const uptime = card.querySelector('[data-tm="uptime"]'); if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); diff --git a/server/src/wled_controller/static/js/features/settings.js b/server/src/wled_controller/static/js/features/settings.js index bb38cb8..501f51e 100644 --- a/server/src/wled_controller/static/js/features/settings.js +++ b/server/src/wled_controller/static/js/features/settings.js @@ -8,6 +8,7 @@ import { Modal } from '../core/modal.js'; import { showToast, showConfirm } from '../core/ui.js'; import { t } from '../core/i18n.js'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js'; +import { IconSelect } from '../core/icon-select.js'; // ─── Log Viewer ──────────────────────────────────────────── @@ -130,9 +131,53 @@ export function applyLogFilter() { // Simple modal (no form / no dirty check needed) const settingsModal = new Modal('settings-modal'); +let _logFilterIconSelect = null; +let _logLevelIconSelect = null; + +const _LOG_LEVEL_ITEMS = [ + { value: 'DEBUG', icon: 'D', label: 'DEBUG', desc: t('settings.log_level.desc.debug') }, + { value: 'INFO', icon: 'I', label: 'INFO', desc: t('settings.log_level.desc.info') }, + { value: 'WARNING', icon: 'W', label: 'WARNING', desc: t('settings.log_level.desc.warning') }, + { value: 'ERROR', icon: 'E', label: 'ERROR', desc: t('settings.log_level.desc.error') }, + { value: 'CRITICAL', icon: '!', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') }, +]; + +const _LOG_FILTER_ITEMS = [ + { value: 'all', icon: '*', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') }, + { value: 'INFO', icon: 'I', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') }, + { value: 'WARNING', icon: 'W', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') }, + { value: 'ERROR', icon: 'E', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') }, +]; + export function openSettingsModal() { document.getElementById('settings-error').style.display = 'none'; settingsModal.open(); + + // Initialize log filter icon select + if (!_logFilterIconSelect) { + const filterSel = document.getElementById('log-viewer-filter'); + if (filterSel) { + _logFilterIconSelect = new IconSelect({ + target: filterSel, + items: _LOG_FILTER_ITEMS, + columns: 2, + onChange: () => applyLogFilter(), + }); + } + } + // Initialize log level icon select + if (!_logLevelIconSelect) { + const levelSel = document.getElementById('settings-log-level'); + if (levelSel) { + _logLevelIconSelect = new IconSelect({ + target: levelSel, + items: _LOG_LEVEL_ITEMS, + columns: 3, + onChange: () => setLogLevel(), + }); + } + } + loadApiKeysList(); loadAutoBackupSettings(); loadBackupList(); @@ -559,8 +604,12 @@ export async function loadLogLevel() { const resp = await fetchWithAuth('/system/log-level'); if (!resp.ok) return; const data = await resp.json(); - const select = document.getElementById('settings-log-level'); - if (select) select.value = data.level; + if (_logLevelIconSelect) { + _logLevelIconSelect.setValue(data.level); + } else { + const select = document.getElementById('settings-log-level'); + if (select) select.value = data.level; + } } catch (err) { console.error('Failed to load log level:', err); } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 2712df7..0cac3ba 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -14,7 +14,7 @@ import { } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js'; import { t } from '../core/i18n.js'; -import { showToast, showConfirm, formatUptime, setTabRefreshing, desktopFocus } from '../core/ui.js'; +import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache } from './devices.js'; import { _splitOpenrgbZone } from './device-discovery.js'; @@ -890,13 +890,13 @@ function _patchTargetMetrics(target) { if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state); const frames = card.querySelector('[data-tm="frames"]'); - if (frames) frames.textContent = metrics.frames_processed || 0; + if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); } const keepalive = card.querySelector('[data-tm="keepalive"]'); - if (keepalive) keepalive.textContent = state.frames_keepalive ?? '-'; + if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); } const errors = card.querySelector('[data-tm="errors"]'); - if (errors) errors.textContent = metrics.errors_count || 0; + if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); } // Error indicator near target name const errorIndicator = card.querySelector('.target-error-indicator'); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index cf25c8c..b509cb0 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1429,6 +1429,11 @@ "settings.log_level.save": "Apply", "settings.log_level.saved": "Log level changed", "settings.log_level.save_error": "Failed to change log level", + "settings.log_level.desc.debug": "Verbose developer output", + "settings.log_level.desc.info": "Normal operation messages", + "settings.log_level.desc.warning": "Potential problems", + "settings.log_level.desc.error": "Failures only", + "settings.log_level.desc.critical": "Fatal errors only", "settings.auto_backup.label": "Auto-Backup", "settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.", "settings.auto_backup.enable": "Enable auto-backup", @@ -1473,6 +1478,10 @@ "settings.logs.filter.info": "Info+", "settings.logs.filter.warning": "Warning+", "settings.logs.filter.error": "Error only", + "settings.logs.filter.all_desc": "Show all log messages", + "settings.logs.filter.info_desc": "Info, warning, and errors", + "settings.logs.filter.warning_desc": "Warnings and errors only", + "settings.logs.filter.error_desc": "Errors only", "device.error.power_off_failed": "Failed to turn off device", "device.removed": "Device removed", "device.error.remove_failed": "Failed to remove device", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 4a24c10..4cc18e5 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1429,6 +1429,11 @@ "settings.log_level.save": "Применить", "settings.log_level.saved": "Уровень логирования изменён", "settings.log_level.save_error": "Не удалось изменить уровень логирования", + "settings.log_level.desc.debug": "Подробный вывод для разработки", + "settings.log_level.desc.info": "Обычные сообщения", + "settings.log_level.desc.warning": "Возможные проблемы", + "settings.log_level.desc.error": "Только ошибки", + "settings.log_level.desc.critical": "Только критические ошибки", "settings.auto_backup.label": "Авто-бэкап", "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", "settings.auto_backup.enable": "Включить авто-бэкап", @@ -1473,6 +1478,10 @@ "settings.logs.filter.info": "Info+", "settings.logs.filter.warning": "Warning+", "settings.logs.filter.error": "Только ошибки", + "settings.logs.filter.all_desc": "Все сообщения лога", + "settings.logs.filter.info_desc": "Info, предупреждения и ошибки", + "settings.logs.filter.warning_desc": "Только предупреждения и ошибки", + "settings.logs.filter.error_desc": "Только ошибки", "device.error.power_off_failed": "Не удалось выключить устройство", "device.removed": "Устройство удалено", "device.error.remove_failed": "Не удалось удалить устройство", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index e9624bb..e0264da 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1429,6 +1429,11 @@ "settings.log_level.save": "应用", "settings.log_level.saved": "日志级别已更改", "settings.log_level.save_error": "更改日志级别失败", + "settings.log_level.desc.debug": "详细开发输出", + "settings.log_level.desc.info": "正常运行消息", + "settings.log_level.desc.warning": "潜在问题", + "settings.log_level.desc.error": "仅显示错误", + "settings.log_level.desc.critical": "仅显示致命错误", "settings.auto_backup.label": "自动备份", "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", "settings.auto_backup.enable": "启用自动备份", @@ -1473,6 +1478,10 @@ "settings.logs.filter.info": "Info+", "settings.logs.filter.warning": "Warning+", "settings.logs.filter.error": "仅错误", + "settings.logs.filter.all_desc": "显示所有日志消息", + "settings.logs.filter.info_desc": "Info、警告和错误", + "settings.logs.filter.warning_desc": "仅警告和错误", + "settings.logs.filter.error_desc": "仅错误", "device.error.power_off_failed": "关闭设备失败", "device.removed": "设备已移除", "device.error.remove_failed": "移除设备失败", diff --git a/server/src/wled_controller/templates/modals/settings.html b/server/src/wled_controller/templates/modals/settings.html index 4d614fa..623ad89 100644 --- a/server/src/wled_controller/templates/modals/settings.html +++ b/server/src/wled_controller/templates/modals/settings.html @@ -199,16 +199,13 @@
-
- - -
+