feat(ui): align Targets metric cells with dashboard pattern

mod-card.ts
- ModMetricOpts.extra: raw HTML appended after the .v cell — used
  to embed a sparkline canvas inside the FPS metric block
- ModMetricOpts.valueDataAttrs: data-attrs on the .v element so
  live-update patchers can target the value directly

LED target card
- FPS sparkline (mod-metric-spark-canvas) is embedded INSIDE the
  FPS cell as a sibling of .v — was a separate target-fps-row
  block before, which floated under the metrics grid
- Label hardcoded to "FPS" (the i18n value "Target FPS:" was
  meant for the editor field, not the readout)
- Uptime cell gets ICON_CLOCK; Errors cell gets ICON_OK / ICON_WARNING
  based on count — matches dashboard cell decorations
- Drops the leading FPS icon (display-font number is the focal
  element; no icon needed)
- _patchTargetMetrics now emits the dashboard FPS shape:
  current<span.dashboard-fps-target>/target</span>
  <span.dashboard-fps-avg>avg N.N</span> — picks up dashboard.css
  styling for free

HA Light target card
- Same icon treatment (Uptime → clock; HA → ok/warning by
  ha_connected); FPS icon dropped

Grid sizing
- .devices-grid bumped from minmax(300px, 1fr) / gap 20px to
  minmax(min(380px, 100%), 1fr) / gap 14px — matches the
  dashboard's section grid so metric values like "1m 43s" stop
  truncating at the typical desktop width
This commit is contained in:
2026-04-27 01:42:26 +03:00
parent 233b463ac3
commit 9067db2639
4 changed files with 49 additions and 32 deletions
+2 -2
View File
@@ -89,8 +89,8 @@ section {
.displays-grid,
.devices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
gap: 14px;
}
.devices-grid > .loading,
+12 -1
View File
@@ -47,6 +47,13 @@ export interface ModMetricOpts {
* secondary line (e.g. LED chip + RGB/RGBW). The default display
* font is too coarse for string identifiers. */
variant?: 'text-stack';
/** Extra raw HTML appended after the .v element inside the cell —
* used to embed a sparkline canvas (`.mod-metric-spark-canvas`)
* into the FPS metric, mirroring the dashboard pattern. */
extra?: string;
/** Extra data attributes on the .v element (e.g. data-fps-text for
* cached lookup during live updates). */
valueDataAttrs?: Record<string, string>;
}
export interface ModChipOpts {
@@ -281,8 +288,12 @@ export function renderModMetrics(metrics: ModMetricOpts[]): string {
const titleAttr = m.title ? ` title="${escapeHtml(m.title)}"` : '';
const vCls = ['v', m.accent ? 'signal' : '', m.error ? 'has-errors' : ''].filter(Boolean).join(' ');
const vIdAttr = m.valueId ? ` id="${m.valueId}"` : '';
const vDataAttrs = m.valueDataAttrs
? ' ' + Object.entries(m.valueDataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
const labelHtml = `<span class="k">${m.icon || ''} <span>${escapeHtml(m.k)}</span></span>`;
return `<div class="${cellCls}"${titleAttr}>${labelHtml}<span class="${vCls}"${vIdAttr}>${m.v}</span></div>`;
const extraHtml = m.extra || '';
return `<div class="${cellCls}"${titleAttr}>${labelHtml}<span class="${vCls}"${vIdAttr}${vDataAttrs}>${m.v}</span>${extraHtml}</div>`;
}).join('');
return `<div class="${cellsClass}">${cells}</div>`;
}
@@ -8,7 +8,7 @@ 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_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_OK, ICON_WARNING, ICON_CLOCK, 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';
@@ -560,19 +560,20 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
const metrics: ModMetricOpts[] = [];
if (isRunning) {
metrics.push({
k: t('targets.fps') || 'RATE',
icon: ICON_FPS,
k: 'FPS',
v: '<span data-tm="fps">---</span>',
accent: true,
title: t('targets.fps'),
});
metrics.push({
k: t('device.metrics.uptime') || 'UPTIME',
k: t('device.metrics.uptime') || 'Uptime',
icon: ICON_CLOCK,
v: '<span data-tm="uptime">---</span>',
title: t('device.metrics.uptime'),
});
metrics.push({
k: 'HA',
icon: state.ha_connected ? ICON_OK : ICON_WARNING,
v: '<span data-tm="ha-status">---</span>',
title: 'Home Assistant connection',
});
@@ -22,7 +22,7 @@ import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTarge
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_EDIT, ICON_START, ICON_STOP,
ICON_FPS, ICON_LED_PREVIEW,
ICON_LED_PREVIEW, ICON_CLOCK, ICON_OK,
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';
@@ -919,11 +919,14 @@ function _patchTargetMetrics(target: any) {
if (fps) {
const effFps = state.fps_effective;
const tgtFps = state.fps_target || 0;
const fpsLabel = (effFps != null && effFps < tgtFps)
? `${state.fps_current ?? 0}<small>/${effFps}${tgtFps}</small>`
: `${state.fps_current ?? 0}<small>/${tgtFps}</small>`;
const fpsCurrent = state.fps_current ?? 0;
const fpsActual = state.fps_actual?.toFixed(1) ?? '0.0';
const fpsTargetLabel = (effFps != null && effFps < tgtFps)
? `${fpsCurrent}<span class="dashboard-fps-target">/${effFps}${tgtFps}</span>`
: `${fpsCurrent}<span class="dashboard-fps-target">/${tgtFps}</span>`;
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`;
fps.innerHTML = `<span class="${unreachableClass}">${fpsTargetLabel}</span>`
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
}
// Update health dot to reflect streaming reachability when processing
@@ -1068,39 +1071,41 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
}
// ── 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. ──
// pop in the instrument-readout style. FPS cell embeds a
// sparkline canvas (mod-metric-spark-canvas) underneath the
// big numeric, mirroring the dashboard pattern. The full
// pipeline detail sits below in the collapsible block. ──
const metrics: ModMetricOpts[] = [];
if (isProcessing) {
const fpsTargetVal = state.fps_target || target.fps || 30;
metrics.push({
k: t('targets.fps') || 'FPS',
icon: ICON_FPS,
v: '<span data-tm="fps">---</span>',
k: 'FPS',
v: '---',
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'),
valueDataAttrs: { 'data-tm': 'fps' },
extra: `<canvas class="mod-metric-spark-canvas" id="target-fps-${target.id}" data-fps-target="${fpsTargetVal}"></canvas>`,
});
metrics.push({
k: t('device.metrics.uptime') || 'UPTIME',
k: t('device.metrics.uptime') || 'Uptime',
icon: ICON_CLOCK,
v: '<span data-tm="uptime">---</span>',
title: t('device.metrics.uptime'),
});
const errCount = tgtMetrics.errors_count || 0;
metrics.push({
k: t('device.metrics.errors') || 'Errors',
icon: errCount > 0 ? ICON_WARNING : ICON_OK,
v: '<span data-tm="errors">---</span>',
error: errCount > 0,
title: t('device.metrics.errors'),
});
}
// ── Live-update raw HTML: FPS sparkline + collapsible pipeline ──
// ── Live-update raw HTML: collapsible pipeline detail (frames /
// keepalive / timing breakdown). The FPS sparkline now lives
// inside the FPS metric cell, not in a separate row. ──
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">