Add collapsible pipeline metrics and error indicator to target cards

FPS chart stays always visible; timing, frames, keepalive, errors, and
uptime are collapsed behind an animated toggle. Error warning icon
appears next to target name when errors_count > 0. Uses CSS grid
0fr→1fr transition for smooth expand/collapse animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 00:27:08 +03:00
parent 6fc0e20e1d
commit dc4495a117
6 changed files with 86 additions and 21 deletions

View File

@@ -742,6 +742,54 @@ ul.section-tip li {
color: #ff5252; color: #ff5252;
} }
/* Error indicator next to target name */
.target-error-indicator {
display: none;
color: var(--danger-color);
margin-left: 4px;
vertical-align: middle;
}
.target-error-indicator .icon {
width: 14px;
height: 14px;
}
.target-error-indicator.visible {
display: inline-flex;
align-items: center;
}
/* Collapsible target pipeline metrics */
.target-metrics-collapse {
margin-top: 4px;
}
.target-metrics-toggle {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-secondary);
padding: 4px 0;
user-select: none;
background: none;
border: none;
font-family: inherit;
}
.target-metrics-toggle::before {
content: '▸ ';
}
.target-metrics-collapse.open .target-metrics-toggle::before {
content: '▾ ';
}
.target-metrics-animate {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.target-metrics-collapse.open .target-metrics-animate {
grid-template-rows: 1fr;
}
.target-metrics-animate > .target-metrics-expanded {
overflow: hidden;
}
/* Timing breakdown bar */ /* Timing breakdown bar */
.timing-breakdown { .timing-breakdown {
margin-top: 8px; margin-top: 8px;

View File

@@ -24,6 +24,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
ICON_WARNING,
} from '../core/icons.js'; } from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
@@ -881,6 +882,13 @@ function _patchTargetMetrics(target) {
const errors = card.querySelector('[data-tm="errors"]'); const errors = card.querySelector('[data-tm="errors"]');
if (errors) errors.textContent = metrics.errors_count || 0; if (errors) errors.textContent = metrics.errors_count || 0;
// Error indicator near target name
const errorIndicator = card.querySelector('.target-error-indicator');
if (errorIndicator) {
const hasErrors = (metrics.errors_count || 0) > 0;
errorIndicator.classList.toggle('visible', hasErrors);
}
const uptime = card.querySelector('[data-tm="uptime"]'); const uptime = card.querySelector('[data-tm="uptime"]');
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
} }
@@ -922,6 +930,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
<div class="card-title"> <div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span> <span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)} ${escapeHtml(target.name)}
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
</div> </div>
</div> </div>
<div class="stream-card-props"> <div class="stream-card-props">
@@ -943,9 +952,12 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
<span class="metric-value" data-tm="fps">---</span> <span class="metric-value" data-tm="fps">---</span>
</div> </div>
</div> </div>
${state.timing_total_ms != null ? ` </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="timing-breakdown" data-tm="timing" style="grid-column:1/-1"></div>
` : ''}
<div class="metric"> <div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div> <div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value" data-tm="frames">---</div> <div class="metric-value" data-tm="frames">---</div>
@@ -965,6 +977,8 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
<div class="metric-value" data-tm="uptime">---</div> <div class="metric-value" data-tm="uptime">---</div>
</div> </div>
</div> </div>
</div>
</div>
` : ''} ` : ''}
</div> </div>
${_buildLedPreviewHtml(target.id, device, bvsId)}`, ${_buildLedPreviewHtml(target.id, device, bvsId)}`,

View File

@@ -458,6 +458,7 @@
"targets.source": "Source:", "targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process", "targets.source.hint": "Which picture source to capture and process",
"targets.source.none": "-- No source assigned --", "targets.source.none": "-- No source assigned --",
"targets.metrics.pipeline": "Pipeline details",
"targets.fps": "Target FPS:", "targets.fps": "Target FPS:",
"targets.fps.hint": "Target frames per second for capture and LED updates (1-90)", "targets.fps.hint": "Target frames per second for capture and LED updates (1-90)",
"targets.fps.rec": "Hardware max ≈ {fps} fps ({leds} LEDs)", "targets.fps.rec": "Hardware max ≈ {fps} fps ({leds} LEDs)",

View File

@@ -458,6 +458,7 @@
"targets.source": "Источник:", "targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать", "targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
"targets.source.none": "-- Источник не назначен --", "targets.source.none": "-- Источник не назначен --",
"targets.metrics.pipeline": "Детали конвейера",
"targets.fps": "Целевой FPS:", "targets.fps": "Целевой FPS:",
"targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)", "targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)",
"targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)", "targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)",

View File

@@ -458,6 +458,7 @@
"targets.source": "源:", "targets.source": "源:",
"targets.source.hint": "要采集和处理的图片源", "targets.source.hint": "要采集和处理的图片源",
"targets.source.none": "-- 未分配源 --", "targets.source.none": "-- 未分配源 --",
"targets.metrics.pipeline": "管线详情",
"targets.fps": "目标 FPS", "targets.fps": "目标 FPS",
"targets.fps.hint": "采集和 LED 更新的目标帧率1-90", "targets.fps.hint": "采集和 LED 更新的目标帧率1-90",
"targets.fps.rec": "硬件最大 ≈ {fps} fps{leds} 个 LED", "targets.fps.rec": "硬件最大 ≈ {fps} fps{leds} 个 LED",

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback * - Navigation: network-first with offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v17'; const CACHE_NAME = 'ledgrab-v18';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.