feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout
All checks were successful
Lint & Test / test (push) Successful in 1m26s

API Input:
- Add interpolation mode (linear/nearest/none) for LED count mismatch
  between incoming data and device LED count
- New IconSelect in editor, i18n for en/ru/zh
- Mark crossfade as won't-do (client owns temporal transitions)
- Mark last-write-wins as already implemented

LED Preview:
- Fix zone-mode preview parsing composite wire format (0xFE header
  bytes were rendered as color data, garbling multi-zone previews)
- Fix _restoreLedPreviewState to handle zone-mode panels

FPS Charts:
- Seed target card charts from server metrics-history on first load
- Add fetchMetricsHistory() with 5s TTL cache shared across
  dashboard, targets, perf-charts, and graph-editor
- Fix chart padding: pass maxSamples per caller (120 for dashboard,
  30 for target cards) instead of hardcoded 120
- Fix dashboard chart empty on tab switch (always fetch server history)
- Left-pad with nulls for consistent chart width across targets

Dashboard:
- Fix metrics row alignment (grid layout with fixed column widths)
- Fix FPS label overflow into uptime column
This commit is contained in:
2026-03-26 02:06:49 +03:00
parent be4c98b543
commit 3e0bf8538c
17 changed files with 248 additions and 67 deletions

View File

@@ -90,6 +90,7 @@ class CSSEditorModal extends Modal {
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value,
api_input_timeout: (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value,
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value,
notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
notification_duration: (document.getElementById('css-editor-notification-duration') as HTMLInputElement).value,
@@ -392,6 +393,7 @@ let _audioVizIconSelect: any = null;
let _gradientPresetIconSelect: any = null;
let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null;
let _apiInputInterpolationIconSelect: any = null;
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureInterpolationIconSelect() {
@@ -406,6 +408,18 @@ function _ensureInterpolationIconSelect() {
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
function _ensureApiInputInterpolationIconSelect() {
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'linear', icon: _icon(P.activity), label: t('color_strip.api_input.interpolation.linear'), desc: t('color_strip.api_input.interpolation.linear.desc') },
{ value: 'nearest', icon: _icon(P.hash), label: t('color_strip.api_input.interpolation.nearest'), desc: t('color_strip.api_input.interpolation.nearest.desc') },
{ value: 'none', icon: _icon(P.circleOff), label: t('color_strip.api_input.interpolation.none'), desc: t('color_strip.api_input.interpolation.none.desc') },
];
if (_apiInputInterpolationIconSelect) { _apiInputInterpolationIconSelect.updateItems(items); return; }
_apiInputInterpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
function _ensureEffectTypeIconSelect() {
const sel = document.getElementById('css-editor-effect-type') as HTMLSelectElement | null;
if (!sel) return;
@@ -1042,11 +1056,13 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
api_input: (source) => {
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
return `
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
</span>
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
<span class="stream-card-prop" title="${t('color_strip.api_input.interpolation')}">${escapeHtml(interpLabel)}</span>
`;
},
notification: (source) => {
@@ -1416,12 +1432,16 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0;
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(css.id);
},
reset() {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = 5.0 as any;
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0';
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(null);
},
getPayload(name) {
@@ -1430,6 +1450,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name,
fallback_color: hexToRgbArray(fbHex),
timeout: parseFloat((document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value),
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
};
},
},

View File

@@ -3,7 +3,7 @@
*/
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
@@ -99,21 +99,16 @@ function _createFpsChart(canvasId: string, actualHistory: number[], currentHisto
async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
_destroyFpsCharts();
// Seed FPS history from server ring buffer on first load
if (Object.keys(_fpsHistory).length === 0 && runningTargetIds.length > 0) {
try {
const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() });
if (resp.ok) {
const data = await resp.json();
const serverTargets = data.targets || {};
for (const id of runningTargetIds) {
const samples = serverTargets[id] || [];
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
}
// Seed FPS history from server ring buffer (on first load and tab switches)
if (runningTargetIds.length > 0) {
const data = await fetchMetricsHistory();
if (data) {
const serverTargets = data.targets || {};
for (const id of runningTargetIds) {
const samples = serverTargets[id] || [];
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
}
} catch {
// Silently ignore — charts will fill from polling
}
}
@@ -162,15 +157,18 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
if (chart) {
const actualH = _fpsHistory[target.id] || [];
const currentH = _fpsCurrentHistory[target.id] || [];
// Mutate in-place to avoid array copies
// Left-pad with nulls so all charts span full width
const pad0 = MAX_FPS_SAMPLES - actualH.length;
const pad1 = MAX_FPS_SAMPLES - currentH.length;
const ds0 = chart.data.datasets[0].data;
ds0.length = 0;
if (pad0 > 0) for (let i = 0; i < pad0; i++) ds0.push(null);
ds0.push(...actualH);
const ds1 = chart.data.datasets[1].data;
ds1.length = 0;
if (pad1 > 0) for (let i = 0; i < pad1; i++) ds1.push(null);
ds1.push(...currentH);
while (chart.data.labels.length < ds0.length) chart.data.labels.push('');
chart.data.labels.length = ds0.length;
chart.data.labels.length = MAX_FPS_SAMPLES;
chart.update('none');
}

View File

@@ -13,7 +13,7 @@ import {
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
automationsCacheObj, csptCache,
} from '../core/state.ts';
import { fetchWithAuth } from '../core/api.ts';
import { fetchWithAuth, fetchMetricsHistory } from '../core/api.ts';
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { t } from '../core/i18n.ts';
@@ -2436,10 +2436,9 @@ async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTML
// Seed from server-side metrics history (non-blocking)
try {
const histResp = await fetchWithAuth('/system/metrics-history');
const hist = await fetchMetricsHistory();
if (_hoverNodeId !== nodeId) return; // user moved away during fetch
if (histResp.ok) {
const hist = await histResp.json();
if (hist) {
const samples = hist.targets?.[nodeId] || [];
for (const s of samples) {
if (s.fps != null) _hoverFpsHistory.push(s.fps);

View File

@@ -7,7 +7,7 @@ import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
window.Chart = Chart; // expose globally for targets.js, dashboard.js
import { API_BASE, getHeaders } from '../core/api.ts';
import { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts';
import { createColorPicker, registerColorPicker } from '../core/color-picker.ts';
@@ -112,9 +112,8 @@ function _createChart(canvasId: string, key: string): any {
/** Seed charts from server-side metrics history. */
async function _seedFromServer(): Promise<void> {
try {
const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() });
if (!resp.ok) return;
const data = await resp.json();
const data = await fetchMetricsHistory();
if (!data) return;
const samples = data.system || [];
_history.cpu = samples.map(s => s.cpu).filter(v => v != null);
_history.ram = samples.map(s => s.ram_pct).filter(v => v != null);

View File

@@ -12,7 +12,7 @@ import {
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -132,7 +132,7 @@ function _pushTargetFps(targetId: any, actual: any, current: any) {
}
function _createTargetFpsChart(canvasId: any, actualHistory: any, currentHistory: any, fpsTarget: any, maxHwFps: any) {
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps });
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps, maxSamples: _TARGET_MAX_FPS_SAMPLES });
}
function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
@@ -140,7 +140,6 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
if (!chart) return;
const actualH = _targetFpsHistory[targetId] || [];
const currentH = _targetFpsCurrentHistory[targetId] || [];
// Mutate in-place to avoid array copies
const ds0 = chart.data.datasets[0].data;
ds0.length = 0;
ds0.push(...actualH);
@@ -832,6 +831,25 @@ export async function loadTargetsTab() {
// Push FPS samples and create/update charts for running targets
const allTargets = [...ledTargets, ...kcTargets];
const runningIds = new Set();
const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id);
// Seed FPS history from server if empty (first load / page reload)
if (runningTargetIds.length > 0 && runningTargetIds.some(id => !_targetFpsHistory[id]?.length)) {
const data = await fetchMetricsHistory();
if (data) {
const serverTargets = data.targets || {};
for (const id of runningTargetIds) {
if (!_targetFpsHistory[id]?.length) {
const samples = serverTargets[id] || [];
const actual = samples.map((s: any) => s.fps).filter((v: any) => v != null);
const current = samples.map((s: any) => s.fps_current).filter((v: any) => v != null);
_targetFpsHistory[id] = actual.slice(-_TARGET_MAX_FPS_SAMPLES);
_targetFpsCurrentHistory[id] = current.slice(-_TARGET_MAX_FPS_SAMPLES);
}
}
}
}
allTargets.forEach(target => {
if (target.state && target.state.processing) {
runningIds.add(target.id);
@@ -1322,6 +1340,8 @@ function _renderLedStripZones(panel: any, rgbBytes: any) {
return;
}
// Separate mode: each zone gets the full source frame resampled to its LED count
// (matches backend OpenRGB separate-mode behavior)
for (const canvas of zoneCanvases) {
const zoneName = canvas.dataset.zoneName;
const zoneSize = cache[zoneName.toLowerCase()];
@@ -1381,28 +1401,45 @@ function connectLedPreviewWS(targetId: any) {
const panel = document.getElementById(`led-preview-panel-${targetId}`);
// Composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
if (raw.length > 4 && raw[1] === 0xFE && panel && panel.dataset.composite === '1') {
// Detect composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
const isCompositeWire = raw.length > 4 && raw[1] === 0xFE;
if (isCompositeWire) {
const layerCount = raw[2];
const ledCount = (raw[3] << 8) | raw[4];
const rgbSize = ledCount * 3;
let offset = 5;
// Render per-layer canvases (individual layers)
const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]');
for (let i = 0; i < layerCount; i++) {
const layerRgb = raw.subarray(offset, offset + rgbSize);
offset += rgbSize;
// layer canvases: idx 0 = "composite", idx 1..N = individual layers
const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first
if (canvas) _renderLedStrip(canvas, layerRgb);
// Render per-layer canvases if panel supports it
if (panel && panel.dataset.composite === '1') {
const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]');
for (let i = 0; i < layerCount; i++) {
const layerRgb = raw.subarray(offset, offset + rgbSize);
offset += rgbSize;
// layer canvases: idx 0 = "composite", idx 1..N = individual layers
const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first
if (canvas) _renderLedStrip(canvas, layerRgb);
}
} else {
// Skip layer data (panel doesn't have layer canvases)
offset += layerCount * rgbSize;
}
// Final composite result
const compositeRgb = raw.subarray(offset, offset + rgbSize);
_ledPreviewLastFrame[targetId] = compositeRgb;
const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]');
if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb);
if (panel) {
if (panel.dataset.composite === '1') {
const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]');
if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb);
} else if (panel.dataset.zoneMode === 'separate') {
_renderLedStripZones(panel, compositeRgb);
} else {
const canvas = panel.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, compositeRgb);
}
}
} else {
// Standard wire format: [brightness_byte] [R G B R G B ...]
const frame = raw.subarray(1);
@@ -1461,9 +1498,13 @@ function _restoreLedPreviewState(targetId: any) {
_setPreviewButtonState(targetId, true);
// Re-render cached frame onto the new canvas
const frame = _ledPreviewLastFrame[targetId];
if (frame) {
const canvas = panel?.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, frame);
if (frame && panel) {
if (panel.dataset.zoneMode === 'separate') {
_renderLedStripZones(panel, frame);
} else {
const canvas = panel.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, frame);
}
}
}