diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 850ed9b..0fba888 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -37,6 +37,7 @@ import { import { loadDashboard, stopUptimeTimer, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardToggleAutoStart, dashboardStopAll, + dashboardPauseClock, dashboardResumeClock, dashboardResetClock, toggleDashboardSection, changeDashboardPollInterval, } from './features/dashboard.js'; import { startEventsWS, stopEventsWS } from './core/events-ws.js'; @@ -212,6 +213,9 @@ Object.assign(window, { dashboardStopTarget, dashboardToggleAutoStart, dashboardStopAll, + dashboardPauseClock, + dashboardResumeClock, + dashboardResetClock, toggleDashboardSection, changeDashboardPollInterval, stopUptimeTimer, diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 3292434..fc23642 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -9,9 +9,8 @@ import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { - getTargetTypeIcon, ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, - ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_SCENE, + ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_AUTOSTART, ICON_HELP, ICON_SCENE, } from '../core/icons.js'; import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; @@ -23,6 +22,7 @@ let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current let _fpsCharts = {}; // { targetId: Chart } let _lastRunningIds = []; // sorted target IDs from previous render let _lastAutoStartIds = ''; // comma-joined sorted auto-start IDs +let _lastSyncClockIds = ''; // comma-joined sorted sync clock IDs let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeTimer = null; let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs @@ -87,14 +87,9 @@ 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: { @@ -102,8 +97,8 @@ function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { datasets: [ { data: [...actualHistory], - borderColor: accent, - backgroundColor: accent + '1f', + borderColor: '#2196F3', + backgroundColor: 'rgba(33,150,243,0.12)', borderWidth: 1.5, tension: 0.3, fill: true, @@ -111,7 +106,7 @@ function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { }, { data: [...currentHistory], - borderColor: accent + '80', + borderColor: '#4CAF50', borderWidth: 1.5, tension: 0.3, fill: false, @@ -278,6 +273,60 @@ function _updateAutomationsInPlace(automations) { } } +function _updateSyncClocksInPlace(syncClocks) { + for (const c of syncClocks) { + const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`); + if (!card) continue; + const speedEl = card.querySelector('.dashboard-clock-speed'); + if (speedEl) speedEl.textContent = `${c.speed}x`; + const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped'); + if (badge) { + badge.className = c.is_running ? 'dashboard-badge-active' : 'dashboard-badge-stopped'; + badge.textContent = c.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused'); + } + const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn'); + if (btn) { + btn.className = `dashboard-action-btn ${c.is_running ? 'stop' : 'start'}`; + btn.setAttribute('onclick', c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`); + btn.innerHTML = c.is_running ? ICON_PAUSE : ICON_START; + } + } +} + +function renderDashboardSyncClock(clock) { + const statusBadge = clock.is_running + ? `${t('sync_clock.status.running')}` + : `${t('sync_clock.status.paused')}`; + + const toggleAction = clock.is_running + ? `dashboardPauseClock('${clock.id}')` + : `dashboardResumeClock('${clock.id}')`; + const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume'); + + const subtitle = [ + `${clock.speed}x`, + clock.description ? escapeHtml(clock.description) : '', + ].filter(Boolean).join(' · '); + + return ``; +} + function _renderPollIntervalSelect() { const sec = Math.round(dashboardPollInterval / 1000); return `${sec}s`; @@ -368,7 +417,7 @@ export async function loadDashboard(forceFullRender = false) { try { // Fire all requests in a single batch to avoid sequential RTTs - const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([ + const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([ fetchWithAuth('/picture-targets'), fetchWithAuth('/automations').catch(() => null), fetchWithAuth('/devices').catch(() => null), @@ -376,6 +425,7 @@ export async function loadDashboard(forceFullRender = false) { fetchWithAuth('/picture-targets/batch/states').catch(() => null), fetchWithAuth('/picture-targets/batch/metrics').catch(() => null), loadScenePresets(), + fetchWithAuth('/sync-clocks').catch(() => null), ]); const targetsData = await targetsResp.json(); @@ -388,6 +438,8 @@ export async function loadDashboard(forceFullRender = false) { const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] }; const cssSourceMap = {}; for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; } + const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] }; + const syncClocks = syncClocksData.clocks || []; const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {}; @@ -397,7 +449,7 @@ export async function loadDashboard(forceFullRender = false) { let runningIds = []; let newAutoStartIds = ''; - if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0) { + if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) { dynamicHtml = `
${t('dashboard.no_targets')}
`; } else { const enriched = targets.map(target => ({ @@ -414,10 +466,12 @@ export async function loadDashboard(forceFullRender = false) { const newRunningIds = running.map(t => t.id).sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(','); newAutoStartIds = enriched.filter(t => t.auto_start).map(t => t.id).sort().join(','); + const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(','); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); - const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds; + const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds && newSyncClockIds === _lastSyncClockIds; if (structureUnchanged && !forceFullRender && running.length > 0) { _updateRunningMetrics(running); + _updateSyncClocksInPlace(syncClocks); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); @@ -427,6 +481,7 @@ export async function loadDashboard(forceFullRender = false) { if (structureUnchanged && forceFullRender) { if (running.length > 0) _updateRunningMetrics(running); _updateAutomationsInPlace(automations); + _updateSyncClocksInPlace(syncClocks); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); @@ -504,6 +559,16 @@ export async function loadDashboard(forceFullRender = false) { } } + // Sync Clocks section + if (syncClocks.length > 0) { + const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join(''); + const clockGrid = `
${clockCards}
`; + dynamicHtml += `
+ ${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)} + ${_sectionContent('sync-clocks', clockGrid)} +
`; + } + if (targets.length > 0) { let targetsInner = ''; @@ -553,6 +618,7 @@ export async function loadDashboard(forceFullRender = false) { } _lastRunningIds = runningIds; _lastAutoStartIds = newAutoStartIds; + _lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(','); _cacheUptimeElements(); await _initFpsCharts(runningIds); _startUptimeTimer(); @@ -800,6 +866,42 @@ export async function dashboardStopAll() { } } +export async function dashboardPauseClock(clockId) { + try { + const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + showToast(t('sync_clock.paused'), 'success'); + loadDashboard(true); + } catch (e) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function dashboardResumeClock(clockId) { + try { + const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + showToast(t('sync_clock.resumed'), 'success'); + loadDashboard(true); + } catch (e) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function dashboardResetClock(clockId) { + try { + const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + showToast(t('sync_clock.reset_done'), 'success'); + loadDashboard(true); + } catch (e) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + export function stopUptimeTimer() { _stopUptimeTimer(); } @@ -828,18 +930,6 @@ document.addEventListener('languageChanged', () => { loadDashboard(); }); -// Update FPS chart colors when accent color changes -document.addEventListener('accentColorChanged', () => { - const accent = _getAccentColor(); - for (const chart of Object.values(_fpsCharts)) { - if (!chart) continue; - chart.data.datasets[0].borderColor = accent; - chart.data.datasets[0].backgroundColor = accent + '1f'; - chart.data.datasets[1].borderColor = accent + '80'; - chart.update(); - } -}); - // Pause uptime timer when browser tab is hidden, resume when visible document.addEventListener('visibilitychange', () => { if (document.hidden) { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 7db79b8..14f2ac8 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -556,6 +556,7 @@ "dashboard.failed": "Failed to load dashboard", "dashboard.section.automations": "Automations", "dashboard.section.scenes": "Scene Presets", + "dashboard.section.sync_clocks": "Sync Clocks", "dashboard.targets": "Targets", "dashboard.section.performance": "System Performance", "dashboard.perf.cpu": "CPU", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index ef6157d..3171d6f 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -556,6 +556,7 @@ "dashboard.failed": "Не удалось загрузить обзор", "dashboard.section.automations": "Автоматизации", "dashboard.section.scenes": "Пресеты сцен", + "dashboard.section.sync_clocks": "Синхронные часы", "dashboard.targets": "Цели", "dashboard.section.performance": "Производительность системы", "dashboard.perf.cpu": "ЦП", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index c971bf1..03894ea 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -556,6 +556,7 @@ "dashboard.failed": "加载仪表盘失败", "dashboard.section.automations": "自动化", "dashboard.section.scenes": "场景预设", + "dashboard.section.sync_clocks": "同步时钟", "dashboard.targets": "目标", "dashboard.section.performance": "系统性能", "dashboard.perf.cpu": "CPU", diff --git a/server/src/wled_controller/static/sw.js b/server/src/wled_controller/static/sw.js index e4c11e5..6453204 100644 --- a/server/src/wled_controller/static/sw.js +++ b/server/src/wled_controller/static/sw.js @@ -7,7 +7,7 @@ * - Navigation: network-first with offline fallback */ -const CACHE_NAME = 'ledgrab-v8'; +const CACHE_NAME = 'ledgrab-v9'; // Only pre-cache static assets (no auth required). // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.