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

@@ -288,16 +288,13 @@ class OpenRGBLEDClient(LEDClient):
Builds raw OpenRGB UpdateZoneLeds packets directly with struct.pack, Builds raw OpenRGB UpdateZoneLeds packets directly with struct.pack,
bypassing RGBColor object creation to avoid GC pressure. 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 # Truncate to match target LED count
n_target = self._device_led_count n_target = self._device_led_count
if len(pixel_array) > n_target: if len(pixel_array) > n_target:
pixel_array = 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. # 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: 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))) 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 return
self._last_sent_pixels = pixel_array.copy() 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 # Separate mode: resample full pixel array independently per zone
if self._zone_mode == "separate" and len(self._target_zones) > 1: if self._zone_mode == "separate" and len(self._target_zones) > 1:
n_src = len(pixel_array) n_src = len(pixel_array)

View File

@@ -27,7 +27,7 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
@property @property
def capabilities(self) -> set: 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: def create_client(self, url: str, **kwargs) -> LEDClient:
return OpenRGBLEDClient( return OpenRGBLEDClient(

View File

@@ -27,6 +27,11 @@
padding: 0 4px; 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 editor rows */
.automation-condition-row { .automation-condition-row {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);

View File

@@ -419,6 +419,16 @@ body.cs-drag-active .card-drag-handle {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
min-width: 0; 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 { .card-title > .icon {

View File

@@ -95,6 +95,8 @@
font-size: 0.75rem; font-size: 0.75rem;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap;
flex-shrink: 0;
} }
.template-description { .template-description {

View File

@@ -17,10 +17,11 @@ export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
// Push current location to history so browser back returns here // Push current location to history so browser back returns here
history.pushState(null, '', location.hash || '#'); history.pushState(null, '', location.hash || '#');
// Activate tab visually without triggering a data reload // Switch to the target tab, allowing data load if needed.
// the command palette already fetched fresh data, and a reload // skipLoad only when tab data is already in the DOM (cards exist).
// would re-render all cards, destroying the highlight. const tabPanel = document.getElementById(`tab-${tab}`);
switchTab(tab, { skipLoad: true }); const hasCards = tabPanel && tabPanel.querySelector('.card');
switchTab(tab, { skipLoad: !!hasCards });
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (subTab) { 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) { export function formatUptime(seconds) {
if (!seconds || seconds <= 0) return '-'; if (!seconds || seconds <= 0) return '-';
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);

View File

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

View File

@@ -5,7 +5,7 @@
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js'; 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 { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.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 { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js';
import { import {
@@ -189,7 +189,7 @@ function _updateRunningMetrics(enrichedRunning) {
} }
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`); 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 // Update health dot — prefer streaming reachability when processing
const isLed = target.target_type === 'led' || target.target_type === 'wled'; 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 isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = ICON_TARGET; const icon = ICON_TARGET;
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc'); 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 navSection = isLed ? 'led-targets' : 'kc-targets';
const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id'; const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${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 class="dashboard-metric-value" data-uptime-text="${target.id}">${ICON_CLOCK} ${uptime}</div>
</div> </div>
<div class="dashboard-metric" title="${t('dashboard.errors')}"> <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> </div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">

View File

@@ -13,7 +13,7 @@ import {
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.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 { Modal } from '../core/modal.js';
import { import {
getValueSourceIcon, getPictureSourceIcon, getValueSourceIcon, getPictureSourceIcon,
@@ -153,13 +153,13 @@ export function patchKCTargetMetrics(target) {
if (fpsTarget) fpsTarget.textContent = state.fps_target || 0; if (fpsTarget) fpsTarget.textContent = state.fps_target || 0;
const frames = card.querySelector('[data-tm="frames"]'); 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"]'); 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"]'); 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"]'); const uptime = card.querySelector('[data-tm="uptime"]');
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); 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 { showToast, showConfirm } from '../core/ui.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
import { IconSelect } from '../core/icon-select.js';
// ─── Log Viewer ──────────────────────────────────────────── // ─── Log Viewer ────────────────────────────────────────────
@@ -130,9 +131,53 @@ export function applyLogFilter() {
// Simple modal (no form / no dirty check needed) // Simple modal (no form / no dirty check needed)
const settingsModal = new Modal('settings-modal'); 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() { export function openSettingsModal() {
document.getElementById('settings-error').style.display = 'none'; document.getElementById('settings-error').style.display = 'none';
settingsModal.open(); 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(); loadApiKeysList();
loadAutoBackupSettings(); loadAutoBackupSettings();
loadBackupList(); loadBackupList();
@@ -559,8 +604,12 @@ export async function loadLogLevel() {
const resp = await fetchWithAuth('/system/log-level'); const resp = await fetchWithAuth('/system/log-level');
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
if (_logLevelIconSelect) {
_logLevelIconSelect.setValue(data.level);
} else {
const select = document.getElementById('settings-log-level'); const select = document.getElementById('settings-log-level');
if (select) select.value = data.level; if (select) select.value = data.level;
}
} catch (err) { } catch (err) {
console.error('Failed to load log level:', err); console.error('Failed to load log level:', err);
} }

View File

@@ -14,7 +14,7 @@ import {
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
import { t } from '../core/i18n.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 { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache } from './devices.js'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache } from './devices.js';
import { _splitOpenrgbZone } from './device-discovery.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); if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);
const frames = card.querySelector('[data-tm="frames"]'); 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"]'); 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"]'); 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 // Error indicator near target name
const errorIndicator = card.querySelector('.target-error-indicator'); const errorIndicator = card.querySelector('.target-error-indicator');

View File

@@ -1429,6 +1429,11 @@
"settings.log_level.save": "Apply", "settings.log_level.save": "Apply",
"settings.log_level.saved": "Log level changed", "settings.log_level.saved": "Log level changed",
"settings.log_level.save_error": "Failed to change log level", "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.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.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", "settings.auto_backup.enable": "Enable auto-backup",
@@ -1473,6 +1478,10 @@
"settings.logs.filter.info": "Info+", "settings.logs.filter.info": "Info+",
"settings.logs.filter.warning": "Warning+", "settings.logs.filter.warning": "Warning+",
"settings.logs.filter.error": "Error only", "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.error.power_off_failed": "Failed to turn off device",
"device.removed": "Device removed", "device.removed": "Device removed",
"device.error.remove_failed": "Failed to remove device", "device.error.remove_failed": "Failed to remove device",

View File

@@ -1429,6 +1429,11 @@
"settings.log_level.save": "Применить", "settings.log_level.save": "Применить",
"settings.log_level.saved": "Уровень логирования изменён", "settings.log_level.saved": "Уровень логирования изменён",
"settings.log_level.save_error": "Не удалось изменить уровень логирования", "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.label": "Авто-бэкап",
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап", "settings.auto_backup.enable": "Включить авто-бэкап",
@@ -1473,6 +1478,10 @@
"settings.logs.filter.info": "Info+", "settings.logs.filter.info": "Info+",
"settings.logs.filter.warning": "Warning+", "settings.logs.filter.warning": "Warning+",
"settings.logs.filter.error": "Только ошибки", "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.error.power_off_failed": "Не удалось выключить устройство",
"device.removed": "Устройство удалено", "device.removed": "Устройство удалено",
"device.error.remove_failed": "Не удалось удалить устройство", "device.error.remove_failed": "Не удалось удалить устройство",

View File

@@ -1429,6 +1429,11 @@
"settings.log_level.save": "应用", "settings.log_level.save": "应用",
"settings.log_level.saved": "日志级别已更改", "settings.log_level.saved": "日志级别已更改",
"settings.log_level.save_error": "更改日志级别失败", "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.label": "自动备份",
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份", "settings.auto_backup.enable": "启用自动备份",
@@ -1473,6 +1478,10 @@
"settings.logs.filter.info": "Info+", "settings.logs.filter.info": "Info+",
"settings.logs.filter.warning": "Warning+", "settings.logs.filter.warning": "Warning+",
"settings.logs.filter.error": "仅错误", "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.error.power_off_failed": "关闭设备失败",
"device.removed": "设备已移除", "device.removed": "设备已移除",
"device.error.remove_failed": "移除设备失败", "device.error.remove_failed": "移除设备失败",

View File

@@ -199,16 +199,13 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small> <small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
<div style="display:flex;gap:0.5rem;"> <select id="settings-log-level">
<select id="settings-log-level" style="flex:1">
<option value="DEBUG">DEBUG</option> <option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option> <option value="INFO">INFO</option>
<option value="WARNING">WARNING</option> <option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option> <option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option> <option value="CRITICAL">CRITICAL</option>
</select> </select>
<button class="btn btn-primary" onclick="setLogLevel()" data-i18n="settings.log_level.save">Apply</button>
</div>
</div> </div>
<!-- Restart section --> <!-- Restart section -->