Fix automation badge overflow, dashboard crosslinks, compact numbers, icon grids, OpenRGB brightness

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 01:29:17 +03:00
parent 304fa24389
commit 29b43b028d
16 changed files with 143 additions and 35 deletions

View File

@@ -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) {

View File

@@ -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);

View File

@@ -202,7 +202,7 @@ function createAutomationCard(automation, sceneMap = new Map()) {
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(automation.name)}">
${escapeHtml(automation.name)}
<span class="card-title-text">${escapeHtml(automation.name)}</span>
<span class="badge badge-automation-${statusClass}">${statusText}</span>
</div>
</div>

View File

@@ -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
<div class="dashboard-metric-value" data-uptime-text="${target.id}">${ICON_CLOCK} ${uptime}</div>
</div>
<div class="dashboard-metric" title="${t('dashboard.errors')}">
<div class="dashboard-metric-value" data-errors-text="${target.id}">${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}</div>
<div class="dashboard-metric-value" data-errors-text="${target.id}" title="${errors}">${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}</div>
</div>
</div>
<div class="dashboard-target-actions">

View File

@@ -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);

View File

@@ -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: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
];
const _LOG_FILTER_ITEMS = [
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', 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);
}

View File

@@ -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');