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:
@@ -160,26 +160,48 @@ function _createFpsChart(canvasId: string, actualHistory: number[], currentHisto
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
|
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)
|
for (const id of toDestroy) {
|
||||||
if (runningTargetIds.length > 0) {
|
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();
|
const data = await fetchMetricsHistory();
|
||||||
if (data) {
|
if (data) {
|
||||||
const serverTargets = data.targets || {};
|
const serverTargets = data.targets || {};
|
||||||
for (const id of runningTargetIds) {
|
for (const id of needSeed) {
|
||||||
const samples = serverTargets[id] || [];
|
const samples = serverTargets[id] || [];
|
||||||
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
|
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
|
||||||
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
|
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const id of toCreate) {
|
||||||
// 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) {
|
|
||||||
const canvas = document.getElementById(`dashboard-fps-${id}`);
|
const canvas = document.getElementById(`dashboard-fps-${id}`);
|
||||||
if (!canvas) continue;
|
if (!canvas) continue;
|
||||||
const actualH = _fpsHistory[id] || [];
|
const actualH = _fpsHistory[id] || [];
|
||||||
@@ -815,23 +837,18 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
|
const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
|
||||||
updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount);
|
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 newRunningIds = running.map(t => t.id).sort().join(',');
|
||||||
const prevRunningIds = [..._lastRunningIds].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 hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
|
||||||
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds;
|
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds;
|
||||||
if (structureUnchanged && !forceFullRender && running.length > 0) {
|
if (structureUnchanged && !forceFullRender) {
|
||||||
_updateRunningMetrics(running);
|
|
||||||
_updateSyncClocksInPlace(syncClocks);
|
|
||||||
_updateIntegrationsInPlace(haStatus, mqttStatus);
|
|
||||||
_cacheUptimeElements();
|
|
||||||
_startUptimeTimer();
|
|
||||||
startPerfPolling();
|
|
||||||
set_dashboardLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (structureUnchanged && forceFullRender) {
|
|
||||||
if (running.length > 0) _updateRunningMetrics(running);
|
if (running.length > 0) _updateRunningMetrics(running);
|
||||||
_updateAutomationsInPlace(automations);
|
_updateAutomationsInPlace(automations);
|
||||||
_updateSyncClocksInPlace(syncClocks);
|
_updateSyncClocksInPlace(syncClocks);
|
||||||
@@ -981,7 +998,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
}
|
}
|
||||||
_mountDashboardCardModeToggles();
|
_mountDashboardCardModeToggles();
|
||||||
_lastRunningIds = runningIds;
|
_lastRunningIds = runningIds;
|
||||||
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
|
_lastSyncClockIds = syncClocks.map(c => c.id).sort().join(',');
|
||||||
_cacheUptimeElements();
|
_cacheUptimeElements();
|
||||||
await _initFpsCharts(runningIds);
|
await _initFpsCharts(runningIds);
|
||||||
_startUptimeTimer();
|
_startUptimeTimer();
|
||||||
@@ -1304,7 +1321,7 @@ export async function dashboardPauseClock(clockId: string): Promise<void> {
|
|||||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
showToast(t('sync_clock.paused'), 'success');
|
showToast(t('sync_clock.paused'), 'success');
|
||||||
loadDashboard(true);
|
loadDashboard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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' });
|
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
showToast(t('sync_clock.resumed'), 'success');
|
showToast(t('sync_clock.resumed'), 'success');
|
||||||
loadDashboard(true);
|
loadDashboard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
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' });
|
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
showToast(t('sync_clock.reset_done'), 'success');
|
showToast(t('sync_clock.reset_done'), 'success');
|
||||||
loadDashboard(true);
|
loadDashboard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.isAuth) return;
|
if (e.isAuth) return;
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -1352,7 +1369,7 @@ function _debouncedDashboardReload(forceFullRender: boolean = false): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
|
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());
|
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
|
||||||
|
|
||||||
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device', 'home_assistant_source']);
|
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.
|
* 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 { t } from '../core/i18n.ts';
|
||||||
import { dashboardPollInterval } from '../core/state.ts';
|
import { dashboardPollInterval } from '../core/state.ts';
|
||||||
import { isActiveTab } from '../core/tab-registry.ts';
|
import { isActiveTab } from '../core/tab-registry.ts';
|
||||||
@@ -397,6 +397,16 @@ export function renderPerfSection(): string {
|
|||||||
return `<div class="perf-charts-grid">${cellsHtml}</div>`;
|
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
|
/** Externally-called from dashboard.ts whenever the running-target set
|
||||||
* is recomputed. Updates the Active Patches cell with count + a short
|
* is recomputed. Updates the Active Patches cell with count + a short
|
||||||
* list of running channels and their current FPS. */
|
* list of running channels and their current FPS. */
|
||||||
@@ -412,6 +422,9 @@ export function updateActivePatches(
|
|||||||
|
|
||||||
const listEl = document.getElementById('perf-patches-list');
|
const listEl = document.getElementById('perf-patches-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
|
||||||
|
let nextHtml: string;
|
||||||
|
let sig: string;
|
||||||
if (running.length === 0) {
|
if (running.length === 0) {
|
||||||
// Empty-state hint — "Ready to launch" when targets exist but
|
// Empty-state hint — "Ready to launch" when targets exist but
|
||||||
// none are running, or "No patches yet" when the user hasn't
|
// 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.none'
|
||||||
: 'dashboard.perf.patches.empty.idle';
|
: 'dashboard.perf.patches.empty.idle';
|
||||||
const hintText = t(hintKey) || (totalCount === 0 ? 'No patches yet' : 'Ready to launch');
|
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-dot" aria-hidden="true"></span>
|
||||||
<span class="perf-patches-empty-text">${escapeText(hintText)}</span>
|
<span class="perf-patches-empty-text">${escapeText(hintText)}</span>
|
||||||
</div>`;
|
</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'];
|
if (listEl === _lastPatchesListEl && sig === _lastPatchesSig) return;
|
||||||
const colorVar = colors[i % colors.length];
|
_lastPatchesListEl = listEl;
|
||||||
const fps = r.fps != null ? `${r.fps.toFixed(1)} FPS` : '—';
|
_lastPatchesSig = sig;
|
||||||
return `<div class="perf-patches-row">
|
listEl.innerHTML = nextHtml;
|
||||||
<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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeText(s: string): string {
|
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 (_history.capture_fps_actual.length > MAX_SAMPLES) _history.capture_fps_actual.shift();
|
||||||
if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps;
|
if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps;
|
||||||
|
|
||||||
const valEl = document.getElementById('perf-capture_fps_actual-value');
|
_paintCaptureFpsActualValue(fps, targetSum, reportingCount);
|
||||||
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' : ''}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_renderChartSvg('capture_fps_actual', /*animate=*/true);
|
_renderChartSvg('capture_fps_actual', /*animate=*/true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,13 +740,18 @@ export function updateTotalErrors(
|
|||||||
const subEl = document.getElementById('perf-errors-sub');
|
const subEl = document.getElementById('perf-errors-sub');
|
||||||
if (subEl) {
|
if (subEl) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (totalErrors > 0) parts.push(`${totalErrors} total`);
|
if (totalErrors > 0) parts.push(t('perf.total_count', { count: totalErrors }));
|
||||||
if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
|
if (skippedRate >= 0.1) parts.push(t('perf.skipped_per_sec', { rate: skippedRate.toFixed(skippedRate < 10 ? 1 : 0) }));
|
||||||
subEl.textContent = parts.join(' · ');
|
subEl.textContent = parts.join(' · ');
|
||||||
}
|
}
|
||||||
_renderChartSvg('errors', /*animate=*/true);
|
_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
|
/** Devices cell — online / total count with a dot strip showing each
|
||||||
* device's connection state at a glance. */
|
* device's connection state at a glance. */
|
||||||
export function updateDevices(
|
export function updateDevices(
|
||||||
@@ -773,24 +776,34 @@ export function updateDevices(
|
|||||||
|
|
||||||
const dotsEl = document.getElementById('perf-devices-dots');
|
const dotsEl = document.getElementById('perf-devices-dots');
|
||||||
if (!dotsEl) return;
|
if (!dotsEl) return;
|
||||||
|
|
||||||
|
let nextHtml: string;
|
||||||
|
let sig: string;
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
dotsEl.innerHTML = '';
|
nextHtml = '';
|
||||||
return;
|
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;
|
if (dotsEl === _lastDevicesDotsEl && sig === _lastDevicesSig) return;
|
||||||
const shown = states.slice(0, MAX_DOTS);
|
_lastDevicesDotsEl = dotsEl;
|
||||||
const overflow = total - shown.length;
|
_lastDevicesSig = sig;
|
||||||
const dots = shown.map(s => {
|
dotsEl.innerHTML = nextHtml;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the global animations preference once per render — read from
|
/** 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
|
* `animate` triggers the smooth left-scroll animation — only set on
|
||||||
* paths that just pushed a fresh sample. Non-sample paths (mode
|
* 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 {
|
function _renderChartSvg(key: string, animate: boolean = false): void {
|
||||||
const host = document.getElementById(`perf-chart-${key}`);
|
const host = document.getElementById(`perf-chart-${key}`);
|
||||||
if (!host) return;
|
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
|
// Effective window (in seconds) for this cell — global default
|
||||||
// unless the cell pinned its own. With 1 sample/sec polling the
|
// unless the cell pinned its own. With 1 sample/sec polling the
|
||||||
// window in seconds equals the desired sample count; we trim 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)
|
: key === 'send_timing' ? Math.max(20, _sendTimingPeak * 1.2)
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
const paths: string[] = [];
|
const nodes = _ensureSparkNodes(host);
|
||||||
|
|
||||||
// FPS-only: dashed "target ceiling" reference line at the sum of
|
// FPS-only: dashed "target ceiling" reference line at the sum of
|
||||||
// fps_target across running targets, so the spark reads as "live
|
// 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) {
|
if (key === 'fps' && _fpsTargetSum > 0 && _fpsTargetSum <= yMax) {
|
||||||
const span = yMax - yMin || 1;
|
const span = yMax - yMin || 1;
|
||||||
const refY = SPARK_H - ((_fpsTargetSum - yMin) / span) * (SPARK_H - 2) - 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) {
|
if (showSystem && sys.length > 1) {
|
||||||
paths.push(_pathFor(sys, yMin, yMax, color, 'sys', sliceN));
|
const built = _buildPath(sys, yMin, yMax, sliceN);
|
||||||
}
|
nodes.area.setAttribute('d', built.area);
|
||||||
if (showApp && app.length > 1) {
|
nodes.area.setAttribute('fill', color);
|
||||||
paths.push(_pathFor(app, yMin, yMax, color, 'app', sliceN));
|
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) {
|
||||||
<svg class="perf-chart-svg" viewBox="0 0 ${SPARK_W} ${SPARK_H}" preserveAspectRatio="none" aria-hidden="true">
|
const built = _buildPath(app, yMin, yMax, sliceN);
|
||||||
<defs>
|
nodes.appLine.setAttribute('d', built.line);
|
||||||
<linearGradient id="perf-fade-${key}" x1="0" y1="0" x2="0" y2="1">
|
nodes.appLine.setAttribute('stroke', color);
|
||||||
<stop offset="0%" stop-color="${color}" stop-opacity="0.32"/>
|
nodes.appLine.style.display = '';
|
||||||
<stop offset="100%" stop-color="${color}" stop-opacity="0"/>
|
} else {
|
||||||
</linearGradient>
|
nodes.appLine.style.display = 'none';
|
||||||
</defs>
|
}
|
||||||
${paths.join('')}
|
|
||||||
</svg>`;
|
|
||||||
|
|
||||||
if (animate) _scrollSpark(host, sliceN);
|
if (animate) _scrollSpark(host, sliceN);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build <path> elements (area + stroke) for one series. */
|
/** Compute the line + area path `d` strings for one series. */
|
||||||
function _pathFor(history: number[], yMin: number, yMax: number, color: string, kind: 'sys' | 'app', sliceN: number = MAX_SAMPLES): string {
|
function _buildPath(history: number[], yMin: number, yMax: number, sliceN: number): { line: string; area: string } {
|
||||||
const n = history.length;
|
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 —
|
// Right-align so the most recent sample sits at the right edge —
|
||||||
// matches an instrument display where new values tick in from the
|
// matches an instrument display where new values tick in from the
|
||||||
// right. `sliceN` is the spark's logical sample-count "width" (set
|
// 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 firstX = offset;
|
||||||
const lastX = offset + (n - 1) * step;
|
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`;
|
const area = `M${firstX.toFixed(1)},${SPARK_H} L${points.join(' L')} L${lastX.toFixed(1)},${SPARK_H} Z`;
|
||||||
|
return { line, area };
|
||||||
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" />`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pushSample(key: string, sysValue: number, appValue: number | null): void {
|
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> {
|
async function _fetchPerformance(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
|
const resp = await fetchWithAuth('/system/performance');
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
_lastFetchData = data;
|
_lastFetchData = data;
|
||||||
_applyPerfDataToDom(data, /*pushHistory=*/true);
|
_applyPerfDataToDom(data, /*pushHistory=*/true);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Silently ignore transient fetch errors
|
// 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
|
// series. `null` samples (no devices online) become 0 in the
|
||||||
// history so the spark drops to floor instead of going jagged.
|
// history so the spark drops to floor instead of going jagged.
|
||||||
const latencySeries = samples
|
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) {
|
if (latencySeries.length > 0) {
|
||||||
_history.device_latency = latencySeries.slice(-MAX_SAMPLES);
|
_history.device_latency = latencySeries.slice(-MAX_SAMPLES);
|
||||||
_deviceLatencyPeak = Math.max(50, ..._history.device_latency);
|
_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
|
// the subtitle/tooltip but isn't a separate spark line to avoid
|
||||||
// adding visual noise.
|
// adding visual noise.
|
||||||
const sendSeries = samples
|
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) {
|
if (sendSeries.length > 0) {
|
||||||
_history.send_timing = sendSeries.slice(-MAX_SAMPLES);
|
_history.send_timing = sendSeries.slice(-MAX_SAMPLES);
|
||||||
const maxes = samples
|
const maxes = samples
|
||||||
@@ -1350,7 +1460,7 @@ function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCo
|
|||||||
const valEl = document.getElementById('perf-capture_fps_actual-value');
|
const valEl = document.getElementById('perf-capture_fps_actual-value');
|
||||||
if (valEl) {
|
if (valEl) {
|
||||||
if (reportingCount === 0) {
|
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 {
|
} else {
|
||||||
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
|
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
|
||||||
const ceilingSuffix = targetSum > 0
|
const ceilingSuffix = targetSum > 0
|
||||||
@@ -1363,11 +1473,14 @@ function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCo
|
|||||||
if (subEl) {
|
if (subEl) {
|
||||||
if (reportingCount === 0) {
|
if (reportingCount === 0) {
|
||||||
subEl.textContent = '';
|
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 {
|
} 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');
|
const subEl = document.getElementById('perf-errors-sub');
|
||||||
if (subEl) {
|
if (subEl) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (totalErrors > 0) parts.push(`${totalErrors} total`);
|
if (totalErrors > 0) parts.push(t('perf.total_count', { count: totalErrors }));
|
||||||
if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
|
if (skippedRate >= 0.1) parts.push(t('perf.skipped_per_sec', { rate: skippedRate.toFixed(skippedRate < 10 ? 1 : 0) }));
|
||||||
subEl.textContent = parts.join(' · ');
|
subEl.textContent = parts.join(' · ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1614,31 +1727,44 @@ function _formatSampleValue(key: string, v: number): string {
|
|||||||
return `${v.toFixed(1)}%`;
|
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 {
|
function _metricLabel(key: string): string {
|
||||||
if (key === 'cpu') return 'CPU';
|
const labelKey = METRIC_LABEL_KEYS[key];
|
||||||
if (key === 'ram') return 'RAM';
|
if (!labelKey) return key.toUpperCase();
|
||||||
if (key === 'gpu') return 'GPU';
|
const translated = t(labelKey);
|
||||||
if (key === 'temp') return 'Temp';
|
return translated === labelKey ? key.toUpperCase() : translated;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _tooltipBound = false;
|
let _tooltipBound = false;
|
||||||
function _initSparkTooltip(): void {
|
function _initSparkTooltip(): void {
|
||||||
if (_tooltipBound) return;
|
if (_tooltipBound) return;
|
||||||
_tooltipBound = true;
|
_tooltipBound = true;
|
||||||
const intervalMs = dashboardPollInterval || 2000;
|
|
||||||
// Bound on `document.body` instead of `.perf-charts-grid` so the
|
// Bound on `document.body` instead of `.perf-charts-grid` so the
|
||||||
// listener survives `rerenderPerfGrid()` replacing the grid element.
|
// listener survives `rerenderPerfGrid()` replacing the grid element.
|
||||||
// The handler bails out unless the cursor is actually over a spark,
|
// The handler bails out unless the cursor is actually over a spark,
|
||||||
// so the hot-path cost is just one `closest()` call per mousemove.
|
// so the hot-path cost is just one `closest()` call per mousemove.
|
||||||
document.body.addEventListener('mousemove', (rawEv) => {
|
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 ev = rawEv as MouseEvent;
|
||||||
const target = ev.target as HTMLElement;
|
const target = ev.target as HTMLElement;
|
||||||
if (!target || !target.closest) { _hideTooltip(); return; }
|
if (!target || !target.closest) { _hideTooltip(); return; }
|
||||||
@@ -1693,7 +1819,7 @@ function _initSparkTooltip(): void {
|
|||||||
<span class="perf-tip-v">${_formatSampleValue(key, appValue)}</span>
|
<span class="perf-tip-v">${_formatSampleValue(key, appValue)}</span>
|
||||||
</div>`
|
</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.innerHTML = `<div class="perf-tip-row">${sysLine}</div>${appLine}${ageLine}`;
|
||||||
tip.style.display = 'block';
|
tip.style.display = 'block';
|
||||||
|
|
||||||
|
|||||||
@@ -453,6 +453,14 @@
|
|||||||
"perf.max_ms": "max {ms}ms",
|
"perf.max_ms": "max {ms}ms",
|
||||||
"perf.targets_count.one": "{count} target",
|
"perf.targets_count.one": "{count} target",
|
||||||
"perf.targets_count.other": "{count} targets",
|
"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.label": "Last seen",
|
||||||
"device.last_seen.just_now": "just now",
|
"device.last_seen.just_now": "just now",
|
||||||
"device.last_seen.seconds": "%ds ago",
|
"device.last_seen.seconds": "%ds ago",
|
||||||
|
|||||||
@@ -509,6 +509,15 @@
|
|||||||
"perf.targets_count.one": "{count} цель",
|
"perf.targets_count.one": "{count} цель",
|
||||||
"perf.targets_count.few": "{count} цели",
|
"perf.targets_count.few": "{count} цели",
|
||||||
"perf.targets_count.many": "{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.label": "Последний раз",
|
||||||
"device.last_seen.just_now": "только что",
|
"device.last_seen.just_now": "только что",
|
||||||
"device.last_seen.seconds": "%d с назад",
|
"device.last_seen.seconds": "%d с назад",
|
||||||
|
|||||||
@@ -506,6 +506,14 @@
|
|||||||
"perf.max_ms": "最大 {ms}毫秒",
|
"perf.max_ms": "最大 {ms}毫秒",
|
||||||
"perf.targets_count.one": "{count} 个目标",
|
"perf.targets_count.one": "{count} 个目标",
|
||||||
"perf.targets_count.other": "{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.label": "最近检测",
|
||||||
"device.last_seen.just_now": "刚刚",
|
"device.last_seen.just_now": "刚刚",
|
||||||
"device.last_seen.seconds": "%d秒前",
|
"device.last_seen.seconds": "%d秒前",
|
||||||
|
|||||||
Reference in New Issue
Block a user