feat: HA light output targets — cast LED colors to Home Assistant lights
Some checks failed
Lint & Test / test (push) Has been cancelled

New output target type `ha_light` that sends averaged LED colors to HA
light entities via WebSocket service calls (light.turn_on/turn_off):

Backend:
- HARuntime.call_service(): fire-and-forget WS service calls
- HALightOutputTarget: data model with light mappings, update rate, transition
- HALightTargetProcessor: processing loop with delta detection, rate limiting
- ProcessorManager.add_ha_light_target(): registration
- API schemas/routes updated for ha_light target type

Frontend:
- HA Light Targets section in Targets tab tree nav
- Modal editor: HA source picker, CSS source picker, light entity mappings
- Target cards with start/stop/clone/edit actions
- i18n keys for all new UI strings
This commit is contained in:
2026-03-28 00:08:49 +03:00
parent fb98e6e2b8
commit cb9289f01f
25 changed files with 1679 additions and 164 deletions

View File

@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
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';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
@@ -511,7 +511,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '')}
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
${_sectionContent('perf', renderPerfSection())}
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;

View File

@@ -0,0 +1,375 @@
/**
* HA Light Targets — editor, cards, CRUD for Home Assistant light output targets.
*/
import { _cachedHASources, haSourcesCache, colorStripSourcesCache, outputTargetsCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getColorStripIcon } from '../core/icons.ts';
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Modal ──
let _haLightTagsInput: TagInput | null = null;
let _haSourceEntitySelect: EntitySelect | null = null;
let _cssSourceEntitySelect: EntitySelect | null = null;
let _editorCssSources: any[] = [];
class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); }
onForceClose() {
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
}
snapshotValues() {
return {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value,
transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value,
mappings: _getMappingsJSON(),
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
};
}
}
const haLightEditorModal = new HALightEditorModal();
function _getMappingsJSON(): string {
const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row');
const mappings: any[] = [];
rows.forEach(row => {
mappings.push({
entity_id: (row.querySelector('.ha-mapping-entity') as HTMLInputElement).value.trim(),
led_start: parseInt((row.querySelector('.ha-mapping-led-start') as HTMLInputElement).value) || 0,
led_end: parseInt((row.querySelector('.ha-mapping-led-end') as HTMLInputElement).value) || -1,
brightness_scale: parseFloat((row.querySelector('.ha-mapping-brightness') as HTMLInputElement).value) || 1.0,
});
});
return JSON.stringify(mappings);
}
// ── Mapping rows ──
export function addHALightMapping(data: any = null): void {
const list = document.getElementById('ha-light-mappings-list');
if (!list) return;
const row = document.createElement('div');
row.className = 'ha-light-mapping-row condition-fields';
row.innerHTML = `
<div class="condition-field">
<label>${t('ha_light.mapping.entity_id')}</label>
<input type="text" class="ha-mapping-entity" value="${escapeHtml(data?.entity_id || '')}" placeholder="light.living_room">
</div>
<div class="condition-field" style="display:flex; gap:0.5rem;">
<div style="flex:1">
<label>${t('ha_light.mapping.led_start')}</label>
<input type="number" class="ha-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
</div>
<div style="flex:1">
<label>${t('ha_light.mapping.led_end')}</label>
<input type="number" class="ha-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
</div>
<div style="flex:1">
<label>${t('ha_light.mapping.brightness')}</label>
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
</div>
</div>
<button type="button" class="btn btn-sm btn-danger ha-mapping-remove" onclick="this.closest('.ha-light-mapping-row').remove()">&times;</button>
`;
list.appendChild(row);
}
// ── Show / Close ──
export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> {
// Load data for dropdowns
const [haSources, cssSources] = await Promise.all([
haSourcesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
]);
_editorCssSources = cssSources;
const isEdit = !!targetId;
const isClone = !!cloneData;
const titleKey = isEdit ? 'ha_light.edit' : 'ha_light.add';
document.getElementById('ha-light-editor-title')!.innerHTML = `${ICON_HA} ${t(titleKey)}`;
(document.getElementById('ha-light-editor-id') as HTMLInputElement).value = '';
(document.getElementById('ha-light-editor-error') as HTMLElement).style.display = 'none';
// Populate HA source dropdown
const haSelect = document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement;
haSelect.innerHTML = haSources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// Populate CSS source dropdown
const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement;
cssSelect.innerHTML = `<option value="">—</option>` + cssSources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// Clear mappings
document.getElementById('ha-light-mappings-list')!.innerHTML = '';
let editData: any = null;
if (isEdit) {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
editData = await resp.json();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
return;
}
} else if (isClone) {
editData = cloneData;
}
if (editData) {
if (isEdit) (document.getElementById('ha-light-editor-id') as HTMLInputElement).value = editData.id;
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
haSelect.value = editData.ha_source_id || '';
cssSelect.value = editData.color_strip_source_id || '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = String(editData.update_rate ?? 2.0);
document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1);
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5);
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
// Load mappings
const mappings = editData.ha_light_mappings || [];
mappings.forEach((m: any) => addHALightMapping(m));
} else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0';
document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0';
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
// Add one empty mapping by default
addHALightMapping();
}
// EntitySelects
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
_haSourceEntitySelect = new EntitySelect({
target: haSelect,
getItems: () => haSources.map((s: any) => ({
value: s.id, label: s.name, icon: ICON_HA,
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
})),
placeholder: t('palette.search'),
});
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
_cssSourceEntitySelect = new EntitySelect({
target: cssSelect,
getItems: () => _editorCssSources.map((s: any) => ({
value: s.id, label: s.name, icon: getColorStripIcon(s.source_type), desc: s.source_type,
})),
placeholder: t('palette.search'),
});
// Tags
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
_haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') });
_haLightTagsInput.setValue(editData?.tags || []);
haLightEditorModal.open();
haLightEditorModal.snapshot();
}
export async function closeHALightEditor(): Promise<void> {
await haLightEditorModal.close();
}
// ── Save ──
export async function saveHALightEditor(): Promise<void> {
const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value;
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0;
const transition = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value) || 0.5;
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
if (!name) {
haLightEditorModal.showError(t('ha_light.error.name_required'));
return;
}
if (!haSourceId) {
haLightEditorModal.showError(t('ha_light.error.ha_source_required'));
return;
}
// Collect mappings
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id);
const payload: any = {
name,
ha_source_id: haSourceId,
color_strip_source_id: cssSourceId,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
try {
let response;
if (targetId) {
response = await fetchWithAuth(`/output-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'ha_light';
response = await fetchWithAuth('/output-targets', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${response.status}`);
}
showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success');
outputTargetsCache.invalidate();
haLightEditorModal.forceClose();
// Reload targets tab
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) {
if (e.isAuth) return;
haLightEditorModal.showError(e.message);
}
}
// ── Edit / Clone / Delete ──
export async function editHALightTarget(targetId: string): Promise<void> {
await showHALightEditor(targetId);
}
export async function cloneHALightTarget(targetId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showHALightEditor(null, data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Card rendering ──
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}): string {
const haSource = haSourceMap[target.ha_source_id];
const cssSource = cssSourceMap[target.color_strip_source_id];
const haName = haSource ? escapeHtml(haSource.name) : target.ha_source_id || '—';
const cssName = cssSource ? escapeHtml(cssSource.name) : target.color_strip_source_id || '—';
const mappingCount = target.ha_light_mappings?.length || 0;
const isRunning = target.state?.processing;
return wrapCard({
type: 'card',
dataAttr: 'data-ha-target-id',
id: target.id,
removeOnclick: `deleteTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<span class="card-title-text">${ICON_HA} ${escapeHtml(target.name)}</span>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop">${_icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop">${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</span>
</div>
${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon ${isRunning ? 'btn-danger' : 'btn-primary'}" data-action="${isRunning ? 'stop' : 'start'}" title="${isRunning ? t('targets.stop') : t('targets.start')}">
${isRunning ? ICON_STOP : ICON_START}
</button>
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Event delegation ──
const _haLightActions: Record<string, (id: string) => void> = {
start: (id) => _startStop(id, 'start'),
stop: (id) => _startStop(id, 'stop'),
clone: cloneHALightTarget,
edit: editHALightTarget,
};
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
outputTargetsCache.invalidate();
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export function initHALightTargetDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="ha-light-targets"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[data-ha-target-id]');
if (!card) return;
const action = btn.dataset.action;
const id = card.getAttribute('data-ha-target-id');
if (!action || !id) return;
const handler = _haLightActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ── Expose to global scope ──
window.showHALightEditor = showHALightEditor;
window.closeHALightEditor = closeHALightEditor;
window.saveHALightEditor = saveHALightEditor;
window.editHALightTarget = editHALightTarget;
window.cloneHALightTarget = cloneHALightTarget;
window.addHALightMapping = addHALightMapping;

View File

@@ -1,5 +1,6 @@
/**
* Performance charts — real-time CPU, RAM, GPU usage with Chart.js.
* Supports system-wide and app-level (process) metrics with a toggle.
* History is seeded from the server-side ring buffer on init.
*/
@@ -14,11 +15,16 @@ import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'
const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu'];
const PERF_MODE_KEY = 'perfMetricsMode';
type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _charts: Record<string, any> = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch
let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both';
function _getColor(key: string): string {
return localStorage.getItem(`perfChartColor_${key}`)
@@ -26,6 +32,12 @@ function _getColor(key: string): string {
|| '#4CAF50';
}
function _getAppColor(key: string): string {
const base = _getColor(key);
// Use a lighter/shifted version for the app line
return base + '99'; // 60% opacity hex suffix
}
function _onChartColorChange(key: string, hex: string | null): void {
if (hex) {
localStorage.setItem(`perfChartColor_${key}`, hex);
@@ -41,10 +53,43 @@ function _onChartColorChange(key: string, hex: string | null): void {
if (chart) {
chart.data.datasets[0].borderColor = hex;
chart.data.datasets[0].backgroundColor = hex + '26';
chart.data.datasets[1].borderColor = hex + '99';
chart.data.datasets[1].backgroundColor = hex + '14';
chart.update();
}
}
/** Build the 3-way toggle HTML for perf section header. */
export function renderPerfModeToggle(): string {
return `<span class="perf-mode-toggle" onclick="event.stopPropagation()">
<button class="perf-mode-btn${_mode === 'system' ? ' active' : ''}" data-perf-mode="system" onclick="setPerfMode('system')" title="${t('dashboard.perf.mode.system')}">${t('dashboard.perf.mode.system')}</button>
<button class="perf-mode-btn${_mode === 'app' ? ' active' : ''}" data-perf-mode="app" onclick="setPerfMode('app')" title="${t('dashboard.perf.mode.app')}">${t('dashboard.perf.mode.app')}</button>
<button class="perf-mode-btn${_mode === 'both' ? ' active' : ''}" data-perf-mode="both" onclick="setPerfMode('both')" title="${t('dashboard.perf.mode.both')}">${t('dashboard.perf.mode.both')}</button>
</span>`;
}
/** Change the perf metrics display mode. */
export function setPerfMode(mode: PerfMode): void {
_mode = mode;
localStorage.setItem(PERF_MODE_KEY, mode);
// Update toggle button active states
document.querySelectorAll('.perf-mode-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode);
});
// Update dataset visibility on all charts
for (const key of CHART_KEYS) {
const chart = _charts[key];
if (!chart) continue;
const showSystem = mode === 'system' || mode === 'both';
const showApp = mode === 'app' || mode === 'both';
chart.data.datasets[0].hidden = !showSystem;
chart.data.datasets[1].hidden = !showApp;
chart.update('none');
}
}
/** Returns the static HTML for the perf section (canvas placeholders). */
export function renderPerfSection(): string {
// Register callbacks before rendering
@@ -81,19 +126,37 @@ function _createChart(canvasId: string, key: string): any {
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!ctx) return null;
const color = _getColor(key);
const showSystem = _mode === 'system' || _mode === 'both';
const showApp = _mode === 'app' || _mode === 'both';
return new Chart(ctx, {
type: 'line',
data: {
labels: Array(MAX_SAMPLES).fill(''),
datasets: [{
data: [],
borderColor: color,
backgroundColor: color + '26',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
}],
datasets: [
{
// System-wide dataset
data: [],
borderColor: color,
backgroundColor: color + '26',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
hidden: !showSystem,
},
{
// App-level dataset (dashed line)
data: [],
borderColor: color + '99',
backgroundColor: color + '14',
borderWidth: 1.5,
borderDash: [4, 3],
tension: 0.3,
fill: true,
pointRadius: 0,
hidden: !showApp,
},
],
},
options: {
responsive: true,
@@ -115,9 +178,12 @@ async function _seedFromServer(): Promise<void> {
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);
_history.gpu = samples.map(s => s.gpu_util).filter(v => v != null);
_history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null);
_history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null);
_history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null);
_appHistory.cpu = samples.map((s: any) => s.app_cpu).filter((v: any) => v != null);
_appHistory.ram = samples.map((s: any) => s.app_ram).filter((v: any) => v != null);
_appHistory.gpu = samples.map((s: any) => s.app_gpu_mem).filter((v: any) => v != null);
// Detect GPU availability from history
if (_history.gpu.length > 0) {
@@ -125,11 +191,20 @@ async function _seedFromServer(): Promise<void> {
}
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(() => '');
_charts[key].update();
const chart = _charts[key];
if (!chart) continue;
// System dataset
if (_history[key].length > 0) {
chart.data.datasets[0].data = [..._history[key]];
}
// App dataset
if (_appHistory[key].length > 0) {
chart.data.datasets[1].data = [..._appHistory[key]];
}
// Align labels to the longer dataset
const maxLen = Math.max(chart.data.datasets[0].data.length, chart.data.datasets[1].data.length);
chart.data.labels = Array(maxLen).fill('');
chart.update();
}
} catch {
// Silently ignore — charts will fill from polling
@@ -151,50 +226,99 @@ function _destroyCharts(): void {
}
}
function _pushSample(key: string, value: number): void {
_history[key].push(value);
function _pushSample(key: string, sysValue: number, appValue: number | null): void {
// System history
_history[key].push(sysValue);
if (_history[key].length > MAX_SAMPLES) _history[key].shift();
// App history
if (appValue != null) {
_appHistory[key].push(appValue);
if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift();
}
const chart = _charts[key];
if (!chart) return;
const ds = chart.data.datasets[0].data;
ds.length = 0;
ds.push(..._history[key]);
// Ensure labels array matches length (reuse existing array)
while (chart.data.labels.length < ds.length) chart.data.labels.push('');
chart.data.labels.length = ds.length;
// Update system dataset
const sysDs = chart.data.datasets[0].data;
sysDs.length = 0;
sysDs.push(..._history[key]);
// Update app dataset
const appDs = chart.data.datasets[1].data;
appDs.length = 0;
appDs.push(..._appHistory[key]);
// Ensure labels array matches the longer dataset
const maxLen = Math.max(sysDs.length, appDs.length);
while (chart.data.labels.length < maxLen) chart.data.labels.push('');
chart.data.labels.length = maxLen;
chart.update('none');
}
/** Format the value display based on mode. */
function _formatValue(sysVal: string, appVal: string | null): string {
if (_mode === 'system') return sysVal;
if (_mode === 'app') return appVal ?? '-';
// 'both': show both
if (appVal != null) return `${sysVal} / ${appVal}`;
return sysVal;
}
async function _fetchPerformance(): Promise<void> {
try {
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
if (!resp.ok) return;
const data = await resp.json();
// CPU
_pushSample('cpu', data.cpu_percent);
// CPU — app_cpu_percent is in the same scale as cpu_percent (per-core %)
_pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
const cpuEl = document.getElementById('perf-cpu-value');
if (cpuEl) cpuEl.textContent = `${data.cpu_percent.toFixed(0)}%`;
if (cpuEl) {
cpuEl.textContent = _formatValue(
`${data.cpu_percent.toFixed(0)}%`,
`${data.app_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);
// RAM — convert app_ram_mb to percent of total for consistent chart scale
const appRamPct = data.ram_total_mb > 0
? (data.app_ram_mb / data.ram_total_mb) * 100
: 0;
_pushSample('ram', data.ram_percent, appRamPct);
const ramEl = document.getElementById('perf-ram-value');
if (ramEl) {
const usedGb = (data.ram_used_mb / 1024).toFixed(1);
const totalGb = (data.ram_total_mb / 1024).toFixed(1);
ramEl.textContent = `${usedGb}/${totalGb} GB`;
const appMb = data.app_ram_mb.toFixed(0);
ramEl.textContent = _formatValue(
`${usedGb}/${totalGb} GB`,
`${appMb} MB`
);
}
// GPU
if (data.gpu) {
_hasGpu = true;
_pushSample('gpu', data.gpu.utilization);
// GPU utilization is system-wide only (no per-process util from NVML)
// For app, show memory percentage if available
const appGpuPct = (data.gpu.app_memory_mb != null && data.gpu.memory_total_mb)
? (data.gpu.app_memory_mb / data.gpu.memory_total_mb) * 100
: null;
_pushSample('gpu', data.gpu.utilization, appGpuPct);
const gpuEl = document.getElementById('perf-gpu-value');
if (gpuEl) gpuEl.textContent = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
if (gpuEl) {
const sysText = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
const appText = data.gpu.app_memory_mb != null
? `${data.gpu.app_memory_mb.toFixed(0)} MB VRAM`
: null;
gpuEl.textContent = _formatValue(sysText, appText);
}
if (data.gpu.name) {
const nameEl = document.getElementById('perf-gpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;

View File

@@ -11,6 +11,7 @@ import {
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
_cachedHASources, haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -19,6 +20,7 @@ import { Modal } from '../core/modal.ts';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
import { _splitOpenrgbZone } from './device-discovery.ts';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
@@ -104,6 +106,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
] });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates },
] });
@@ -605,6 +608,7 @@ export async function loadTargetsTab() {
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
syncClocksCache.fetch().catch((): any[] => []),
haSourcesCache.fetch().catch((): any[] => []),
]);
const colorStripSourceMap = {};
@@ -658,6 +662,7 @@ export async function loadTargetsTab() {
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
// Update tab badge with running target count
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
@@ -680,10 +685,16 @@ export async function loadTargetsTab() {
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
]
},
{
key: 'ha_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'ha_light.section.title',
children: [
{ key: 'ha-light-targets', titleKey: 'ha_light.section.targets', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: haLightTargets.length },
]
}
];
// Determine which tree leaf is active — migrate old values
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns'];
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
@@ -694,6 +705,9 @@ export async function loadTargetsTab() {
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const haSourceMap: Record<string, any> = {};
_cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) })));
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
@@ -706,11 +720,13 @@ export async function loadTargetsTab() {
'led-targets': ledTargets.length,
'kc-targets': kcTargets.length,
'kc-patterns': patternTemplates.length,
'ha-light-targets': haLightTargets.length,
});
csDevices.reconcile(deviceItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems);
csHALightTargets.reconcile(haLightTargetItems);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]),
...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]);
@@ -727,9 +743,11 @@ export async function loadTargetsTab() {
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
{ key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
container.innerHTML = panels;
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]);
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]);
initHALightTargetDelegation(container);
// Render tree sidebar with expand/collapse buttons
_targetsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);