feat(ui): migrate Targets cards to mod-card system

LED targets and HA Light targets adopt the dashboard's instrument-
readout vocabulary (mod-head, mod-leds, mod-metrics, mod-foot,
mod-patch, mod-btn, kebab menu) — same classes and tokens already
used by Dashboard and device cards.

mod-card.ts
- ModBtnOpts.dataAttrs for arbitrary data-attrs (used by the LED
  preview toggle's data-led-preview-btn binding)
- ModBodyOpts.extraHtml escape-hatch for live-update widgets that
  don't fit the predefined slots (FPS sparkline canvas, entity
  swatch grid, collapsible pipeline metrics)

LED target card (targets.ts)
- Badge "LED · TGT" pairs with device "WLED · OUT"-style badges
- Meta row: device link → protocol badge → fps → pixel count
- LED bezel: 1-3 dots reflecting checking / streaming / online /
  offline / unreachable
- Headline metrics on running cards (FPS / ERR / UPTIME) preserve
  data-tm selectors so _patchTargetMetrics still patches in place
- Chips for CSS source link, brightness/value-source, threshold
- Patch indicator: STREAMING / UNREACHABLE / STANDBY / OFFLINE /
  CHECKING
- Foot: START/STOP go/stop variant + LED preview + Edit
- Kebab menu: Duplicate / Hide / Delete (replaces top-right trash)
- FPS sparkline + collapsible pipeline preserved via extraHtml
- Tag chips and LED preview panel appended after wrap (mirrors
  devices.ts pattern)

HA Light target card (ha-light-targets.ts)
- Badge "HA · LIGHT"
- Meta: HA source link → light count → update rate
- LEDs: blink running, fault when ha_connected === false, off idle
- Running metrics: RATE / UPTIME / HA status
- Patch: STREAMING / DISCONNECTED / STANDBY / NOT CONFIGURED
- Buttons keep [data-action] for initHALightTargetDelegation
- Live entity color swatches preserved via extraHtml

Misc
- Chip border-radius dropped from 999px (pill) to var(--lux-r-sm,
  3px) — sharp corners match badges/metrics/buttons elsewhere
- _patchTargetMetrics FPS readout uses <small> for the target
  fraction instead of the legacy target-fps-target span
This commit is contained in:
2026-04-27 01:33:13 +03:00
parent de13f44f24
commit 233b463ac3
4 changed files with 386 additions and 157 deletions
+1 -1
View File
@@ -2160,7 +2160,7 @@ ul.section-tip li {
background: transparent; background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 3px 9px; padding: 3px 9px;
border-radius: 999px; border-radius: var(--lux-r-sm, 3px);
cursor: default; cursor: default;
transition: color 0.15s, border-color 0.15s, background 0.15s; transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap; white-space: nowrap;
+13 -1
View File
@@ -109,6 +109,8 @@ export interface ModBtnOpts {
iconOnly?: boolean; iconOnly?: boolean;
/** i18n keys for runtime translation */ /** i18n keys for runtime translation */
i18nTitle?: string; i18nTitle?: string;
/** Extra data attributes (e.g. for live-update binding) */
dataAttrs?: Record<string, string>;
} }
export interface ModMenuItemOpts { export interface ModMenuItemOpts {
@@ -170,6 +172,12 @@ export interface ModBodyOpts {
preview?: string; preview?: string;
/** Free-form description text shown below the head */ /** Free-form description text shown below the head */
desc?: string; desc?: string;
/** Free-form raw HTML appended at the end of the body — escape-hatch
* for live-updating widgets (sparkline canvases, swatch grids,
* collapsible detail blocks) that don't fit the predefined slots.
* Caller is responsible for HTML-escaping any user-controlled
* substrings. */
extraHtml?: string;
} }
export interface ModCardOpts { export interface ModCardOpts {
@@ -319,7 +327,10 @@ function _btnHtml(b: ModBtnOpts): string {
const titleAttr = b.title ? ` title="${escapeHtml(b.title)}"` : ''; const titleAttr = b.title ? ` title="${escapeHtml(b.title)}"` : '';
const i18nAttr = b.i18nTitle ? ` data-i18n-title="${b.i18nTitle}"` : ''; const i18nAttr = b.i18nTitle ? ` data-i18n-title="${b.i18nTitle}"` : '';
const labelHtml = b.label ? ` <span>${escapeHtml(b.label)}</span>` : ''; const labelHtml = b.label ? ` <span>${escapeHtml(b.label)}</span>` : '';
return `<button type="button" class="mod-btn${variant}${icon}" onclick="${b.onclick}"${titleAttr}${i18nAttr}>${b.icon || ''}${labelHtml}</button>`; const dataAttrs = b.dataAttrs
? ' ' + Object.entries(b.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
return `<button type="button" class="mod-btn${variant}${icon}" onclick="${b.onclick}"${titleAttr}${i18nAttr}${dataAttrs}>${b.icon || ''}${labelHtml}</button>`;
} }
export function renderModFoot(foot: ModFootOpts): string { export function renderModFoot(foot: ModFootOpts): string {
@@ -349,6 +360,7 @@ export function renderModBody(body: ModBodyOpts | undefined): string {
if (body.preview) parts.push(`<div class="mod-preview">${body.preview}</div>`); if (body.preview) parts.push(`<div class="mod-preview">${body.preview}</div>`);
if (body.chips) parts.push(renderModChips(body.chips)); if (body.chips) parts.push(renderModChips(body.chips));
if (body.fader) parts.push(renderModFader(body.fader)); if (body.fader) parts.push(renderModFader(body.fader));
if (body.extraHtml) parts.push(body.extraHtml);
return parts.join(''); return parts.join('');
} }
@@ -8,10 +8,11 @@ import { logError } from '../core/log.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts'; import { ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { openAuthedWs } from '../core/ws-auth.ts'; import { openAuthedWs } from '../core/ws-auth.ts';
import { bindableSourceId, bindableValue } from '../types.ts'; import { bindableSourceId, bindableValue } from '../types.ts';
@@ -504,71 +505,159 @@ export async function cloneHALightTarget(targetId: string): Promise<void> {
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}, valueSourceMap: Record<string, any> = {}): string { export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}, valueSourceMap: Record<string, any> = {}): string {
const haSource = haSourceMap[target.ha_source_id]; const haSource = haSourceMap[target.ha_source_id];
const cssSource = cssSourceMap[target.color_strip_source_id]; const cssSource = cssSourceMap[target.color_strip_source_id];
const haName = haSource ? escapeHtml(haSource.name) : target.ha_source_id || '—';
const cssId = target.color_strip_source_id; const cssId = target.color_strip_source_id;
const cssName = cssSource ? escapeHtml(cssSource.name) : cssId || '—';
const mappingCount = target.ha_light_mappings?.length || 0; const mappingCount = target.ha_light_mappings?.length || 0;
const isRunning = target.state?.processing; const isRunning = !!target.state?.processing;
const state = target.state || {}; const state = target.state || {};
const metrics = target.metrics || {};
// Crosslinks
const haLink = haSource
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${target.ha_source_id}')`
: '';
const cssLink = cssSource
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')`
: '';
// Brightness value source // Brightness value source
const bvsId = bindableSourceId(target.brightness); const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null; const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
return wrapCard({ // ── Badge / meta ──
const badgeText = 'HA · LIGHT';
const haLabel = haSource ? haSource.name : (target.ha_source_id || '—');
const haLink = haSource
? `<a class="mod-meta__link" role="button" tabindex="0" onclick="event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${target.ha_source_id}')" title="HA Connection">${escapeHtml(haLabel)}</a>`
: escapeHtml(haLabel);
const lightsLabel = `${mappingCount} ${mappingCount === 1 ? 'light' : 'lights'}`;
const rateLabel = `${target.update_rate ?? 2.0} Hz`;
const metaHtml = [haLink, lightsLabel, rateLabel].join(' · ');
// ── LEDs ──
const leds: LedState[] = isRunning
? (state.ha_connected === false ? ['fault'] : ['on', 'blink'])
: ['off'];
// ── Chips ──
const chips: ModChipOpts[] = [];
if (cssSource) {
chips.push({
icon: getColorStripIcon(cssSource.source_type),
text: cssSource.name,
title: t('targets.color_strip_source'),
onclick: `event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')`,
});
} else if (cssId) {
chips.push({ icon: _icon(P.palette), text: cssId, title: t('targets.color_strip_source') });
}
if (bvs) {
chips.push({
icon: getValueSourceIcon(bvs.source_type),
text: bvs.name,
title: t('targets.brightness_vs'),
onclick: `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')`,
});
} else if (bindableValue(target.brightness, 1.0) < 1.0) {
chips.push({
icon: ICON_SUN,
text: `${Math.round(bindableValue(target.brightness, 1.0) * 100)}%`,
title: t('targets.brightness'),
});
}
// ── Metrics (running only) ──
const metrics: ModMetricOpts[] = [];
if (isRunning) {
metrics.push({
k: t('targets.fps') || 'RATE',
icon: ICON_FPS,
v: '<span data-tm="fps">---</span>',
accent: true,
title: t('targets.fps'),
});
metrics.push({
k: t('device.metrics.uptime') || 'UPTIME',
v: '<span data-tm="uptime">---</span>',
title: t('device.metrics.uptime'),
});
metrics.push({
k: 'HA',
v: '<span data-tm="ha-status">---</span>',
title: 'Home Assistant connection',
});
}
// ── Live entity color swatches (running) ──
const extraHtml = isRunning
? `<div class="ha-light-swatches" data-ha-swatches="${target.id}"></div>`
: '';
// ── Patch indicator ──
const patchState: 'live' | 'standby' | 'offline' | 'idle' =
isRunning ? (state.ha_connected === false ? 'offline' : 'live') :
haSource ? 'standby' : 'idle';
const patchLabel = isRunning
? (state.ha_connected === false ? 'DISCONNECTED' : 'STREAMING')
: haSource
? 'STANDBY'
: 'NOT CONFIGURED';
// ── Foot actions — uses event delegation via [data-action] ──
const primaryAction: ModBtnOpts = isRunning
? {
label: 'STOP',
icon: ICON_STOP,
onclick: 'void 0',
title: t('targets.stop'),
variant: 'stop',
dataAttrs: { 'data-action': 'stop' },
}
: {
label: 'START',
icon: ICON_START,
onclick: 'void 0',
title: t('targets.start'),
variant: 'go',
dataAttrs: { 'data-action': 'start' },
};
const iconActions: ModBtnOpts[] = [
{
icon: ICON_EDIT,
onclick: 'void 0',
title: t('common.edit'),
dataAttrs: { 'data-action': 'edit' },
},
];
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: target.name,
metaHtml,
leds,
menu: {
duplicateOnclick: `cloneHALightTarget('${target.id}')`,
hideOnclick: `toggleCardHidden('ha-light-targets','${target.id}')`,
deleteOnclick: `deleteTarget('${target.id}')`,
},
},
body: {
desc: target.description || undefined,
metrics: metrics.length ? metrics : undefined,
chips: chips.length ? chips : undefined,
extraHtml: extraHtml || undefined,
},
foot: {
patchState,
patchLabel,
primaryAction,
iconActions,
},
running: isRunning,
};
const cardHtml = wrapCard({
type: 'card', type: 'card',
dataAttr: 'data-ha-target-id', dataAttr: 'data-ha-target-id',
id: target.id, id: target.id,
removeOnclick: `deleteTarget('${target.id}')`, mod,
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${haLink}" title="HA Connection">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop${cssLink}" title="${t('targets.color_strip_source')}">${cssSource ? getColorStripIcon(cssSource.source_type) : _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>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
</div>
${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}
<div class="card-content">
${isRunning ? `
<div class="metrics-grid target-metrics-expanded">
<div class="metric">
<div class="metric-label">${t('targets.fps')}</div>
<div class="metric-value" data-tm="fps">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">---</div>
</div>
<div class="metric">
<div class="metric-label">HA</div>
<div class="metric-value" data-tm="ha-status">---</div>
</div>
</div>
<div class="ha-light-swatches" data-ha-swatches="${target.id}"></div>
` : ''}
</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>`,
}); });
const tags = renderTagChips(target.tags || []);
return tags
? cardHtml.replace(/<\/div>\s*$/, `${tags}</div>`)
: cardHtml;
} }
// ── Metrics patching ── // ── Metrics patching ──
+229 -101
View File
@@ -21,8 +21,8 @@ import { _splitOpenrgbZone } from './device-discovery.ts';
import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics, connectHALightWS, disconnectHALightWS } from './ha-light-targets.ts'; import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics, connectHALightWS, disconnectHALightWS } from './ha-light-targets.ts';
import { import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_FPS, ICON_LED_PREVIEW,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH, ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH,
} from '../core/icons.ts'; } from '../core/icons.ts';
@@ -30,6 +30,7 @@ import { EntitySelect } from '../core/entity-palette.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { createFpsSparkline } from '../core/chart-utils.ts'; import { createFpsSparkline } from '../core/chart-utils.ts';
import { CardSection } from '../core/card-sections.ts'; import { CardSection } from '../core/card-sections.ts';
@@ -919,11 +920,10 @@ function _patchTargetMetrics(target: any) {
const effFps = state.fps_effective; const effFps = state.fps_effective;
const tgtFps = state.fps_target || 0; const tgtFps = state.fps_target || 0;
const fpsLabel = (effFps != null && effFps < tgtFps) const fpsLabel = (effFps != null && effFps < tgtFps)
? `${state.fps_current ?? 0}<span class="target-fps-target">/${effFps}${tgtFps}</span>` ? `${state.fps_current ?? 0}<small>/${effFps}${tgtFps}</small>`
: `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`; : `${state.fps_current ?? 0}<small>/${tgtFps}</small>`;
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : ''; const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>` fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`;
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
} }
// Update health dot to reflect streaming reachability when processing // Update health dot to reflect streaming reachability when processing
@@ -964,118 +964,246 @@ function _patchTargetMetrics(target: any) {
export function createTargetCard(target: LedOutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) { export function createTargetCard(target: LedOutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
const state = target.state || {}; const state = target.state || {};
const tgtMetrics = target.metrics || {};
const isProcessing = state.processing || false; const isProcessing = state.processing || false;
const device = deviceMap[target.device_id!]; const device = deviceMap[target.device_id!];
const deviceName = device ? device.name : (target.device_id || 'No device'); const deviceName = device ? device.name : (target.device_id || t('targets.no_device') || 'No device');
const cssId = target.color_strip_source_id || ''; const cssId = target.color_strip_source_id || '';
const cssSummary = _cssSourceName(cssId, colorStripSourceMap); const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
const cssSource = cssId ? colorStripSourceMap[cssId] : null;
const bvsId = bindableSourceId(target.brightness); const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null; const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
// Determine if overlay is available (picture-based CSS) // Health info from target state (forwarded from device); reflects
const css = cssId ? colorStripSourceMap[cssId] : null; // streaming-reachability when running.
const overlayAvailable = !css || css.source_type === 'picture';
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false; const devOnline = state.device_online || false;
let healthClass = 'health-unknown'; const lastChecked = state.device_last_checked;
let healthTitle = ''; let healthClass: string;
if (state.device_last_checked !== null && state.device_last_checked !== undefined) { let healthTitle: string;
healthClass = devOnline ? 'health-online' : 'health-offline'; if (lastChecked === null || lastChecked === undefined) {
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline'); healthClass = 'health-unknown';
healthTitle = t('device.health.checking') || 'Checking';
} else if (isProcessing && state.device_streaming_reachable === false) {
healthClass = 'health-offline';
healthTitle = t('device.health.streaming_unreachable') || 'Unreachable during streaming';
} else if (devOnline) {
healthClass = 'health-online';
healthTitle = t('device.health.online');
} else {
healthClass = 'health-offline';
healthTitle = t('device.health.offline');
}
const healthDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status" aria-label="${escapeHtml(healthTitle)}"></span>`
+ `<span class="target-error-indicator" title="${escapeHtml(t('device.metrics.errors'))}">${ICON_WARNING}</span>`;
// ── LED bezel ──
let leds: LedState[];
if (lastChecked === null || lastChecked === undefined) {
leds = ['off'];
} else if (isProcessing) {
leds = state.device_streaming_reachable === false
? ['fault', 'on', 'on']
: ['on', 'blink', 'blink'];
} else if (devOnline) {
leds = ['on'];
} else {
leds = ['fault'];
} }
return wrapCard({ // ── Badge text — pairs with the dashboard's device badge ("WLED ·
// OUT", "OPENRGB · OUT") so the target reads as a downstream
// instrument routed through the device. ──
const badgeText = 'LED · TGT';
// ── Meta line: device link · protocol · target FPS · pixel count.
// Protocol carries device-type-specific richness (OpenRGB SDK,
// Adalight serial, etc.) — _protocolBadge() returns icon + label. ──
const deviceLink = target.device_id
? `<a class="mod-meta__link" role="button" tabindex="0" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')" title="${escapeHtml(t('targets.device'))}">${escapeHtml(deviceName)}</a>`
: escapeHtml(deviceName);
const targetFps = bindableValue(target.fps, 30);
const metaParts: string[] = [deviceLink, _protocolBadge(device, target), `${targetFps} fps`];
const ledCount = device?.state?.device_led_count || device?.led_count;
if (ledCount) metaParts.push(`${ledCount} px`);
const metaHtml = metaParts.join(' · ');
// ── Chips: CSS source link, brightness override, threshold ──
const chips: ModChipOpts[] = [];
if (cssSource) {
chips.push({
icon: ICON_FILM,
text: cssSource.name,
title: t('targets.color_strip_source'),
onclick: `event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')`,
});
} else if (cssId) {
chips.push({ icon: ICON_FILM, text: cssSummary, title: t('targets.color_strip_source') });
} else {
chips.push({ icon: ICON_FILM, text: t('targets.no_css'), title: t('targets.color_strip_source') });
}
if (bvs) {
chips.push({
icon: getValueSourceIcon(bvs.source_type),
text: bvs.name,
title: t('targets.brightness_vs'),
onclick: `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')`,
});
} else if (bindableValue(target.brightness, 1.0) < 1.0) {
chips.push({
icon: ICON_SUN,
text: `${Math.round(bindableValue(target.brightness, 1.0) * 100)}%`,
title: t('targets.brightness'),
});
}
if (bindableValue(target.min_brightness_threshold, 0) > 0) {
chips.push({
icon: ICON_SUN_DIM,
text: `<${bindableValue(target.min_brightness_threshold, 0)} → off`,
title: t('targets.min_brightness_threshold'),
});
}
// ── Headline metric strip — shown when running so live FPS / errors
// pop in the instrument-readout style. The full pipeline detail
// sits below in the collapsible block. ──
const metrics: ModMetricOpts[] = [];
if (isProcessing) {
metrics.push({
k: t('targets.fps') || 'FPS',
icon: ICON_FPS,
v: '<span data-tm="fps">---</span>',
accent: true,
title: t('targets.fps'),
});
const errCount = tgtMetrics.errors_count || 0;
metrics.push({
k: t('device.metrics.errors') || 'ERR',
v: '<span data-tm="errors">---</span>',
error: errCount > 0,
icon: errCount > 0 ? ICON_WARNING : '',
title: t('device.metrics.errors'),
});
metrics.push({
k: t('device.metrics.uptime') || 'UPTIME',
v: '<span data-tm="uptime">---</span>',
title: t('device.metrics.uptime'),
});
}
// ── Live-update raw HTML: FPS sparkline + collapsible pipeline ──
const extraHtml = isProcessing ? `
<div class="target-fps-row">
<div class="target-fps-sparkline">
<canvas id="target-fps-${target.id}" data-fps-target="${state.fps_target || 30}"></canvas>
</div>
</div>
<div class="target-metrics-collapse">
<button type="button" class="target-metrics-toggle" onclick="this.parentElement.classList.toggle('open')">${escapeHtml(t('targets.metrics.pipeline'))}</button>
<div class="target-metrics-animate">
<div class="metrics-grid target-metrics-expanded">
<div class="timing-breakdown" data-tm="timing" style="grid-column:1/-1"></div>
<div class="metric">
<div class="metric-label">${escapeHtml(t('device.metrics.frames'))}</div>
<div class="metric-value" data-tm="frames">---</div>
</div>
${state.needs_keepalive !== false ? `
<div class="metric">
<div class="metric-label">${escapeHtml(t('device.metrics.keepalive'))}</div>
<div class="metric-value" data-tm="keepalive">---</div>
</div>` : ''}
</div>
</div>
</div>` : '';
// ── Patch indicator state ──
const patchState: 'live' | 'standby' | 'offline' | 'idle' =
lastChecked == null ? 'idle' :
isProcessing ? (state.device_streaming_reachable === false ? 'offline' : 'live') :
devOnline ? 'standby' : 'offline';
const patchLabel = lastChecked == null
? (t('device.health.checking') || 'Checking').toUpperCase()
: isProcessing
? (state.device_streaming_reachable === false ? 'UNREACHABLE' : 'STREAMING')
: devOnline
? 'STANDBY'
: (t('device.health.offline') || 'Offline').toUpperCase();
// ── Foot actions ──
const primaryAction: ModBtnOpts = isProcessing
? {
label: 'STOP',
icon: ICON_STOP,
onclick: `stopTargetProcessing('${target.id}')`,
title: t('device.button.stop'),
variant: 'stop',
}
: {
label: 'START',
icon: ICON_START,
onclick: `startTargetProcessing('${target.id}')`,
title: t('device.button.start'),
variant: 'go',
};
const iconActions: ModBtnOpts[] = [];
if (isProcessing) {
iconActions.push({
icon: ICON_LED_PREVIEW,
onclick: `toggleLedPreview('${target.id}')`,
title: 'LED Preview',
dataAttrs: { 'data-led-preview-btn': target.id },
});
}
iconActions.push({
icon: ICON_EDIT,
onclick: `showTargetEditor('${target.id}')`,
title: t('common.edit'),
});
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: target.name,
metaHtml,
healthDot,
leds,
menu: {
duplicateOnclick: `cloneTarget('${target.id}')`,
hideOnclick: `toggleCardHidden('led-targets','${target.id}')`,
deleteOnclick: `deleteTarget('${target.id}')`,
},
},
body: {
metrics: metrics.length ? metrics : undefined,
chips: chips.length ? chips : undefined,
extraHtml: extraHtml || undefined,
},
foot: {
patchState,
patchLabel,
primaryAction,
iconActions,
},
running: isProcessing,
};
const cardHtml = wrapCard({
dataAttr: 'data-target-id', dataAttr: 'data-target-id',
id: target.id, id: target.id,
classes: isProcessing ? 'card-running' : '', mod,
removeOnclick: `deleteTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(target.name)}">
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
<span class="card-title-text">${escapeHtml(target.name)}</span>
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${bindableValue(target.fps, 30)}</span>
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
${bindableValue(target.min_brightness_threshold, 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${bindableValue(target.min_brightness_threshold, 0)} → off</span>` : ''}
</div>
${renderTagChips(target.tags)}
<div class="card-content">
${isProcessing ? `
<div class="metrics-grid">
<div class="target-fps-row">
<div class="target-fps-sparkline">
<canvas id="target-fps-${target.id}" data-fps-target="${state.fps_target || 30}"></canvas>
</div>
<div class="target-fps-label">
<span class="metric-value" data-tm="fps">---</span>
</div>
</div>
</div>
<div class="target-metrics-collapse">
<button type="button" class="target-metrics-toggle" onclick="this.parentElement.classList.toggle('open')">${t('targets.metrics.pipeline')}</button>
<div class="target-metrics-animate">
<div class="metrics-grid target-metrics-expanded">
<div class="timing-breakdown" data-tm="timing" style="grid-column:1/-1"></div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value" data-tm="frames">---</div>
</div>
${state.needs_keepalive !== false ? `
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value" data-tm="keepalive">---</div>
</div>
` : ''}
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value" data-tm="errors">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">---</div>
</div>
</div>
</div>
</div>
` : ''}
</div>
${_buildLedPreviewHtml(target.id, device, bvsId, css, colorStripSourceMap)}`,
actions: `
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
${ICON_STOP}
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
${ICON_START}
</button>
`}
${isProcessing ? `
<button class="btn btn-icon btn-secondary" data-led-preview-btn="${target.id}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
${ICON_LED_PREVIEW}
</button>
` : ''}
<button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
${ICON_EDIT}
</button>
`,
}); });
// Append tag chips and the LED preview panel (keeps existing
// _patchTargetMetrics / preview WebSocket selectors intact)
const tags = renderTagChips(target.tags || []);
const ledPanel = _buildLedPreviewHtml(target.id, device, bvsId, cssSource, colorStripSourceMap);
const trailing = `${tags}${ledPanel}`;
return trailing
? cardHtml.replace(/<\/div>\s*$/, `${trailing}</div>`)
: cardHtml;
} }
async function _targetAction(action: any) { async function _targetAction(action: any) {