From f6486f9b34b26db7db9f91fd3a263bcfe1c82bf4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 26 May 2026 00:12:29 +0300 Subject: [PATCH] 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. --- .../ledgrab/static/js/features/dashboard.ts | 73 ++-- .../ledgrab/static/js/features/perf-charts.ts | 352 ++++++++++++------ server/src/ledgrab/static/locales/en.json | 8 + server/src/ledgrab/static/locales/ru.json | 9 + server/src/ledgrab/static/locales/zh.json | 8 + 5 files changed, 309 insertions(+), 141 deletions(-) diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 55b4687..726747f 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -160,26 +160,48 @@ function _createFpsChart(canvasId: string, actualHistory: number[], currentHisto } async function _initFpsCharts(runningTargetIds: string[]): Promise { - _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 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 `${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 { 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 { 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 { 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']); diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index 73c964b..b1b2c57 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -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 `
${cellsHtml}
`; } +/** 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 = `
+ sig = `empty:${hintKey}:${hintText}`; + nextHtml = `
${escapeText(hintText)}
`; - 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 `
+ + ${escapeText(r.name)} + ${fps} +
`; + }).join(''); + const overflow = Math.max(0, running.length - 4); + const more = overflow > 0 ? `
+${overflow} more
` : ''; + 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 `
- - ${escapeText(r.name)} - ${fps} -
`; - }).join(''); - const more = running.length > 4 ? `
+${running.length - 4} more
` : ''; - 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 = 'no captures'; - } else { - const fpsText = fps.toFixed(fps < 10 ? 1 : 0); - const ceilingSuffix = targetSum > 0 - ? `/ ${Math.round(targetSum)}` - : ''; - valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`; - } - } - 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 ``; + }).join(''); + const more = overflow > 0 ? `+${overflow}` : ''; + 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 ``; - }).join(''); - const more = overflow > 0 - ? `+${overflow}` - : ''; - 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(); + +/** 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 `` 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(``); + 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 = ` - `; + 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 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 ` - - `; - } - // App line: thinner, dashed, no fill - return ``; + 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 { 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 = 'no captures'; + valEl.innerHTML = `${t('perf.no_captures')}`; } 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.` 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 = { + 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 { ${_formatSampleValue(key, appValue)}
` : ''; - const ageLine = `
${ageSecs === 0 ? 'now' : `−${ageSecs}s`}
`; + const ageLine = `
${ageSecs === 0 ? t('perf.tip.now') : t('perf.tip.ago', { seconds: ageSecs })}
`; tip.innerHTML = `
${sysLine}
${appLine}${ageLine}`; tip.style.display = 'block'; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 414d7fb..1129b38 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -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", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index fa797c9..b8ab239 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -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 с назад", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index ebff042..8310f1b 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -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秒前",