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 `
+
+
${ICON_CLOCK}
+
+
${escapeHtml(clock.name)} ${statusBadge}
+ ${subtitle ? `
${subtitle}
` : ''}
+
+
+
+
+
+
+
`;
+}
+
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.