From 233b463ac3c68cbad1a49ab230e91497bff5e0bd Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 27 Apr 2026 01:33:13 +0300 Subject: [PATCH] feat(ui): migrate Targets cards to mod-card system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 for the target fraction instead of the legacy target-fps-target span --- server/src/ledgrab/static/css/cards.css | 2 +- server/src/ledgrab/static/js/core/mod-card.ts | 14 +- .../static/js/features/ha-light-targets.ts | 197 ++++++++--- .../src/ledgrab/static/js/features/targets.ts | 330 ++++++++++++------ 4 files changed, 386 insertions(+), 157 deletions(-) diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 843e644..dbe2c6e 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -2160,7 +2160,7 @@ ul.section-tip li { background: transparent; border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); padding: 3px 9px; - border-radius: 999px; + border-radius: var(--lux-r-sm, 3px); cursor: default; transition: color 0.15s, border-color 0.15s, background 0.15s; white-space: nowrap; diff --git a/server/src/ledgrab/static/js/core/mod-card.ts b/server/src/ledgrab/static/js/core/mod-card.ts index 9844733..82369e0 100644 --- a/server/src/ledgrab/static/js/core/mod-card.ts +++ b/server/src/ledgrab/static/js/core/mod-card.ts @@ -109,6 +109,8 @@ export interface ModBtnOpts { iconOnly?: boolean; /** i18n keys for runtime translation */ i18nTitle?: string; + /** Extra data attributes (e.g. for live-update binding) */ + dataAttrs?: Record; } export interface ModMenuItemOpts { @@ -170,6 +172,12 @@ export interface ModBodyOpts { preview?: string; /** Free-form description text shown below the head */ 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 { @@ -319,7 +327,10 @@ function _btnHtml(b: ModBtnOpts): string { const titleAttr = b.title ? ` title="${escapeHtml(b.title)}"` : ''; const i18nAttr = b.i18nTitle ? ` data-i18n-title="${b.i18nTitle}"` : ''; const labelHtml = b.label ? ` ${escapeHtml(b.label)}` : ''; - return ``; + const dataAttrs = b.dataAttrs + ? ' ' + Object.entries(b.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ') + : ''; + return ``; } export function renderModFoot(foot: ModFootOpts): string { @@ -349,6 +360,7 @@ export function renderModBody(body: ModBodyOpts | undefined): string { if (body.preview) parts.push(`
${body.preview}
`); if (body.chips) parts.push(renderModChips(body.chips)); if (body.fader) parts.push(renderModFader(body.fader)); + if (body.extraHtml) parts.push(body.extraHtml); return parts.join(''); } diff --git a/server/src/ledgrab/static/js/features/ha-light-targets.ts b/server/src/ledgrab/static/js/features/ha-light-targets.ts index a0d815e..26af582 100644 --- a/server/src/ledgrab/static/js/features/ha-light-targets.ts +++ b/server/src/ledgrab/static/js/features/ha-light-targets.ts @@ -8,10 +8,11 @@ import { logError } from '../core/log.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.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 { EntitySelect } from '../core/entity-palette.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 { openAuthedWs } from '../core/ws-auth.ts'; import { bindableSourceId, bindableValue } from '../types.ts'; @@ -504,71 +505,159 @@ export async function cloneHALightTarget(targetId: string): Promise { export function createHALightTargetCard(target: any, haSourceMap: Record = {}, cssSourceMap: Record = {}, valueSourceMap: Record = {}): 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 cssId = target.color_strip_source_id; - const cssName = cssSource ? escapeHtml(cssSource.name) : cssId || '—'; const mappingCount = target.ha_light_mappings?.length || 0; - const isRunning = target.state?.processing; + const isRunning = !!target.state?.processing; 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 const bvsId = bindableSourceId(target.brightness); 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 + ? `${escapeHtml(haLabel)}` + : 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: '---', + accent: true, + title: t('targets.fps'), + }); + metrics.push({ + k: t('device.metrics.uptime') || 'UPTIME', + v: '---', + title: t('device.metrics.uptime'), + }); + metrics.push({ + k: 'HA', + v: '---', + title: 'Home Assistant connection', + }); + } + + // ── Live entity color swatches (running) ── + const extraHtml = isRunning + ? `
` + : ''; + + // ── 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', dataAttr: 'data-ha-target-id', id: target.id, - removeOnclick: `deleteTarget('${target.id}')`, - removeTitle: t('common.delete'), - content: ` -
- ${ICON_HA} ${escapeHtml(target.name)} -
-
- ${ICON_HA} ${haName} - ${cssName !== '—' ? `${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}` : ''} - ${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''} - ${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz - ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : (bindableValue(target.brightness, 1.0) < 1.0 ? `${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%` : '')} -
- ${renderTagChips(target.tags || [])} - ${target.description ? `
${escapeHtml(target.description)}
` : ''} -
- ${isRunning ? ` -
-
-
${t('targets.fps')}
-
---
-
-
-
${t('device.metrics.uptime')}
-
---
-
-
-
HA
-
---
-
-
-
- ` : ''} -
`, - actions: ` - - - `, + mod, }); + const tags = renderTagChips(target.tags || []); + return tags + ? cardHtml.replace(/<\/div>\s*$/, `${tags}`) + : cardHtml; } // ── Metrics patching ── diff --git a/server/src/ledgrab/static/js/features/targets.ts b/server/src/ledgrab/static/js/features/targets.ts index 230d50e..ad2f73d 100644 --- a/server/src/ledgrab/static/js/features/targets.ts +++ b/server/src/ledgrab/static/js/features/targets.ts @@ -21,8 +21,8 @@ import { _splitOpenrgbZone } from './device-discovery.ts'; import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics, connectHALightWS, disconnectHALightWS } from './ha-light-targets.ts'; import { getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, - ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, - ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, + ICON_EDIT, ICON_START, ICON_STOP, + ICON_FPS, ICON_LED_PREVIEW, 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, } from '../core/icons.ts'; @@ -30,6 +30,7 @@ import { EntitySelect } from '../core/entity-palette.ts'; import { IconSelect } from '../core/icon-select.ts'; import * as P from '../core/icon-paths.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 { createFpsSparkline } from '../core/chart-utils.ts'; import { CardSection } from '../core/card-sections.ts'; @@ -919,11 +920,10 @@ function _patchTargetMetrics(target: any) { const effFps = state.fps_effective; const tgtFps = state.fps_target || 0; const fpsLabel = (effFps != null && effFps < tgtFps) - ? `${state.fps_current ?? 0}/${effFps}↓${tgtFps}` - : `${state.fps_current ?? 0}/${tgtFps}`; + ? `${state.fps_current ?? 0}/${effFps}↓${tgtFps}` + : `${state.fps_current ?? 0}/${tgtFps}`; const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : ''; - fps.innerHTML = `${fpsLabel}` - + `avg ${state.fps_actual?.toFixed(1) || '0.0'}`; + fps.innerHTML = `${fpsLabel}`; } // 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, colorStripSourceMap: Record, valueSourceMap: Record) { const state = target.state || {}; + const tgtMetrics = target.metrics || {}; const isProcessing = state.processing || false; 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 cssSummary = _cssSourceName(cssId, colorStripSourceMap); + const cssSource = cssId ? colorStripSourceMap[cssId] : null; const bvsId = bindableSourceId(target.brightness); const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null; - // Determine if overlay is available (picture-based CSS) - const css = cssId ? colorStripSourceMap[cssId] : null; - const overlayAvailable = !css || css.source_type === 'picture'; - - // Health info from target state (forwarded from device) + // Health info from target state (forwarded from device); reflects + // streaming-reachability when running. const devOnline = state.device_online || false; - let healthClass = 'health-unknown'; - let healthTitle = ''; - if (state.device_last_checked !== null && state.device_last_checked !== undefined) { - healthClass = devOnline ? 'health-online' : 'health-offline'; - healthTitle = devOnline ? t('device.health.online') : t('device.health.offline'); + const lastChecked = state.device_last_checked; + let healthClass: string; + let healthTitle: string; + if (lastChecked === null || lastChecked === undefined) { + 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 = `` + + `${ICON_WARNING}`; + + // ── 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 + ? `${escapeHtml(deviceName)}` + : 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: '---', + accent: true, + title: t('targets.fps'), + }); + const errCount = tgtMetrics.errors_count || 0; + metrics.push({ + k: t('device.metrics.errors') || 'ERR', + v: '---', + error: errCount > 0, + icon: errCount > 0 ? ICON_WARNING : '', + title: t('device.metrics.errors'), + }); + metrics.push({ + k: t('device.metrics.uptime') || 'UPTIME', + v: '---', + title: t('device.metrics.uptime'), + }); + } + + // ── Live-update raw HTML: FPS sparkline + collapsible pipeline ── + const extraHtml = isProcessing ? ` +
+
+ +
+
+
+ +
+
+
+
+
${escapeHtml(t('device.metrics.frames'))}
+
---
+
+ ${state.needs_keepalive !== false ? ` +
+
${escapeHtml(t('device.metrics.keepalive'))}
+
---
+
` : ''} +
+
+
` : ''; + + // ── 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', id: target.id, - classes: isProcessing ? 'card-running' : '', - removeOnclick: `deleteTarget('${target.id}')`, - removeTitle: t('common.delete'), - content: ` -
-
- - ${escapeHtml(target.name)} - ${ICON_WARNING} -
-
-
- ${ICON_LED} ${escapeHtml(deviceName)} - ${ICON_FPS} ${bindableValue(target.fps, 30)} - ${_protocolBadge(device, target)} - ${ICON_FILM} ${cssSummary} - ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : (bindableValue(target.brightness, 1.0) < 1.0 ? `${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%` : '')} - ${bindableValue(target.min_brightness_threshold, 0) > 0 ? `${ICON_SUN_DIM} <${bindableValue(target.min_brightness_threshold, 0)} → off` : ''} -
- ${renderTagChips(target.tags)} -
- ${isProcessing ? ` -
-
-
- -
-
- --- -
-
-
-
- -
-
-
-
-
${t('device.metrics.frames')}
-
---
-
- ${state.needs_keepalive !== false ? ` -
-
${t('device.metrics.keepalive')}
-
---
-
- ` : ''} -
-
${t('device.metrics.errors')}
-
---
-
-
-
${t('device.metrics.uptime')}
-
---
-
-
-
-
- ` : ''} -
- ${_buildLedPreviewHtml(target.id, device, bvsId, css, colorStripSourceMap)}`, - actions: ` - ${isProcessing ? ` - - ` : ` - - `} - ${isProcessing ? ` - - ` : ''} - - -`, + mod, }); + // 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}`) + : cardHtml; } async function _targetAction(action: any) {