@@ -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' ;