Add sync clock cards to dashboard and match FPS chart colors
Sync clocks now appear as compact cards on the dashboard with pause/resume/reset controls and click-to-navigate. Dashboard FPS sparkline charts use the same blue/green colors as target card charts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
? `<span class="dashboard-badge-active">${t('sync_clock.status.running')}</span>`
|
||||
: `<span class="dashboard-badge-stopped">${t('sync_clock.status.paused')}</span>`;
|
||||
|
||||
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 = [
|
||||
`<span class="dashboard-clock-speed">${clock.speed}x</span>`,
|
||||
clock.description ? escapeHtml(clock.description) : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}">
|
||||
<div class="dashboard-target-info">
|
||||
<span class="dashboard-target-icon">${ICON_CLOCK}</span>
|
||||
<div>
|
||||
<div class="dashboard-target-name">${escapeHtml(clock.name)} ${statusBadge}</div>
|
||||
${subtitle ? `<div class="dashboard-target-subtitle">${subtitle}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-target-actions">
|
||||
<button class="dashboard-action-btn ${clock.is_running ? 'stop' : 'start'}" onclick="${toggleAction}" title="${toggleTitle}">
|
||||
${clock.is_running ? ICON_PAUSE : ICON_START}
|
||||
</button>
|
||||
<button class="dashboard-action-btn" onclick="dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">
|
||||
${ICON_CLOCK}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderPollIntervalSelect() {
|
||||
const sec = Math.round(dashboardPollInterval / 1000);
|
||||
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
|
||||
@@ -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 = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||
} 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 = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)}
|
||||
${_sectionContent('sync-clocks', clockGrid)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user