diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index e19b57a..160779b 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -2,6 +2,7 @@ import io import json +import platform import subprocess import sys import threading @@ -55,6 +56,40 @@ except Exception: _nvml = None logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)") + +def _get_cpu_name() -> str | None: + """Get a human-friendly CPU model name (cached at module level).""" + try: + if platform.system() == "Windows": + import winreg + + key = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", + ) + name, _ = winreg.QueryValueEx(key, "ProcessorNameString") + winreg.CloseKey(key) + return name.strip() + elif platform.system() == "Linux": + with open("/proc/cpuinfo") as f: + for line in f: + if "model name" in line: + return line.split(":")[1].strip() + elif platform.system() == "Darwin": + return ( + subprocess.check_output( + ["sysctl", "-n", "machdep.cpu.brand_string"] + ) + .decode() + .strip() + ) + except Exception: + pass + return platform.processor() or None + + +_cpu_name: str | None = _get_cpu_name() + router = APIRouter() @@ -196,6 +231,7 @@ def get_system_performance(_: AuthRequired): pass return PerformanceResponse( + cpu_name=_cpu_name, cpu_percent=psutil.cpu_percent(interval=None), ram_used_mb=round(mem.used / 1024 / 1024, 1), ram_total_mb=round(mem.total / 1024 / 1024, 1), diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py index 2b10265..2ed16df 100644 --- a/server/src/wled_controller/api/schemas/system.py +++ b/server/src/wled_controller/api/schemas/system.py @@ -62,6 +62,7 @@ class GpuInfo(BaseModel): class PerformanceResponse(BaseModel): """System performance metrics.""" + cpu_name: str | None = Field(default=None, description="CPU model name") cpu_percent: float = Field(description="System-wide CPU usage percent") ram_used_mb: float = Field(description="RAM used in MB") ram_total_mb: float = Field(description="RAM total in MB") diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 8bfbd01..f48e852 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -195,6 +195,7 @@ select:disabled { input[type="range"] { width: 100%; margin: 8px 0; + accent-color: var(--primary-color); } /* Better password field appearance */ @@ -394,8 +395,7 @@ input:-webkit-autofill:focus { .modal-header-btn:focus-visible, .tab-btn:focus-visible, .stream-tab-btn:focus-visible, -.search-toggle:focus-visible, -.theme-toggle:focus-visible, +.header-btn:focus-visible, .tutorial-trigger-btn:focus-visible, .tutorial-close-btn:focus-visible, .btn-expand-collapse:focus-visible, diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index 84cc8f1..e2b95fd 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -104,6 +104,7 @@ .dashboard-target-icon { font-size: 1rem; flex-shrink: 0; + color: var(--primary-text-color); } .dashboard-target-name { @@ -317,7 +318,7 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 4px; + margin-bottom: 0; } .perf-chart-label { @@ -328,14 +329,33 @@ color: var(--text-secondary); } +.perf-chart-subtitle { + position: absolute; + top: 0; + left: 0; + font-size: 0.6rem; + font-weight: 400; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: calc(100% - 4px); + pointer-events: none; + opacity: 0.7; + z-index: 1; +} + .perf-chart-value { font-size: 0.85rem; font-weight: 700; + color: var(--primary-text-color); } -.perf-chart-value.cpu { color: #2196F3; } -.perf-chart-value.ram { color: #4CAF50; } -.perf-chart-value.gpu { color: #FF9800; } +.perf-chart-label .color-picker-swatch { + width: 12px; + height: 12px; + vertical-align: middle; +} .perf-chart-unavailable { text-align: center; diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index cad1afa..034f4a3 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -26,19 +26,31 @@ h2 { font-size: 1.5rem; } -.server-info { +.header-toolbar { display: flex; align-items: center; - gap: 8px; + gap: 2px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 3px 4px; +} + +.header-toolbar-sep { + width: 1px; + height: 18px; + background: var(--border-color); + margin: 0 3px; + flex-shrink: 0; } .header-link { color: var(--text-secondary); text-decoration: none; - font-size: 0.85rem; - font-weight: 500; + font-size: 0.75rem; + font-weight: 600; padding: 4px 8px; - border-radius: 4px; + border-radius: 5px; transition: color 0.2s, background 0.2s; } @@ -47,6 +59,23 @@ h2 { background: var(--bg-secondary); } +.header-locale { + padding: 2px 4px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text-secondary); + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + transition: color 0.2s, background 0.2s; +} + +.header-locale:hover { + color: var(--text-color); + background: var(--bg-secondary); +} + #server-version { font-size: 0.75rem; font-weight: 400; @@ -198,61 +227,67 @@ h2 { 100% { left: 100%; } } -/* Theme Toggle */ -.search-toggle, -.theme-toggle { - background: var(--card-bg); - border: 1px solid var(--border-color); - padding: 4px 8px; - border-radius: 4px; +/* Header toolbar buttons */ +.header-btn { + background: transparent; + border: none; + padding: 4px 6px; + border-radius: 5px; cursor: pointer; - font-size: 1rem; - transition: transform 0.2s; - margin-left: 0; + font-size: 0.9rem; + color: var(--text-secondary); + transition: color 0.2s, background 0.2s; + display: inline-flex; + align-items: center; + line-height: 1; } -.search-toggle:hover, -.theme-toggle:hover { - transform: scale(1.1); +.header-btn:hover { + color: var(--text-color); + background: var(--bg-secondary); } -/* Accent color picker */ -.accent-wrapper { +/* Reusable color picker popover */ +.color-picker-wrapper { position: relative; + display: inline-flex; + align-items: center; } -.accent-swatch { +.color-picker-swatch { display: inline-block; width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--border-color); + cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; } -.search-toggle:hover .accent-swatch { +.color-picker-swatch:hover { box-shadow: 0 0 6px var(--primary-color); } -.accent-popover { +.color-picker-popover { position: absolute; top: calc(100% + 8px); - right: 0; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 10px; box-shadow: 0 8px 24px var(--shadow-color); z-index: 200; - animation: accent-pop-in 0.15s ease-out; + animation: color-picker-pop-in 0.15s ease-out; } -@keyframes accent-pop-in { +.color-picker-popover.anchor-right { right: 0; } +.color-picker-popover.anchor-left { left: 0; } +@keyframes color-picker-pop-in { from { opacity: 0; transform: translateY(-4px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } -.accent-grid { +.color-picker-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } -.accent-dot { +.color-picker-dot { width: 32px; height: 32px; border-radius: 50%; @@ -261,15 +296,15 @@ h2 { transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s; padding: 0; } -.accent-dot:hover { +.color-picker-dot:hover { transform: scale(1.15); box-shadow: 0 0 8px rgba(255,255,255,0.2); } -.accent-dot.active { +.color-picker-dot.active { border-color: var(--text-color); box-shadow: 0 0 0 2px var(--card-bg), 0 0 0 4px var(--text-color); } -.accent-custom { +.color-picker-custom { display: flex; align-items: center; gap: 8px; @@ -280,7 +315,7 @@ h2 { color: var(--text-secondary); cursor: pointer; } -.accent-custom input[type="color"] { +.color-picker-custom input[type="color"] { width: 28px; height: 28px; border: 1px solid var(--border-color); @@ -457,9 +492,9 @@ h2 { } @media (max-width: 900px) { - .server-info { + .header-toolbar { flex-wrap: wrap; - gap: 4px; + gap: 2px; } } diff --git a/server/src/wled_controller/static/js/core/color-picker.js b/server/src/wled_controller/static/js/core/color-picker.js new file mode 100644 index 0000000..79bcf0c --- /dev/null +++ b/server/src/wled_controller/static/js/core/color-picker.js @@ -0,0 +1,112 @@ +/** + * Reusable color-picker popover. + * + * Usage: + * import { createColorPicker } from '../core/color-picker.js'; + * const html = createColorPicker({ + * id: 'my-picker', + * currentColor: '#4CAF50', + * onPick: 'myCallback', // global function name: window[onPick](hex) + * anchor: 'left', // 'left' | 'right' (default 'right') + * }); + * + * The returned HTML contains: + * - A small swatch dot (click to toggle popover) + * - A popover with 9 preset colors + custom native picker + * + * Call `closeAllColorPickers()` to dismiss any open popover. + */ + +import { t } from './i18n.js'; + +const PRESETS = [ + '#4CAF50', '#7C4DFF', '#FF6D00', + '#E91E63', '#00BCD4', '#FF5252', + '#26A69A', '#2196F3', '#FFC107', +]; + +/** + * Build the HTML string for a color-picker widget. + */ +export function createColorPicker({ id, currentColor, onPick, anchor = 'right' }) { + const dots = PRESETS.map(c => { + const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : ''; + return ``; + }).join(''); + + return `` + + `` + + `` + + ``; +} + +// -- Global helpers called from onclick attributes -- + +// Merge any callbacks pre-registered before this module loaded (e.g. accent picker in index.html) +const _callbacks = Object.assign({}, window._cpCallbacks || {}); + +/** Register the callback for a picker id. */ +export function registerColorPicker(id, callback) { + _callbacks[id] = callback; +} + +function _rgbToHex(rgb) { + const m = rgb.match(/\d+/g); + if (!m || m.length < 3) return rgb; + return '#' + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join(''); +} + +window._cpToggle = function (id) { + // Close all other pickers first + document.querySelectorAll('.color-picker-popover').forEach(p => { + if (p.id !== `cp-pop-${id}`) p.style.display = 'none'; + }); + const pop = document.getElementById(`cp-pop-${id}`); + if (!pop) return; + const show = pop.style.display === 'none'; + pop.style.display = show ? '' : 'none'; + if (show) { + // Mark active dot + const swatch = document.getElementById(`cp-swatch-${id}`); + const cur = swatch ? (_rgbToHex(swatch.style.backgroundColor) || swatch.style.background) : ''; + pop.querySelectorAll('.color-picker-dot').forEach(d => { + const dHex = _rgbToHex(d.style.backgroundColor || d.style.background); + d.classList.toggle('active', dHex.toLowerCase() === cur.toLowerCase()); + }); + } +}; + +window._cpPick = function (id, hex) { + // Update swatch + const swatch = document.getElementById(`cp-swatch-${id}`); + if (swatch) swatch.style.background = hex; + // Update native input + const native = document.getElementById(`cp-native-${id}`); + if (native) native.value = hex; + // Mark active dot + const pop = document.getElementById(`cp-pop-${id}`); + if (pop) { + pop.querySelectorAll('.color-picker-dot').forEach(d => { + const dHex = _rgbToHex(d.style.backgroundColor || d.style.background); + d.classList.toggle('active', dHex.toLowerCase() === hex.toLowerCase()); + }); + pop.style.display = 'none'; + } + // Fire callback + if (_callbacks[id]) _callbacks[id](hex); +}; + +export function closeAllColorPickers() { + document.querySelectorAll('.color-picker-popover').forEach(p => p.style.display = 'none'); +} + +// Close on outside click +document.addEventListener('click', () => closeAllColorPickers()); diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 3f2aad6..d8854e6 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -86,9 +86,14 @@ function _destroyFpsCharts() { _fpsCharts = {}; } +function _getAccentColor() { + return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4CAF50'; +} + function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { const canvas = document.getElementById(canvasId); if (!canvas) return null; + const accent = _getAccentColor(); return new Chart(canvas, { type: 'line', data: { @@ -96,8 +101,8 @@ function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { datasets: [ { data: [...actualHistory], - borderColor: '#2196F3', - backgroundColor: 'rgba(33,150,243,0.12)', + borderColor: accent, + backgroundColor: accent + '1f', borderWidth: 1.5, tension: 0.3, fill: true, @@ -105,7 +110,7 @@ function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { }, { data: [...currentHistory], - borderColor: '#4CAF50', + borderColor: accent + '80', borderWidth: 1.5, tension: 0.3, fill: false, @@ -490,7 +495,7 @@ export async function loadDashboard(forceFullRender = false) { if (running.length > 0) { runningIds = running.map(t => t.id); - const stopAllBtn = ``; + const stopAllBtn = ``; const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join(''); targetsInner += `
diff --git a/server/src/wled_controller/static/js/features/perf-charts.js b/server/src/wled_controller/static/js/features/perf-charts.js index 51d8f30..6bede60 100644 --- a/server/src/wled_controller/static/js/features/perf-charts.js +++ b/server/src/wled_controller/static/js/features/perf-charts.js @@ -6,44 +6,68 @@ import { API_BASE, getHeaders } from '../core/api.js'; import { t } from '../core/i18n.js'; import { dashboardPollInterval } from '../core/state.js'; +import { createColorPicker, registerColorPicker } from '../core/color-picker.js'; const MAX_SAMPLES = 120; +const CHART_KEYS = ['cpu', 'ram', 'gpu']; let _pollTimer = null; let _charts = {}; // { cpu: Chart, ram: Chart, gpu: Chart } let _history = { cpu: [], ram: [], gpu: [] }; let _hasGpu = null; // null = unknown, true/false after first fetch +function _getColor(key) { + return localStorage.getItem(`perfChartColor_${key}`) + || getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() + || '#4CAF50'; +} + +function _onChartColorChange(key, hex) { + localStorage.setItem(`perfChartColor_${key}`, hex); + const chart = _charts[key]; + if (chart) { + chart.data.datasets[0].borderColor = hex; + chart.data.datasets[0].backgroundColor = hex + '26'; + chart.update(); + } +} + /** Returns the static HTML for the perf section (canvas placeholders). */ export function renderPerfSection() { + // Register callbacks before rendering + for (const key of CHART_KEYS) { + registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); + } + return `
- ${t('dashboard.perf.cpu')} - - + ${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), anchor: 'left' })} + -
-
+
- ${t('dashboard.perf.ram')} - - + ${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), anchor: 'left' })} + -
- ${t('dashboard.perf.gpu')} - - + ${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), anchor: 'left' })} + -
-
+
`; } -function _createChart(canvasId, color, fillColor) { +function _createChart(canvasId, key) { const ctx = document.getElementById(canvasId); if (!ctx) return null; + const color = _getColor(key); return new Chart(ctx, { type: 'line', data: { @@ -51,7 +75,7 @@ function _createChart(canvasId, color, fillColor) { datasets: [{ data: [], borderColor: color, - backgroundColor: fillColor, + backgroundColor: color + '26', borderWidth: 1.5, tension: 0.3, fill: true, @@ -88,7 +112,7 @@ async function _seedFromServer() { _hasGpu = true; } - for (const key of ['cpu', 'ram', 'gpu']) { + for (const key of CHART_KEYS) { if (_charts[key] && _history[key].length > 0) { _charts[key].data.datasets[0].data = [..._history[key]]; _charts[key].data.labels = _history[key].map(() => ''); @@ -103,9 +127,9 @@ async function _seedFromServer() { /** Initialize Chart.js instances on the already-mounted canvases. */ export async function initPerfCharts() { _destroyCharts(); - _charts.cpu = _createChart('perf-chart-cpu', '#2196F3', 'rgba(33,150,243,0.15)'); - _charts.ram = _createChart('perf-chart-ram', '#4CAF50', 'rgba(76,175,80,0.15)'); - _charts.gpu = _createChart('perf-chart-gpu', '#FF9800', 'rgba(255,152,0,0.15)'); + _charts.cpu = _createChart('perf-chart-cpu', 'cpu'); + _charts.ram = _createChart('perf-chart-ram', 'ram'); + _charts.gpu = _createChart('perf-chart-gpu', 'gpu'); await _seedFromServer(); } @@ -135,6 +159,10 @@ async function _fetchPerformance() { _pushSample('cpu', data.cpu_percent); const cpuEl = document.getElementById('perf-cpu-value'); if (cpuEl) cpuEl.textContent = `${data.cpu_percent.toFixed(0)}%`; + if (data.cpu_name) { + const nameEl = document.getElementById('perf-cpu-name'); + if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name; + } // RAM _pushSample('ram', data.ram_percent); @@ -151,6 +179,10 @@ async function _fetchPerformance() { _pushSample('gpu', data.gpu.utilization); const gpuEl = document.getElementById('perf-gpu-value'); if (gpuEl) gpuEl.textContent = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`; + if (data.gpu.name) { + const nameEl = document.getElementById('perf-gpu-name'); + if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name; + } } else if (_hasGpu === null) { _hasGpu = false; const card = document.getElementById('perf-gpu-card'); diff --git a/server/src/wled_controller/static/js/features/tutorials.js b/server/src/wled_controller/static/js/features/tutorials.js index e7ab459..fa91a30 100644 --- a/server/src/wled_controller/static/js/features/tutorials.js +++ b/server/src/wled_controller/static/js/features/tutorials.js @@ -29,7 +29,7 @@ const gettingStartedSteps = [ { selector: '#tab-btn-profiles', textKey: 'tour.profiles', position: 'bottom' }, { selector: '[onclick*="openSettingsModal"]', textKey: 'tour.settings', position: 'bottom' }, { selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' }, - { selector: '.theme-toggle', textKey: 'tour.theme', position: 'bottom' }, + { selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' }, { selector: '#locale-select', textKey: 'tour.language', position: 'bottom' } ]; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 99c7a11..5af77d9 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -533,6 +533,7 @@ "dashboard.perf.ram": "RAM", "dashboard.perf.gpu": "GPU", "dashboard.perf.unavailable": "unavailable", + "dashboard.perf.color": "Chart color", "dashboard.poll_interval": "Refresh interval", "profiles.title": "Profiles", "profiles.empty": "No profiles configured. Create one to automate target activation.", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index fed38ca..89291f5 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -533,6 +533,7 @@ "dashboard.perf.ram": "ОЗУ", "dashboard.perf.gpu": "ГП", "dashboard.perf.unavailable": "недоступно", + "dashboard.perf.color": "Цвет графика", "dashboard.poll_interval": "Интервал обновления", "profiles.title": "Профили", "profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index ce51ada..02b47d3 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -533,6 +533,7 @@ "dashboard.perf.ram": "内存", "dashboard.perf.gpu": "GPU", "dashboard.perf.unavailable": "不可用", + "dashboard.perf.color": "图表颜色", "dashboard.poll_interval": "刷新间隔", "profiles.title": "配置文件", "profiles.empty": "尚未配置配置文件。创建一个以自动化目标激活。", diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index b41ead0..4d5014f 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -26,52 +26,54 @@

LED Grab

-
+
API - - - -
- -