perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings

- dashboard: only destroy/recreate FPS charts for added/removed/detached
  targets; skip the history fetch when local samples already exist.
  Drops sync-clock `is_running` from the structure signature so toggles
  don't trigger a full rebuild; route clock/automation refresh through
  the in-place path.
- perf-charts: cache SVG skeleton per spark host and mutate node
  attributes instead of rewriting `innerHTML` every poll. Memoize
  patches/devices rendering by content signature so unchanged ticks
  no longer restart CSS animations. Skip render for env-hidden cards.
- perf-charts: switch `/system/performance` poll to `fetchWithAuth`,
  re-read `dashboardPollInterval` per tooltip move, and route the
  remaining hardcoded English strings ("no captures", "{n} total",
  "{rate} skipped/s", tooltip age, metric labels) through `t()`.
- locales: add `perf.no_captures`, `perf.captures_count`,
  `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`,
  `perf.tip.now`, `perf.tip.ago` in en/ru/zh.
This commit is contained in:
2026-05-26 00:12:29 +03:00
parent 48dbdb90e9
commit f6486f9b34
5 changed files with 309 additions and 141 deletions
@@ -160,26 +160,48 @@ function _createFpsChart(canvasId: string, actualHistory: number[], currentHisto
}
async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
_destroyFpsCharts();
// Diff against current charts so we only tear down/recreate what actually
// changed. The slow-path full-render swaps card DOM, which detaches the
// canvases existing Chart.js instances are bound to — detect those by
// checking whether the chart's canvas is still in the document.
const desired = new Set(runningTargetIds);
const existing = new Set(Object.keys(_fpsCharts));
const removed = [...existing].filter(id => !desired.has(id));
const added = [...desired].filter(id => !existing.has(id));
const detached: string[] = [];
for (const id of existing) {
if (!desired.has(id)) continue;
const canvas = _fpsCharts[id]?.canvas;
if (canvas && !document.body.contains(canvas)) detached.push(id);
}
const toDestroy = new Set([...removed, ...detached]);
const toCreate = new Set([...added, ...detached]);
// Seed FPS history from server ring buffer (on first load and tab switches)
if (runningTargetIds.length > 0) {
for (const id of toDestroy) {
if (_fpsCharts[id]) { _fpsCharts[id].destroy(); delete _fpsCharts[id]; }
}
for (const id of removed) {
delete _fpsHistory[id];
delete _fpsCurrentHistory[id];
}
// Only fetch history for genuinely new ids that have no local samples yet.
// Skips the network round-trip entirely on full renders triggered by
// settings changes — local FPS history accumulated via _pushFps stays
// intact and seeds the recreated chart.
const needSeed = [...toCreate].filter(id => !_fpsHistory[id]);
if (needSeed.length > 0) {
const data = await fetchMetricsHistory();
if (data) {
const serverTargets = data.targets || {};
for (const id of runningTargetIds) {
for (const id of needSeed) {
const samples = serverTargets[id] || [];
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
}
}
}
// Clean up history for targets that are no longer running
for (const id of Object.keys(_fpsHistory)) {
if (!runningTargetIds.includes(id)) { delete _fpsHistory[id]; delete _fpsCurrentHistory[id]; }
}
for (const id of runningTargetIds) {
for (const id of toCreate) {
const canvas = document.getElementById(`dashboard-fps-${id}`);
if (!canvas) continue;
const actualH = _fpsHistory[id] || [];
@@ -815,23 +837,18 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount);
// Check if we can do an in-place metrics update (same targets, not first load)
// Fast path: in-place update when the entity *set* is unchanged.
// Running-state toggles for sync clocks/automations/integrations are
// handled by the in-place updaters below, so they don't need to
// invalidate this check. Structural adds/removes/edits are caught by
// `server:entity_changed` SSE which sets `forceFullRender=true` and
// skips this path so cards rebuild with fresh settings.
const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(',');
const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
const newSyncClockIds = syncClocks.map(c => c.id).sort().join(',');
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds;
if (structureUnchanged && !forceFullRender && running.length > 0) {
_updateRunningMetrics(running);
_updateSyncClocksInPlace(syncClocks);
_updateIntegrationsInPlace(haStatus, mqttStatus);
_cacheUptimeElements();
_startUptimeTimer();
startPerfPolling();
set_dashboardLoading(false);
return;
}
if (structureUnchanged && forceFullRender) {
if (structureUnchanged && !forceFullRender) {
if (running.length > 0) _updateRunningMetrics(running);
_updateAutomationsInPlace(automations);
_updateSyncClocksInPlace(syncClocks);
@@ -981,7 +998,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
}
_mountDashboardCardModeToggles();
_lastRunningIds = runningIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
_lastSyncClockIds = syncClocks.map(c => c.id).sort().join(',');
_cacheUptimeElements();
await _initFpsCharts(runningIds);
_startUptimeTimer();
@@ -1304,7 +1321,7 @@ export async function dashboardPauseClock(clockId: string): Promise<void> {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.paused'), 'success');
loadDashboard(true);
loadDashboard();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -1316,7 +1333,7 @@ export async function dashboardResumeClock(clockId: string): Promise<void> {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.resumed'), 'success');
loadDashboard(true);
loadDashboard();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -1328,7 +1345,7 @@ export async function dashboardResetClock(clockId: string): Promise<void> {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.reset_done'), 'success');
loadDashboard(true);
loadDashboard();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -1352,7 +1369,7 @@ function _debouncedDashboardReload(forceFullRender: boolean = false): void {
}
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload());
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device', 'home_assistant_source']);
@@ -8,7 +8,7 @@
* cheap for 120-sample lines.
*/
import { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts';
import { fetchMetricsHistory, fetchWithAuth } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts';
import { isActiveTab } from '../core/tab-registry.ts';
@@ -397,6 +397,16 @@ export function renderPerfSection(): string {
return `<div class="perf-charts-grid">${cellsHtml}</div>`;
}
/** Last `perf-patches-list` element we rendered into + the signature of
* its rendered content. Both must match for us to skip a re-render —
* identity differs when the perf section gets rebuilt (layout change,
* full dashboard re-render) so a stale cached signature can't suppress
* a needed write. The signature mirrors the inputs that actually shape
* the HTML so unchanged polls don't tear down the DOM and restart the
* pulsing-dot CSS animation on every tick. */
let _lastPatchesListEl: HTMLElement | null = null;
let _lastPatchesSig: string | null = null;
/** Externally-called from dashboard.ts whenever the running-target set
* is recomputed. Updates the Active Patches cell with count + a short
* list of running channels and their current FPS. */
@@ -412,6 +422,9 @@ export function updateActivePatches(
const listEl = document.getElementById('perf-patches-list');
if (!listEl) return;
let nextHtml: string;
let sig: string;
if (running.length === 0) {
// Empty-state hint — "Ready to launch" when targets exist but
// none are running, or "No patches yet" when the user hasn't
@@ -420,24 +433,33 @@ export function updateActivePatches(
? 'dashboard.perf.patches.empty.none'
: 'dashboard.perf.patches.empty.idle';
const hintText = t(hintKey) || (totalCount === 0 ? 'No patches yet' : 'Ready to launch');
listEl.innerHTML = `<div class="perf-patches-empty">
sig = `empty:${hintKey}:${hintText}`;
nextHtml = `<div class="perf-patches-empty">
<span class="perf-patches-empty-dot" aria-hidden="true"></span>
<span class="perf-patches-empty-text">${escapeText(hintText)}</span>
</div>`;
return;
} else {
const visible = running.slice(0, 4);
const rows = visible.map((r, i) => {
const colors = ['--ch-signal', '--ch-cyan', '--ch-magenta', '--ch-amber'];
const colorVar = colors[i % colors.length];
const fps = r.fps != null ? `${r.fps.toFixed(1)} FPS` : '—';
return `<div class="perf-patches-row">
<span class="perf-patches-stripe" style="background: var(${colorVar})"></span>
<span class="perf-patches-name">${escapeText(r.name)}</span>
<span class="perf-patches-fps">${fps}</span>
</div>`;
}).join('');
const overflow = Math.max(0, running.length - 4);
const more = overflow > 0 ? `<div class="perf-patches-more">+${overflow} more</div>` : '';
sig = `run:${visible.map(r => `${r.id}|${r.name}|${r.fps != null ? r.fps.toFixed(1) : '-'}`).join(';')}:more=${overflow}`;
nextHtml = rows + more;
}
const rows = running.slice(0, 4).map((r, i) => {
const colors = ['--ch-signal', '--ch-cyan', '--ch-magenta', '--ch-amber'];
const colorVar = colors[i % colors.length];
const fps = r.fps != null ? `${r.fps.toFixed(1)} FPS` : '—';
return `<div class="perf-patches-row">
<span class="perf-patches-stripe" style="background: var(${colorVar})"></span>
<span class="perf-patches-name">${escapeText(r.name)}</span>
<span class="perf-patches-fps">${fps}</span>
</div>`;
}).join('');
const more = running.length > 4 ? `<div class="perf-patches-more">+${running.length - 4} more</div>` : '';
listEl.innerHTML = rows + more;
if (listEl === _lastPatchesListEl && sig === _lastPatchesSig) return;
_lastPatchesListEl = listEl;
_lastPatchesSig = sig;
listEl.innerHTML = nextHtml;
}
function escapeText(s: string): string {
@@ -533,31 +555,7 @@ export function updateTotalCaptureFpsActual(
if (_history.capture_fps_actual.length > MAX_SAMPLES) _history.capture_fps_actual.shift();
if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps;
const valEl = document.getElementById('perf-capture_fps_actual-value');
if (valEl) {
if (reportingCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
} else {
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
const ceilingSuffix = targetSum > 0
? `<span class="perf-fps-ceiling">/ ${Math.round(targetSum)}</span>`
: '';
valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
}
}
const subEl = document.getElementById('perf-capture_fps_actual-sub');
if (subEl) {
if (reportingCount === 0) {
subEl.textContent = '';
} else if (targetSum > 0) {
// Drop ratio reads "how far behind requested" — useful at-a-glance
// diagnostic for capture saturation.
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
} else {
subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
}
}
_paintCaptureFpsActualValue(fps, targetSum, reportingCount);
_renderChartSvg('capture_fps_actual', /*animate=*/true);
}
@@ -742,13 +740,18 @@ export function updateTotalErrors(
const subEl = document.getElementById('perf-errors-sub');
if (subEl) {
const parts: string[] = [];
if (totalErrors > 0) parts.push(`${totalErrors} total`);
if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
if (totalErrors > 0) parts.push(t('perf.total_count', { count: totalErrors }));
if (skippedRate >= 0.1) parts.push(t('perf.skipped_per_sec', { rate: skippedRate.toFixed(skippedRate < 10 ? 1 : 0) }));
subEl.textContent = parts.join(' · ');
}
_renderChartSvg('errors', /*animate=*/true);
}
/** Same identity+signature pair as the patches cell — see
* `_lastPatchesListEl` for the rationale. */
let _lastDevicesDotsEl: HTMLElement | null = null;
let _lastDevicesSig: string | null = null;
/** Devices cell — online / total count with a dot strip showing each
* device's connection state at a glance. */
export function updateDevices(
@@ -773,24 +776,34 @@ export function updateDevices(
const dotsEl = document.getElementById('perf-devices-dots');
if (!dotsEl) return;
let nextHtml: string;
let sig: string;
if (total === 0) {
dotsEl.innerHTML = '';
return;
nextHtml = '';
sig = 'empty';
} else {
// Cap visible dots to avoid wrapping weirdness; indicate overflow.
const MAX_DOTS = 24;
const shown = states.slice(0, MAX_DOTS);
const overflow = total - shown.length;
const dots = shown.map(s => {
const name = s.device_name || s.device_id;
const latency = s.device_latency_ms != null ? ` · ${s.device_latency_ms.toFixed(0)}ms` : '';
const title = `${name} · ${s.device_online ? t('perf.online') : t('perf.offline')}${latency}`;
return `<span class="perf-devices-dot ${s.device_online ? 'is-online' : 'is-offline'}" title="${escapeText(title)}"></span>`;
}).join('');
const more = overflow > 0 ? `<span class="perf-devices-more">+${overflow}</span>` : '';
nextHtml = dots + more;
// Latency is included so a changing ms value re-renders the title
// tooltips; without it the hover would show stale latency.
sig = `n=${total}:o=${overflow}:` + shown.map(s => `${s.device_id}|${s.device_online ? 1 : 0}|${s.device_latency_ms ?? '-'}|${s.device_name ?? ''}`).join(';');
}
// Cap visible dots to avoid wrapping weirdness; indicate overflow.
const MAX_DOTS = 24;
const shown = states.slice(0, MAX_DOTS);
const overflow = total - shown.length;
const dots = shown.map(s => {
const name = s.device_name || s.device_id;
const latency = s.device_latency_ms != null ? ` · ${s.device_latency_ms.toFixed(0)}ms` : '';
const title = `${name} · ${s.device_online ? t('perf.online') : t('perf.offline')}${latency}`;
return `<span class="perf-devices-dot ${s.device_online ? 'is-online' : 'is-offline'}" title="${escapeText(title)}"></span>`;
}).join('');
const more = overflow > 0
? `<span class="perf-devices-more">+${overflow}</span>`
: '';
dotsEl.innerHTML = dots + more;
if (dotsEl === _lastDevicesDotsEl && sig === _lastDevicesSig) return;
_lastDevicesDotsEl = dotsEl;
_lastDevicesSig = sig;
dotsEl.innerHTML = nextHtml;
}
/** Resolve the global animations preference once per render — read from
@@ -843,7 +856,94 @@ function _scrollSpark(host: HTMLElement, sliceN: number): void {
});
}
/** Render the SVG sparkline into its container.
const SVG_NS = 'http://www.w3.org/2000/svg';
interface SparkNodes {
svg: SVGSVGElement;
ref: SVGLineElement;
area: SVGPathElement;
line: SVGPathElement;
appLine: SVGPathElement;
}
/** Cache of host → spark-node refs so we don't pay four `querySelector`
* calls per spark per poll. Entries drop automatically once the host
* element is GC'd; stale entries (svg detached from the host) are
* filtered out at lookup time via `host.contains(cached.svg)`. */
const _sparkNodeCache = new WeakMap<HTMLElement, SparkNodes>();
/** Lazily build (or reuse) the stable SVG skeleton for a spark host. The
* reference line + two system paths + one app path stay in the DOM for
* the life of the card; each render only mutates their attributes. This
* avoids the per-tick `innerHTML` rewrite that previously destroyed and
* recreated the entire SVG subtree. If the host still has an `<svg>` but
* any expected child is missing — or the cached node is detached — the
* skeleton is rebuilt fresh so callers can rely on every field being a
* live element. */
function _ensureSparkNodes(host: HTMLElement): SparkNodes {
const cached = _sparkNodeCache.get(host);
if (cached && host.contains(cached.svg)) {
return cached;
}
const existing = host.querySelector('svg.perf-chart-svg') as SVGSVGElement | null;
if (existing) {
const ref = existing.querySelector('line.perf-chart-ref') as SVGLineElement | null;
const area = existing.querySelector('path.perf-chart-area') as SVGPathElement | null;
const line = existing.querySelector('path.perf-chart-line') as SVGPathElement | null;
const appLine = existing.querySelector('path.perf-chart-app-line') as SVGPathElement | null;
if (ref && area && line && appLine) {
const nodes: SparkNodes = { svg: existing, ref, area, line, appLine };
_sparkNodeCache.set(host, nodes);
return nodes;
}
existing.remove();
}
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'perf-chart-svg');
svg.setAttribute('viewBox', `0 0 ${SPARK_W} ${SPARK_H}`);
svg.setAttribute('preserveAspectRatio', 'none');
svg.setAttribute('aria-hidden', 'true');
const ref = document.createElementNS(SVG_NS, 'line');
ref.setAttribute('class', 'perf-chart-ref');
ref.setAttribute('stroke-width', '1');
ref.setAttribute('stroke-dasharray', '5 4');
ref.setAttribute('opacity', '0.4');
ref.style.display = 'none';
svg.appendChild(ref);
const area = document.createElementNS(SVG_NS, 'path');
area.setAttribute('class', 'perf-chart-area');
area.setAttribute('opacity', '0.14');
area.style.display = 'none';
svg.appendChild(area);
const line = document.createElementNS(SVG_NS, 'path');
line.setAttribute('class', 'perf-chart-line');
line.setAttribute('fill', 'none');
line.setAttribute('stroke-width', '1.5');
line.setAttribute('stroke-linejoin', 'round');
line.style.display = 'none';
svg.appendChild(line);
const appLine = document.createElementNS(SVG_NS, 'path');
appLine.setAttribute('class', 'perf-chart-app-line');
appLine.setAttribute('fill', 'none');
appLine.setAttribute('stroke-width', '1.1');
appLine.setAttribute('stroke-dasharray', '4 3');
appLine.setAttribute('stroke-linejoin', 'round');
appLine.setAttribute('opacity', '0.75');
appLine.style.display = 'none';
svg.appendChild(appLine);
host.appendChild(svg);
const nodes: SparkNodes = { svg, ref, area, line, appLine };
_sparkNodeCache.set(host, nodes);
return nodes;
}
/** Render the SVG sparkline into its container by mutating the existing
* path nodes (created once via `_ensureSparkNodes`).
*
* `animate` triggers the smooth left-scroll animation — only set on
* paths that just pushed a fresh sample. Non-sample paths (mode
@@ -852,6 +952,11 @@ function _scrollSpark(host: HTMLElement, sliceN: number): void {
function _renderChartSvg(key: string, animate: boolean = false): void {
const host = document.getElementById(`perf-chart-${key}`);
if (!host) return;
// Cards env-hidden (e.g. GPU on a host without one, Temp without
// LibreHardwareMonitor) keep their DOM but should skip every render
// cycle — the SVG never paints anyway.
const card = host.closest('.perf-chart-card') as HTMLElement | null;
if (card?.hasAttribute('hidden')) return;
// Effective window (in seconds) for this cell — global default
// unless the cell pinned its own. With 1 sample/sec polling the
// window in seconds equals the desired sample count; we trim the
@@ -886,7 +991,7 @@ function _renderChartSvg(key: string, animate: boolean = false): void {
: key === 'send_timing' ? Math.max(20, _sendTimingPeak * 1.2)
: 100;
const paths: string[] = [];
const nodes = _ensureSparkNodes(host);
// FPS-only: dashed "target ceiling" reference line at the sum of
// fps_target across running targets, so the spark reads as "live
@@ -894,34 +999,45 @@ function _renderChartSvg(key: string, animate: boolean = false): void {
if (key === 'fps' && _fpsTargetSum > 0 && _fpsTargetSum <= yMax) {
const span = yMax - yMin || 1;
const refY = SPARK_H - ((_fpsTargetSum - yMin) / span) * (SPARK_H - 2) - 1;
paths.push(`<line x1="0" y1="${refY.toFixed(1)}" x2="${SPARK_W}" y2="${refY.toFixed(1)}" stroke="${color}" stroke-width="1" stroke-dasharray="5 4" opacity="0.4" />`);
nodes.ref.setAttribute('x1', '0');
nodes.ref.setAttribute('y1', refY.toFixed(1));
nodes.ref.setAttribute('x2', String(SPARK_W));
nodes.ref.setAttribute('y2', refY.toFixed(1));
nodes.ref.setAttribute('stroke', color);
nodes.ref.style.display = '';
} else {
nodes.ref.style.display = 'none';
}
if (showSystem && sys.length > 1) {
paths.push(_pathFor(sys, yMin, yMax, color, 'sys', sliceN));
}
if (showApp && app.length > 1) {
paths.push(_pathFor(app, yMin, yMax, color, 'app', sliceN));
const built = _buildPath(sys, yMin, yMax, sliceN);
nodes.area.setAttribute('d', built.area);
nodes.area.setAttribute('fill', color);
nodes.area.style.display = '';
nodes.line.setAttribute('d', built.line);
nodes.line.setAttribute('stroke', color);
nodes.line.style.display = '';
} else {
nodes.area.style.display = 'none';
nodes.line.style.display = 'none';
}
host.innerHTML = `
<svg class="perf-chart-svg" viewBox="0 0 ${SPARK_W} ${SPARK_H}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="perf-fade-${key}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.32"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0"/>
</linearGradient>
</defs>
${paths.join('')}
</svg>`;
if (showApp && app.length > 1) {
const built = _buildPath(app, yMin, yMax, sliceN);
nodes.appLine.setAttribute('d', built.line);
nodes.appLine.setAttribute('stroke', color);
nodes.appLine.style.display = '';
} else {
nodes.appLine.style.display = 'none';
}
if (animate) _scrollSpark(host, sliceN);
}
/** Build <path> elements (area + stroke) for one series. */
function _pathFor(history: number[], yMin: number, yMax: number, color: string, kind: 'sys' | 'app', sliceN: number = MAX_SAMPLES): string {
/** Compute the line + area path `d` strings for one series. */
function _buildPath(history: number[], yMin: number, yMax: number, sliceN: number): { line: string; area: string } {
const n = history.length;
if (n < 2) return '';
if (n < 2) return { line: '', area: '' };
// Right-align so the most recent sample sits at the right edge —
// matches an instrument display where new values tick in from the
// right. `sliceN` is the spark's logical sample-count "width" (set
@@ -943,15 +1059,7 @@ function _pathFor(history: number[], yMin: number, yMax: number, color: string,
const firstX = offset;
const lastX = offset + (n - 1) * step;
const area = `M${firstX.toFixed(1)},${SPARK_H} L${points.join(' L')} L${lastX.toFixed(1)},${SPARK_H} Z`;
if (kind === 'sys') {
const gradientId = `perf-fade-${color.replace(/[^a-z0-9]/gi, '')}`;
return `
<path d="${area}" fill="${color}" opacity="0.14" />
<path d="${line}" stroke="${color}" stroke-width="1.5" fill="none" stroke-linejoin="round" />`;
}
// App line: thinner, dashed, no fill
return `<path d="${line}" stroke="${color}" stroke-width="1.1" fill="none" stroke-dasharray="4 3" stroke-linejoin="round" opacity="0.75" />`;
return { line, area };
}
function _pushSample(key: string, sysValue: number, appValue: number | null): void {
@@ -994,13 +1102,15 @@ function _renderValuePair(key: string, sysVal: string, appVal: string | null): v
async function _fetchPerformance(): Promise<void> {
try {
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
const resp = await fetchWithAuth('/system/performance');
if (!resp.ok) return;
const data = await resp.json();
_lastFetchData = data;
_applyPerfDataToDom(data, /*pushHistory=*/true);
} catch {
// Silently ignore transient fetch errors
} catch (err) {
// Auth failures are surfaced via fetchWithAuth's redirect flow; swallow
// other transient fetch errors so the next tick can recover.
if ((err as { isAuth?: boolean })?.isAuth) return;
}
}
@@ -1288,7 +1398,7 @@ function _seedAggregateHistories(samples: any[]): void {
// series. `null` samples (no devices online) become 0 in the
// history so the spark drops to floor instead of going jagged.
const latencySeries = samples
.map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0)
.map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0);
if (latencySeries.length > 0) {
_history.device_latency = latencySeries.slice(-MAX_SAMPLES);
_deviceLatencyPeak = Math.max(50, ..._history.device_latency);
@@ -1306,7 +1416,7 @@ function _seedAggregateHistories(samples: any[]): void {
// the subtitle/tooltip but isn't a separate spark line to avoid
// adding visual noise.
const sendSeries = samples
.map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0)
.map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0);
if (sendSeries.length > 0) {
_history.send_timing = sendSeries.slice(-MAX_SAMPLES);
const maxes = samples
@@ -1350,7 +1460,7 @@ function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCo
const valEl = document.getElementById('perf-capture_fps_actual-value');
if (valEl) {
if (reportingCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.no_captures')}</span>`;
} else {
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
const ceilingSuffix = targetSum > 0
@@ -1363,11 +1473,14 @@ function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCo
if (subEl) {
if (reportingCount === 0) {
subEl.textContent = '';
} else if (targetSum > 0) {
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
} else {
subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
const captures = t('perf.captures_count', { count: reportingCount });
if (targetSum > 0) {
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = t('perf.ratio_of_requested', { percent: Math.round(ratio * 100), captures });
} else {
subEl.textContent = captures;
}
}
}
}
@@ -1388,8 +1501,8 @@ function _paintErrorsValue(errorsRate: number, totalErrors: number, skippedRate:
const subEl = document.getElementById('perf-errors-sub');
if (subEl) {
const parts: string[] = [];
if (totalErrors > 0) parts.push(`${totalErrors} total`);
if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
if (totalErrors > 0) parts.push(t('perf.total_count', { count: totalErrors }));
if (skippedRate >= 0.1) parts.push(t('perf.skipped_per_sec', { rate: skippedRate.toFixed(skippedRate < 10 ? 1 : 0) }));
subEl.textContent = parts.join(' · ');
}
}
@@ -1614,31 +1727,44 @@ function _formatSampleValue(key: string, v: number): string {
return `${v.toFixed(1)}%`;
}
/** Map metric-key → locale-key for the card title shown on tooltip.
* Most keys are `dashboard.perf.<key>` but the three FPS metrics live
* under `total_*` to match the card headers (the bare `fps` etc. keys
* do not exist). Keeps the tooltip header in lockstep with the card. */
const METRIC_LABEL_KEYS: Record<string, string> = {
cpu: 'dashboard.perf.cpu',
ram: 'dashboard.perf.ram',
gpu: 'dashboard.perf.gpu',
temp: 'dashboard.perf.temp',
fps: 'dashboard.perf.total_fps',
capture_fps: 'dashboard.perf.total_capture_fps',
capture_fps_actual: 'dashboard.perf.total_capture_fps_actual',
network: 'dashboard.perf.network',
device_latency: 'dashboard.perf.device_latency',
send_timing: 'dashboard.perf.send_timing',
errors: 'dashboard.perf.errors',
};
function _metricLabel(key: string): string {
if (key === 'cpu') return 'CPU';
if (key === 'ram') return 'RAM';
if (key === 'gpu') return 'GPU';
if (key === 'temp') return 'Temp';
if (key === 'fps') return 'Total FPS';
if (key === 'capture_fps') return 'Total Source FPS';
if (key === 'capture_fps_actual') return 'Total Capture FPS';
if (key === 'network') return 'Network';
if (key === 'device_latency') return 'Device Latency';
if (key === 'send_timing') return 'Send Timing';
if (key === 'errors') return 'Errors';
return key.toUpperCase();
const labelKey = METRIC_LABEL_KEYS[key];
if (!labelKey) return key.toUpperCase();
const translated = t(labelKey);
return translated === labelKey ? key.toUpperCase() : translated;
}
let _tooltipBound = false;
function _initSparkTooltip(): void {
if (_tooltipBound) return;
_tooltipBound = true;
const intervalMs = dashboardPollInterval || 2000;
// Bound on `document.body` instead of `.perf-charts-grid` so the
// listener survives `rerenderPerfGrid()` replacing the grid element.
// The handler bails out unless the cursor is actually over a spark,
// so the hot-path cost is just one `closest()` call per mousemove.
document.body.addEventListener('mousemove', (rawEv) => {
// Re-read poll interval per handler tick — the user can change it
// mid-session via the transport bar slider, and a captured value
// would skew the "N seconds ago" calculation after every change.
const intervalMs = dashboardPollInterval || 2000;
const ev = rawEv as MouseEvent;
const target = ev.target as HTMLElement;
if (!target || !target.closest) { _hideTooltip(); return; }
@@ -1693,7 +1819,7 @@ function _initSparkTooltip(): void {
<span class="perf-tip-v">${_formatSampleValue(key, appValue)}</span>
</div>`
: '';
const ageLine = `<div class="perf-tip-age">${ageSecs === 0 ? 'now' : `${ageSecs}s`}</div>`;
const ageLine = `<div class="perf-tip-age">${ageSecs === 0 ? t('perf.tip.now') : t('perf.tip.ago', { seconds: ageSecs })}</div>`;
tip.innerHTML = `<div class="perf-tip-row">${sysLine}</div>${appLine}${ageLine}`;
tip.style.display = 'block';
@@ -453,6 +453,14 @@
"perf.max_ms": "max {ms}ms",
"perf.targets_count.one": "{count} target",
"perf.targets_count.other": "{count} targets",
"perf.no_captures": "no captures",
"perf.captures_count.one": "{count} capture",
"perf.captures_count.other": "{count} captures",
"perf.ratio_of_requested": "{percent}% of requested · {captures}",
"perf.total_count": "{count} total",
"perf.skipped_per_sec": "{rate} skipped/s",
"perf.tip.now": "now",
"perf.tip.ago": "{seconds}s",
"device.last_seen.label": "Last seen",
"device.last_seen.just_now": "just now",
"device.last_seen.seconds": "%ds ago",
@@ -509,6 +509,15 @@
"perf.targets_count.one": "{count} цель",
"perf.targets_count.few": "{count} цели",
"perf.targets_count.many": "{count} целей",
"perf.no_captures": "нет источников",
"perf.captures_count.one": "{count} источник",
"perf.captures_count.few": "{count} источника",
"perf.captures_count.many": "{count} источников",
"perf.ratio_of_requested": "{percent}% от запрошенного · {captures}",
"perf.total_count": "{count} всего",
"perf.skipped_per_sec": "{rate} пропущ/с",
"perf.tip.now": "сейчас",
"perf.tip.ago": "{seconds}с",
"device.last_seen.label": "Последний раз",
"device.last_seen.just_now": "только что",
"device.last_seen.seconds": "%d с назад",
@@ -506,6 +506,14 @@
"perf.max_ms": "最大 {ms}毫秒",
"perf.targets_count.one": "{count} 个目标",
"perf.targets_count.other": "{count} 个目标",
"perf.no_captures": "无源",
"perf.captures_count.one": "{count} 个源",
"perf.captures_count.other": "{count} 个源",
"perf.ratio_of_requested": "请求的 {percent}% · {captures}",
"perf.total_count": "共 {count}",
"perf.skipped_per_sec": "跳过 {rate}/秒",
"perf.tip.now": "现在",
"perf.tip.ago": "{seconds} 秒",
"device.last_seen.label": "最近检测",
"device.last_seen.just_now": "刚刚",
"device.last_seen.seconds": "%d秒前",