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:
@@ -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;
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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 ? ` <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 {
|
||||
@@ -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.chips) parts.push(renderModChips(body.chips));
|
||||
if (body.fader) parts.push(renderModFader(body.fader));
|
||||
if (body.extraHtml) parts.push(body.extraHtml);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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 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
|
||||
? `<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',
|
||||
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${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>`,
|
||||
mod,
|
||||
});
|
||||
const tags = renderTagChips(target.tags || []);
|
||||
return tags
|
||||
? cardHtml.replace(/<\/div>\s*$/, `${tags}</div>`)
|
||||
: cardHtml;
|
||||
}
|
||||
|
||||
// ── Metrics patching ──
|
||||
|
||||
@@ -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}<span class="target-fps-target">/${effFps}↓${tgtFps}</span>`
|
||||
: `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`;
|
||||
? `${state.fps_current ?? 0}<small>/${effFps}↓${tgtFps}</small>`
|
||||
: `${state.fps_current ?? 0}<small>/${tgtFps}</small>`;
|
||||
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
|
||||
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`
|
||||
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
|
||||
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`;
|
||||
}
|
||||
|
||||
// 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>) {
|
||||
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 = `<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',
|
||||
id: target.id,
|
||||
classes: isProcessing ? 'card-running' : '',
|
||||
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} <${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>
|
||||
`,
|
||||
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}</div>`)
|
||||
: cardHtml;
|
||||
}
|
||||
|
||||
async function _targetAction(action: any) {
|
||||
|
||||
Reference in New Issue
Block a user