fix(stage-web): improve performance visualizer (#2344)
This commit is contained in:
@@ -71,6 +71,7 @@
|
|||||||
"driver.js": "catalog:",
|
"driver.js": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"embla-carousel-vue": "catalog:",
|
"embla-carousel-vue": "catalog:",
|
||||||
|
"es-toolkit": "catalog:",
|
||||||
"gpuu": "catalog:",
|
"gpuu": "catalog:",
|
||||||
"hono": "catalog:",
|
"hono": "catalog:",
|
||||||
"html2canvas": "catalog:",
|
"html2canvas": "catalog:",
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { LagMetric } from '../../stores/devtools-lag'
|
import type { LagMetric } from '../../stores/devtools-lag'
|
||||||
|
|
||||||
|
import { Button, IconButton } from '@proj-airi/ui'
|
||||||
|
import { useDraggable, useElementBounding } from '@vueuse/core'
|
||||||
|
import { clamp } from 'es-toolkit/math'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
||||||
|
|
||||||
|
const { n, t } = useI18n()
|
||||||
const store = useDevtoolsLagStore()
|
const store = useDevtoolsLagStore()
|
||||||
const { enabled, buffers, recording } = storeToRefs(store)
|
const { enabled, buffers, lastRecording, recording, recordingElapsedMs, supported } = storeToRefs(store)
|
||||||
|
|
||||||
const hovered = ref(false)
|
const dragBoundary = useTemplateRef<HTMLElement>('dragBoundary')
|
||||||
|
const overlay = useTemplateRef<HTMLElement>('overlay')
|
||||||
|
const dragHandle = useTemplateRef<HTMLElement>('dragHandle')
|
||||||
|
const positionInitialized = shallowRef(false)
|
||||||
|
|
||||||
const metrics: Array<{ key: LagMetric, label: string, enabled: () => boolean }> = [
|
const metrics: Array<{ key: LagMetric, labelKey: string }> = [
|
||||||
{ key: 'fps', label: 'FPS', enabled: () => enabled.value.fps },
|
{
|
||||||
{ key: 'frameDuration', label: 'Frame (ms)', enabled: () => enabled.value.frameDuration },
|
key: 'fps',
|
||||||
{ key: 'longtask', label: 'Long task (ms)', enabled: () => enabled.value.longtask },
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.fps.label',
|
||||||
{ key: 'memory', label: 'Memory (MB)', enabled: () => enabled.value.memory },
|
},
|
||||||
|
{
|
||||||
|
key: 'frameDuration',
|
||||||
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.frame-duration.label',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'longtask',
|
||||||
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.label',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memory',
|
||||||
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.label',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const visibleMetrics = computed(() => metrics.filter(metric => metric.enabled()))
|
const visibleMetrics = computed(() => metrics.filter(metric => (
|
||||||
|
enabled.value[metric.key] && supported.value[metric.key]
|
||||||
|
)))
|
||||||
const hasAnyEnabled = computed(() => visibleMetrics.value.length > 0)
|
const hasAnyEnabled = computed(() => visibleMetrics.value.length > 0)
|
||||||
const metricStatsMap = computed<Record<LagMetric, ReturnType<typeof store.calcStats>>>(() => {
|
const metricStatsMap = computed<Record<LagMetric, ReturnType<typeof store.calcStats>>>(() => {
|
||||||
const result = {} as Record<LagMetric, ReturnType<typeof store.calcStats>>
|
const result = {} as Record<LagMetric, ReturnType<typeof store.calcStats>>
|
||||||
@@ -30,20 +52,66 @@ const metricStatsMap = computed<Record<LagMetric, ReturnType<typeof store.calcSt
|
|||||||
})
|
})
|
||||||
const metricsWithStats = computed(() => visibleMetrics.value.map(metric => ({
|
const metricsWithStats = computed(() => visibleMetrics.value.map(metric => ({
|
||||||
...metric,
|
...metric,
|
||||||
|
hasSamples: buffers.value[metric.key].length > 0,
|
||||||
stats: metricStatsMap.value[metric.key],
|
stats: metricStatsMap.value[metric.key],
|
||||||
})))
|
})))
|
||||||
|
const recordingElapsedSeconds = computed(() => Math.min(60, Math.floor(recordingElapsedMs.value / 1000)))
|
||||||
|
const recordingButtonLabel = computed(() => recording.value
|
||||||
|
? t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.stop')
|
||||||
|
: t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.record'))
|
||||||
|
|
||||||
function formatValue(metric: string, value: number) {
|
const { width: boundaryWidth, height: boundaryHeight } = useElementBounding(dragBoundary)
|
||||||
|
const { width: overlayWidth, height: overlayHeight } = useElementBounding(overlay)
|
||||||
|
const { position, isDragging } = useDraggable(overlay, {
|
||||||
|
containerElement: dragBoundary,
|
||||||
|
handle: dragHandle,
|
||||||
|
preventDefault: true,
|
||||||
|
restrictInView: true,
|
||||||
|
onEnd() {
|
||||||
|
positionInitialized.value = true
|
||||||
|
clampPosition()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const overlayPosition = computed(() => ({
|
||||||
|
left: `${position.value.x}px`,
|
||||||
|
top: `${position.value.y}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[hasAnyEnabled, boundaryWidth, boundaryHeight, overlayWidth, overlayHeight],
|
||||||
|
([visible, availableWidth, availableHeight, currentWidth, currentHeight]) => {
|
||||||
|
if (!visible || availableWidth <= 0 || availableHeight <= 0 || currentWidth <= 0 || currentHeight <= 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!positionInitialized.value) {
|
||||||
|
resetPosition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clampPosition()
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatValue(metric: LagMetric, value: number) {
|
||||||
if (!Number.isFinite(value))
|
if (!Number.isFinite(value))
|
||||||
return '--'
|
return '--'
|
||||||
|
|
||||||
if (metric === 'memory')
|
if (metric === 'memory') {
|
||||||
return `${(value / 1048576).toFixed(1)}`
|
return n(value / 1048576, {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (metric === 'fps')
|
if (metric === 'fps')
|
||||||
return value.toFixed(0)
|
return n(value, { maximumFractionDigits: 0 })
|
||||||
|
|
||||||
return value.toFixed(1)
|
return n(value, {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function barSeries(metric: LagMetric) {
|
function barSeries(metric: LagMetric) {
|
||||||
@@ -56,72 +124,133 @@ function barSeries(metric: LagMetric) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRecording() {
|
function clampPosition(nextX = position.value.x, nextY = position.value.y) {
|
||||||
if (recording.value) {
|
const maxX = Math.max(0, boundaryWidth.value - overlayWidth.value)
|
||||||
const snapshot = store.stopRecording()
|
const maxY = Math.max(0, boundaryHeight.value - overlayHeight.value)
|
||||||
if (snapshot)
|
|
||||||
store.exportCsv(snapshot)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
store.startRecording()
|
position.value = {
|
||||||
|
x: clamp(nextX, 0, maxX),
|
||||||
|
y: clamp(nextY, 0, maxY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPosition() {
|
||||||
|
clampPosition(0, boundaryHeight.value - overlayHeight.value)
|
||||||
|
positionInitialized.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<Teleport to="body">
|
||||||
v-if="hasAnyEnabled"
|
<div
|
||||||
:style="{ opacity: hovered ? 1 : 0.3 }"
|
v-if="hasAnyEnabled"
|
||||||
class="fixed bottom-3 left-3 z-50"
|
ref="dragBoundary"
|
||||||
p-3
|
:style="{
|
||||||
flex="~ col gap-2"
|
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
|
||||||
rounded="xl"
|
right: 'calc(env(safe-area-inset-right, 0px) + 0.75rem)',
|
||||||
bg="neutral-900/80"
|
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 0.75rem)',
|
||||||
text="white sm"
|
left: 'calc(env(safe-area-inset-left, 0px) + 0.75rem)',
|
||||||
shadow="xl"
|
}"
|
||||||
transition="opacity 200ms ease"
|
:class="['pointer-events-none fixed z-[900]']"
|
||||||
@mouseenter="hovered = true"
|
>
|
||||||
@mouseleave="hovered = false"
|
<section
|
||||||
>
|
ref="overlay"
|
||||||
<div flex="~ row items-center gap-2" justify-between>
|
data-testid="performance-overlay"
|
||||||
<div text="xs neutral-200" uppercase tracking="wide">
|
:style="overlayPosition"
|
||||||
Performance Overlay
|
:class="[
|
||||||
</div>
|
'pointer-events-auto absolute w-72 max-h-full max-w-full overflow-y-auto rounded-xl p-3',
|
||||||
<button
|
'flex flex-col gap-2',
|
||||||
type="button"
|
'bg-neutral-950/92 text-sm text-white shadow-xl backdrop-blur-lg',
|
||||||
class="inline-flex items-center gap-1 rounded bg-white/10 px-2 py-1 text-xs transition-colors hover:bg-white/20"
|
'transition-opacity duration-150 ease-out motion-reduce:transition-none',
|
||||||
@click="toggleRecording"
|
positionInitialized ? 'opacity-100' : 'opacity-0',
|
||||||
|
isDragging ? 'select-none' : '',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<span
|
<header :class="['flex items-center gap-2']">
|
||||||
class="inline-block h-2 w-2 rounded-full"
|
<div
|
||||||
:class="recording ? 'bg-red-400' : 'bg-neutral-400'"
|
ref="dragHandle"
|
||||||
/>
|
:class="[
|
||||||
<span>{{ recording ? 'Stop' : 'Record' }}</span>
|
'shrink-0 touch-none select-none',
|
||||||
</button>
|
isDragging ? 'cursor-grabbing' : 'cursor-grab',
|
||||||
</div>
|
]"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="i-solar:menu-dots-bold-duotone"
|
||||||
|
:aria-label="t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.move')"
|
||||||
|
:title="t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.move')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="['min-w-0 flex-1 truncate text-xs font-medium text-neutral-200 uppercase tracking-wide']">
|
||||||
|
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.title') }}
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
icon="i-solar:restart-bold-duotone"
|
||||||
|
:aria-label="t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.reset-position')"
|
||||||
|
:title="t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.reset-position')"
|
||||||
|
@click="resetPosition"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div v-for="metric in metricsWithStats" :key="metric.key" flex="~ col gap-1">
|
|
||||||
<div flex="~ row items-center justify-between">
|
|
||||||
<span text="xs neutral-100">{{ metric.label }}</span>
|
|
||||||
<span text="xs neutral-300">
|
|
||||||
<template v-if="metric.stats.latest">
|
|
||||||
avg {{ formatValue(metric.key, metric.stats.avg) }}
|
|
||||||
/
|
|
||||||
p95 {{ formatValue(metric.key, metric.stats.p95) }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
--
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-10 flex items-end gap-0.5 overflow-hidden rounded bg-white/5 px-1 py-1">
|
|
||||||
<div
|
<div
|
||||||
v-for="(bar, index) in barSeries(metric.key)"
|
v-for="metric in metricsWithStats"
|
||||||
:key="index"
|
:key="metric.key"
|
||||||
:style="{ width: bar.width, height: bar.height }"
|
:class="['flex flex-col gap-1']"
|
||||||
class="bg-white/50"
|
>
|
||||||
/>
|
<div :class="['flex items-center justify-between gap-3']">
|
||||||
</div>
|
<span :class="['min-w-0 truncate text-xs text-neutral-100']">
|
||||||
|
{{ t(metric.labelKey) }}
|
||||||
|
</span>
|
||||||
|
<span :class="['shrink-0 font-mono text-xs text-neutral-300 tabular-nums']">
|
||||||
|
<template v-if="metric.hasSamples">
|
||||||
|
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.average') }}
|
||||||
|
{{ formatValue(metric.key, metric.stats.avg) }}
|
||||||
|
/
|
||||||
|
p95 {{ formatValue(metric.key, metric.stats.p95) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
--
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['h-10 overflow-hidden rounded bg-white/5 px-1 py-1', 'flex items-end gap-0.5']"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(bar, index) in barSeries(metric.key)"
|
||||||
|
:key="index"
|
||||||
|
:style="{ width: bar.width, height: bar.height }"
|
||||||
|
:class="['bg-white/50']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer :class="['flex items-center gap-2 pt-2']">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:icon="recording ? 'i-solar:stop-circle-bold-duotone' : 'i-solar:record-circle-bold-duotone'"
|
||||||
|
:label="recordingButtonLabel"
|
||||||
|
:color="recording ? 'red' : 'neutral'"
|
||||||
|
:outline="false"
|
||||||
|
@click="store.toggleRecording"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="recording"
|
||||||
|
:class="['font-mono text-xs text-neutral-300 tabular-nums']"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ n(recordingElapsedSeconds) }}s / 60s
|
||||||
|
</span>
|
||||||
|
<span v-else :class="['flex-1']" />
|
||||||
|
<IconButton
|
||||||
|
icon="i-solar:export-bold-duotone"
|
||||||
|
:disabled="!lastRecording"
|
||||||
|
:aria-label="t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.export')"
|
||||||
|
:title="t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.export')"
|
||||||
|
@click="store.exportCsv()"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import en from '@proj-airi/i18n/locales/en'
|
||||||
|
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render } from 'vitest-browser-vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import PerformanceOverlay from './PerformanceOverlay.vue'
|
||||||
|
|
||||||
|
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
||||||
|
|
||||||
|
import 'virtual:uno.css'
|
||||||
|
|
||||||
|
describe('performance overlay recording controls', () => {
|
||||||
|
it('keeps CSV export separate from stopping a recording', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The overlay exported a CSV file when the user stopped a recording.
|
||||||
|
// The settings page only saved the recording, so the same action had two
|
||||||
|
// different outcomes. We keep export as a separate explicit action.
|
||||||
|
const pinia = createPinia()
|
||||||
|
const store = useDevtoolsLagStore(pinia)
|
||||||
|
const exportCsv = vi.spyOn(store, 'exportCsv')
|
||||||
|
store.enabled.fps = true
|
||||||
|
|
||||||
|
const screen = await render(PerformanceOverlay, {
|
||||||
|
global: {
|
||||||
|
plugins: [
|
||||||
|
pinia,
|
||||||
|
createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: 'Record', exact: true }).click()
|
||||||
|
expect(store.recording).toBe(true)
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: 'Stop', exact: true }).click()
|
||||||
|
expect(store.recording).toBe(false)
|
||||||
|
expect(exportCsv).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
store.toggleAll(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,12 +7,28 @@ interface LagEnabled {
|
|||||||
memory: boolean
|
memory: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LagMetricSupport = Readonly<Record<keyof LagEnabled, boolean>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a browser-local sampler for live performance metrics.
|
||||||
|
*
|
||||||
|
* Unsupported metrics stay disabled because their Web APIs do not produce
|
||||||
|
* comparable fallback values.
|
||||||
|
*/
|
||||||
export function createLagSampler(tracer: PerfTracer) {
|
export function createLagSampler(tracer: PerfTracer) {
|
||||||
let rafId: number | undefined
|
let rafId: number | undefined
|
||||||
let lastTs: number | undefined
|
let lastTs: number | undefined
|
||||||
let longTaskObserver: PerformanceObserver | undefined
|
let longTaskObserver: PerformanceObserver | undefined
|
||||||
let memoryTimer: ReturnType<typeof setInterval> | undefined
|
let memoryTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
const supported: LagMetricSupport = {
|
||||||
|
fps: typeof requestAnimationFrame === 'function',
|
||||||
|
frameDuration: typeof requestAnimationFrame === 'function',
|
||||||
|
longtask: typeof PerformanceObserver !== 'undefined'
|
||||||
|
&& PerformanceObserver.supportedEntryTypes.includes('longtask'),
|
||||||
|
memory: typeof performance !== 'undefined' && 'memory' in performance,
|
||||||
|
}
|
||||||
|
|
||||||
function stopRaf() {
|
function stopRaf() {
|
||||||
if (rafId !== undefined) {
|
if (rafId !== undefined) {
|
||||||
cancelAnimationFrame(rafId)
|
cancelAnimationFrame(rafId)
|
||||||
@@ -58,7 +74,7 @@ export function createLagSampler(tracer: PerfTracer) {
|
|||||||
|
|
||||||
function startLongTaskObserver() {
|
function startLongTaskObserver() {
|
||||||
stopLongTaskObserver()
|
stopLongTaskObserver()
|
||||||
if (!('PerformanceObserver' in window))
|
if (!supported.longtask)
|
||||||
return
|
return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -89,7 +105,7 @@ export function createLagSampler(tracer: PerfTracer) {
|
|||||||
function startMemoryTimer() {
|
function startMemoryTimer() {
|
||||||
stopMemoryTimer()
|
stopMemoryTimer()
|
||||||
const perfWithMemory = performance as Performance & { memory?: { usedJSHeapSize: number } }
|
const perfWithMemory = performance as Performance & { memory?: { usedJSHeapSize: number } }
|
||||||
if (!perfWithMemory.memory)
|
if (!supported.memory || !perfWithMemory.memory)
|
||||||
return
|
return
|
||||||
|
|
||||||
memoryTimer = setInterval(() => {
|
memoryTimer = setInterval(() => {
|
||||||
@@ -105,13 +121,13 @@ export function createLagSampler(tracer: PerfTracer) {
|
|||||||
function start(enabled: LagEnabled) {
|
function start(enabled: LagEnabled) {
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
if (enabled.fps || enabled.frameDuration)
|
if ((enabled.fps && supported.fps) || (enabled.frameDuration && supported.frameDuration))
|
||||||
startRaf()
|
startRaf()
|
||||||
|
|
||||||
if (enabled.longtask)
|
if (enabled.longtask && supported.longtask)
|
||||||
startLongTaskObserver()
|
startLongTaskObserver()
|
||||||
|
|
||||||
if (enabled.memory)
|
if (enabled.memory && supported.memory)
|
||||||
startMemoryTimer()
|
startMemoryTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +138,7 @@ export function createLagSampler(tracer: PerfTracer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
supported,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,161 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ButtonBar, CheckBar } from '@proj-airi/stage-ui/components'
|
import type { LagMetric } from '../../stores/devtools-lag'
|
||||||
|
|
||||||
|
import { Button, FieldCheckbox } from '@proj-airi/ui'
|
||||||
import { useMagicKeys, whenever } from '@vueuse/core'
|
import { useMagicKeys, whenever } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
import { useDevtoolsLagStore } from '../../stores/devtools-lag'
|
||||||
|
|
||||||
|
const { n, t } = useI18n()
|
||||||
const lagStore = useDevtoolsLagStore()
|
const lagStore = useDevtoolsLagStore()
|
||||||
const { enabled, lastRecording, recording } = storeToRefs(lagStore)
|
const { enabled, lastRecording, recording, recordingElapsedMs, supported } = storeToRefs(lagStore)
|
||||||
|
|
||||||
const recordingLabel = computed(() => recording.value ? 'Stop recording (max 60s)' : 'Start recording')
|
const metricDefinitions: Array<{
|
||||||
|
key: LagMetric
|
||||||
|
labelKey: string
|
||||||
|
descriptionKey: string
|
||||||
|
unsupportedKey?: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'fps',
|
||||||
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.fps.label',
|
||||||
|
descriptionKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.fps.description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'frameDuration',
|
||||||
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.frame-duration.label',
|
||||||
|
descriptionKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.frame-duration.description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'longtask',
|
||||||
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.label',
|
||||||
|
descriptionKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.description',
|
||||||
|
unsupportedKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.unsupported',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memory',
|
||||||
|
labelKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.label',
|
||||||
|
descriptionKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.description',
|
||||||
|
unsupportedKey: 'tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.unsupported',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const metricControls = computed(() => metricDefinitions.map(metric => ({
|
||||||
|
...metric,
|
||||||
|
supported: supported.value[metric.key],
|
||||||
|
})))
|
||||||
|
const recordingElapsedSeconds = computed(() => Math.min(60, Math.floor(recordingElapsedMs.value / 1000)))
|
||||||
|
const recordingLabel = computed(() => recording.value
|
||||||
|
? t('tamagotchi.settings.devtools.pages.performance-visualizer.controls.stop-recording', { seconds: recordingElapsedSeconds.value })
|
||||||
|
: t('tamagotchi.settings.devtools.pages.performance-visualizer.controls.start-recording'))
|
||||||
const hasRecording = computed(() => !!lastRecording.value)
|
const hasRecording = computed(() => !!lastRecording.value)
|
||||||
const allEnabled = computed(() => enabled.value.fps && enabled.value.frameDuration && enabled.value.longtask && enabled.value.memory)
|
const allEnabled = computed({
|
||||||
|
get() {
|
||||||
|
const supportedMetrics = metricDefinitions.filter(metric => supported.value[metric.key])
|
||||||
|
return supportedMetrics.length > 0
|
||||||
|
&& supportedMetrics.every(metric => enabled.value[metric.key])
|
||||||
|
},
|
||||||
|
set(value: boolean) {
|
||||||
|
lagStore.toggleAll(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const magicKeys = useMagicKeys()
|
const magicKeys = useMagicKeys()
|
||||||
whenever(magicKeys['ctrl+alt+l'], () => toggleAll(true))
|
whenever(magicKeys['ctrl+alt+l'], () => lagStore.toggleAll(true))
|
||||||
whenever(magicKeys['ctrl+alt+k'], () => toggleAll(false))
|
whenever(magicKeys['ctrl+alt+k'], () => lagStore.toggleAll(false))
|
||||||
|
|
||||||
function toggleAll(on: boolean) {
|
|
||||||
lagStore.toggleAll(on)
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportCsv() {
|
function exportCsv() {
|
||||||
lagStore.exportCsv()
|
lagStore.exportCsv()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function metricDescription(metric: typeof metricControls.value[number]) {
|
||||||
|
if (metric.supported || !metric.unsupportedKey)
|
||||||
|
return t(metric.descriptionKey)
|
||||||
|
|
||||||
|
return t(metric.unsupportedKey)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex="~ col gap-4" pb-6>
|
<div :class="['flex flex-col gap-4', 'pb-6']">
|
||||||
<div flex="~ col gap-2">
|
<section
|
||||||
<div flex="~ row items-center gap-2">
|
:class="[
|
||||||
<CheckBar
|
'rounded-2xl p-4',
|
||||||
:model-value="allEnabled"
|
'bg-neutral-50/80 dark:bg-neutral-900/50',
|
||||||
icon-on="i-solar:sledgehammer-bold-duotone"
|
]"
|
||||||
icon-off="i-solar:sledgehammer-bold-duotone"
|
>
|
||||||
text="Enable all metrics"
|
<FieldCheckbox
|
||||||
description="Toggle all lag metrics (FPS, frame time, long task, memory)"
|
v-model="allEnabled"
|
||||||
@update:model-value="value => toggleAll(Boolean(value))"
|
:label="t('tamagotchi.settings.devtools.pages.performance-visualizer.controls.enable-all.label')"
|
||||||
/>
|
:description="t('tamagotchi.settings.devtools.pages.performance-visualizer.controls.enable-all.description')"
|
||||||
<ButtonBar
|
/>
|
||||||
:icon="recording ? 'i-solar:stop-circle-bold-duotone' : 'i-solar:recive-bold-duotone'"
|
</section>
|
||||||
text="Recording"
|
|
||||||
@click="recording ? lagStore.stopRecording() : lagStore.startRecording()"
|
|
||||||
>
|
|
||||||
{{ recordingLabel }}
|
|
||||||
</ButtonBar>
|
|
||||||
<ButtonBar
|
|
||||||
icon="i-solar:export-bold-duotone"
|
|
||||||
text="Export CSV"
|
|
||||||
:disabled="!hasRecording"
|
|
||||||
@click="exportCsv"
|
|
||||||
>
|
|
||||||
Export last recording
|
|
||||||
</ButtonBar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div flex="~ col gap-2">
|
<div :class="['flex flex-wrap items-center gap-2']">
|
||||||
<CheckBar
|
<Button
|
||||||
v-model="enabled.fps"
|
:icon="recording ? 'i-solar:stop-circle-bold-duotone' : 'i-solar:record-circle-bold-duotone'"
|
||||||
icon-on="i-solar:activity-bold-duotone"
|
:label="recordingLabel"
|
||||||
icon-off="i-solar:activity-bold-duotone"
|
:color="recording ? 'red' : 'primary'"
|
||||||
text="FPS"
|
variant="secondary"
|
||||||
description="Collect FPS histogram"
|
@click="lagStore.toggleRecording"
|
||||||
/>
|
/>
|
||||||
<CheckBar
|
<Button
|
||||||
v-model="enabled.frameDuration"
|
icon="i-solar:export-bold-duotone"
|
||||||
icon-on="i-solar:chart-bold-duotone"
|
:label="t('tamagotchi.settings.devtools.pages.performance-visualizer.controls.export-last-recording')"
|
||||||
icon-off="i-solar:chart-bold-duotone"
|
:disabled="!hasRecording"
|
||||||
text="Frame time (ms)"
|
@click="exportCsv"
|
||||||
description="Collect frame duration histogram"
|
/>
|
||||||
/>
|
<span :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
||||||
<CheckBar
|
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.recording.limit') }}
|
||||||
v-model="enabled.longtask"
|
</span>
|
||||||
icon-on="i-solar:timer-bold-duotone"
|
|
||||||
icon-off="i-solar:timer-bold-duotone"
|
|
||||||
text="Long tasks"
|
|
||||||
description="PerformanceObserver('longtask')"
|
|
||||||
/>
|
|
||||||
<CheckBar
|
|
||||||
v-model="enabled.memory"
|
|
||||||
icon-on="i-solar:database-bold-duotone"
|
|
||||||
icon-off="i-solar:database-bold-duotone"
|
|
||||||
text="Memory"
|
|
||||||
description="Sample performance.memory every second"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasRecording" flex="~ col gap-2" rounded="lg" border="1 dashed neutral-700" p-3>
|
<div :class="['grid gap-3', 'md:grid-cols-2']">
|
||||||
<div text="sm neutral-200">
|
<section
|
||||||
Last recording
|
v-for="metric in metricControls"
|
||||||
</div>
|
:key="metric.key"
|
||||||
<div text="xs neutral-400">
|
:class="[
|
||||||
Started at {{ lastRecording?.startedAt.toFixed(0) }} ms, duration
|
'min-w-0 rounded-2xl p-4',
|
||||||
{{ (lastRecording!.stoppedAt - lastRecording!.startedAt).toFixed(0) }} ms
|
'bg-neutral-50/80 dark:bg-neutral-900/50',
|
||||||
</div>
|
]"
|
||||||
<div text="xs neutral-400">
|
>
|
||||||
Samples:
|
<FieldCheckbox
|
||||||
FPS {{ lastRecording?.samples.fps.length }},
|
v-model="enabled[metric.key]"
|
||||||
Frames {{ lastRecording?.samples.frameDuration.length }},
|
:label="t(metric.labelKey)"
|
||||||
Long tasks {{ lastRecording?.samples.longtask.length }},
|
:description="metricDescription(metric)"
|
||||||
Memory {{ lastRecording?.samples.memory.length }}
|
:disabled="!metric.supported"
|
||||||
</div>
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div text="xs neutral-500">
|
<section
|
||||||
Overlay is visible when any metric is enabled. Recording caps at 60s.
|
v-if="lastRecording"
|
||||||
|
:class="[
|
||||||
|
'flex flex-col gap-2 rounded-2xl p-4',
|
||||||
|
'border border-dashed border-neutral-300 dark:border-neutral-700',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div :class="['text-sm font-medium text-neutral-800 dark:text-neutral-200']">
|
||||||
|
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.recording.title') }}
|
||||||
|
</div>
|
||||||
|
<div :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
||||||
|
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.recording.duration', {
|
||||||
|
duration: n(lastRecording.stoppedAt - lastRecording.startedAt, { maximumFractionDigits: 0 }),
|
||||||
|
}) }}
|
||||||
|
</div>
|
||||||
|
<div :class="['grid gap-1 text-xs text-neutral-500 dark:text-neutral-400', 'sm:grid-cols-2 lg:grid-cols-4']">
|
||||||
|
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.fps.label') }}: {{ n(lastRecording.samples.fps.length) }}</span>
|
||||||
|
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.frame-duration.label') }}: {{ n(lastRecording.samples.frameDuration.length) }}</span>
|
||||||
|
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.label') }}: {{ n(lastRecording.samples.longtask.length) }}</span>
|
||||||
|
<span>{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.label') }}: {{ n(lastRecording.samples.memory.length) }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
||||||
|
{{ t('tamagotchi.settings.devtools.pages.performance-visualizer.description') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { TraceEvent } from '@proj-airi/stage-shared'
|
|||||||
|
|
||||||
import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-shared'
|
import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-shared'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { onScopeDispose, reactive, ref, shallowRef, watch } from 'vue'
|
||||||
|
|
||||||
import { createLagSampler } from '../composables/perf/register-lag-sampler'
|
import { createLagSampler } from '../composables/perf/register-lag-sampler'
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export type LagMetric = 'fps' | 'frameDuration' | 'longtask' | 'memory'
|
|||||||
interface Sample {
|
interface Sample {
|
||||||
ts: number
|
ts: number
|
||||||
value: number
|
value: number
|
||||||
meta?: Record<string, any>
|
meta?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecordingSnapshot {
|
interface RecordingSnapshot {
|
||||||
@@ -49,7 +49,7 @@ function calcStats(values: number[]) {
|
|||||||
const sorted = [...values].sort((a, b) => a - b)
|
const sorted = [...values].sort((a, b) => a - b)
|
||||||
const idx = Math.max(0, Math.floor(0.95 * (sorted.length - 1)))
|
const idx = Math.max(0, Math.floor(0.95 * (sorted.length - 1)))
|
||||||
const p95 = sorted[idx]
|
const p95 = sorted[idx]
|
||||||
const latest = values.at(-1)
|
const latest = values.at(-1) ?? 0
|
||||||
|
|
||||||
return { avg, p95, latest }
|
return { avg, p95, latest }
|
||||||
}
|
}
|
||||||
@@ -99,11 +99,14 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
|
|
||||||
const recording = ref(false)
|
const recording = ref(false)
|
||||||
const recordingStartedAt = ref<number | null>(null)
|
const recordingStartedAt = ref<number | null>(null)
|
||||||
|
const recordingElapsedMs = ref(0)
|
||||||
const recordingSamples = reactive(createEmptySamples())
|
const recordingSamples = reactive(createEmptySamples())
|
||||||
const lastRecording = ref<RecordingSnapshot>()
|
const lastRecording = ref<RecordingSnapshot>()
|
||||||
let recordingTimeout: ReturnType<typeof setTimeout> | undefined
|
let recordingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let recordingElapsedTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
const sampler = createLagSampler(defaultPerfTracer)
|
const sampler = createLagSampler(defaultPerfTracer)
|
||||||
|
const supported = shallowRef(sampler.supported)
|
||||||
let unsubscribeTracer: (() => void) | undefined
|
let unsubscribeTracer: (() => void) | undefined
|
||||||
let releaseTracer: (() => void) | undefined
|
let releaseTracer: (() => void) | undefined
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
recordingSamples[metric] = []
|
recordingSamples[metric] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySample(metric: LagMetric, value: number, meta?: Record<string, any>) {
|
function applySample(metric: LagMetric, value: number, meta?: Record<string, unknown>) {
|
||||||
const ts = performance.now()
|
const ts = performance.now()
|
||||||
const cutoff = ts - windowMs
|
const cutoff = ts - windowMs
|
||||||
|
|
||||||
@@ -132,8 +135,15 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
|
|
||||||
recording.value = true
|
recording.value = true
|
||||||
recordingStartedAt.value = performance.now()
|
recordingStartedAt.value = performance.now()
|
||||||
|
recordingElapsedMs.value = 0
|
||||||
resetRecordingSamples()
|
resetRecordingSamples()
|
||||||
|
|
||||||
|
recordingElapsedTimer = setInterval(() => {
|
||||||
|
if (recordingStartedAt.value === null)
|
||||||
|
return
|
||||||
|
|
||||||
|
recordingElapsedMs.value = performance.now() - recordingStartedAt.value
|
||||||
|
}, 1000)
|
||||||
recordingTimeout = setTimeout(stopRecording, 60000)
|
recordingTimeout = setTimeout(stopRecording, 60000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +155,17 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
clearTimeout(recordingTimeout)
|
clearTimeout(recordingTimeout)
|
||||||
recordingTimeout = undefined
|
recordingTimeout = undefined
|
||||||
}
|
}
|
||||||
|
if (recordingElapsedTimer) {
|
||||||
|
clearInterval(recordingElapsedTimer)
|
||||||
|
recordingElapsedTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const stoppedAt = performance.now()
|
const stoppedAt = performance.now()
|
||||||
|
recordingElapsedMs.value = recordingStartedAt.value === null
|
||||||
|
? 0
|
||||||
|
: stoppedAt - recordingStartedAt.value
|
||||||
const snapshot: RecordingSnapshot = {
|
const snapshot: RecordingSnapshot = {
|
||||||
startedAt: recordingStartedAt.value || stoppedAt,
|
startedAt: recordingStartedAt.value ?? stoppedAt,
|
||||||
stoppedAt,
|
stoppedAt,
|
||||||
samples: {
|
samples: {
|
||||||
fps: [...recordingSamples.fps],
|
fps: [...recordingSamples.fps],
|
||||||
@@ -165,6 +182,13 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRecording(): RecordingSnapshot | undefined {
|
||||||
|
if (recording.value)
|
||||||
|
return stopRecording()
|
||||||
|
|
||||||
|
startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
function stopAll() {
|
function stopAll() {
|
||||||
sampler.stop()
|
sampler.stop()
|
||||||
if (unsubscribeTracer) {
|
if (unsubscribeTracer) {
|
||||||
@@ -176,7 +200,10 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureSampler() {
|
function ensureSampler() {
|
||||||
const anyEnabled = enabled.fps || enabled.frameDuration || enabled.longtask || enabled.memory
|
const anyEnabled = (enabled.fps && supported.value.fps)
|
||||||
|
|| (enabled.frameDuration && supported.value.frameDuration)
|
||||||
|
|| (enabled.longtask && supported.value.longtask)
|
||||||
|
|| (enabled.memory && supported.value.memory)
|
||||||
if (!anyEnabled) {
|
if (!anyEnabled) {
|
||||||
stopAll()
|
stopAll()
|
||||||
return
|
return
|
||||||
@@ -206,18 +233,18 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
if (!releaseTracer)
|
if (!releaseTracer)
|
||||||
releaseTracer = defaultPerfTracer.acquire('lag-overlay')
|
releaseTracer = defaultPerfTracer.acquire('lag-overlay')
|
||||||
sampler.start({
|
sampler.start({
|
||||||
fps: enabled.fps,
|
fps: enabled.fps && supported.value.fps,
|
||||||
frameDuration: enabled.frameDuration,
|
frameDuration: enabled.frameDuration && supported.value.frameDuration,
|
||||||
longtask: enabled.longtask,
|
longtask: enabled.longtask && supported.value.longtask,
|
||||||
memory: enabled.memory,
|
memory: enabled.memory && supported.value.memory,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAll(on: boolean) {
|
function toggleAll(on: boolean) {
|
||||||
enabled.fps = on
|
enabled.fps = on && supported.value.fps
|
||||||
enabled.frameDuration = on
|
enabled.frameDuration = on && supported.value.frameDuration
|
||||||
enabled.longtask = on
|
enabled.longtask = on && supported.value.longtask
|
||||||
enabled.memory = on
|
enabled.memory = on && supported.value.memory
|
||||||
ensureSampler()
|
ensureSampler()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,11 +277,16 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cleanup on tab close
|
function dispose() {
|
||||||
|
stopRecording()
|
||||||
|
stopAll()
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', dispose)
|
||||||
stopRecording()
|
onScopeDispose(() => {
|
||||||
stopAll()
|
window.removeEventListener('beforeunload', dispose)
|
||||||
|
dispose()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +294,12 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => {
|
|||||||
enabled,
|
enabled,
|
||||||
buffers,
|
buffers,
|
||||||
recording,
|
recording,
|
||||||
|
recordingElapsedMs,
|
||||||
lastRecording,
|
lastRecording,
|
||||||
|
supported,
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
|
toggleRecording,
|
||||||
exportCsv,
|
exportCsv,
|
||||||
toggleAll,
|
toggleAll,
|
||||||
calcStats,
|
calcStats,
|
||||||
|
|||||||
@@ -1542,7 +1542,7 @@ pages:
|
|||||||
section:
|
section:
|
||||||
performance-visualizer:
|
performance-visualizer:
|
||||||
title: Performance Visualizer
|
title: Performance Visualizer
|
||||||
description: Toggle FPS/long task/memory overlay
|
description: Show FPS, frame time, long tasks, and supported browser memory metrics
|
||||||
markdown-stress:
|
markdown-stress:
|
||||||
title: Markdown Stress
|
title: Markdown Stress
|
||||||
description: Stress markdown parsing and rendering with heavy chat payloads
|
description: Stress markdown parsing and rendering with heavy chat payloads
|
||||||
|
|||||||
@@ -57,7 +57,41 @@ devtools:
|
|||||||
title: Lag Visualizer
|
title: Lag Visualizer
|
||||||
performance-visualizer:
|
performance-visualizer:
|
||||||
title: Performance Visualizer
|
title: Performance Visualizer
|
||||||
description: Toggle FPS/long task/memory overlay
|
description: Inspect live performance metrics and runtime diagnostics
|
||||||
|
controls:
|
||||||
|
enable-all:
|
||||||
|
label: Enable all available metrics
|
||||||
|
description: Enable each metric that this browser supports.
|
||||||
|
start-recording: Start recording
|
||||||
|
stop-recording: Stop recording ({seconds}s)
|
||||||
|
export-last-recording: Export last recording
|
||||||
|
metrics:
|
||||||
|
fps:
|
||||||
|
label: FPS
|
||||||
|
description: Frames per second during the last 10 seconds.
|
||||||
|
frame-duration:
|
||||||
|
label: Frame time (ms)
|
||||||
|
description: Time between animation frames during the last 10 seconds.
|
||||||
|
long-task:
|
||||||
|
label: Long tasks
|
||||||
|
description: Main-thread tasks that take at least 50 ms.
|
||||||
|
unsupported: This browser does not expose long-task timing.
|
||||||
|
memory:
|
||||||
|
label: JS heap (MB)
|
||||||
|
description: JavaScript heap usage from Chromium's performance.memory API.
|
||||||
|
unsupported: This browser does not expose JavaScript heap usage.
|
||||||
|
recording:
|
||||||
|
title: Last recording
|
||||||
|
duration: Duration {duration} ms
|
||||||
|
limit: Recording stops after 60 seconds.
|
||||||
|
overlay:
|
||||||
|
title: Performance
|
||||||
|
move: Move performance overlay
|
||||||
|
reset-position: Reset overlay position
|
||||||
|
record: Record
|
||||||
|
stop: Stop
|
||||||
|
export: Export last recording
|
||||||
|
average: avg
|
||||||
markdown-stress:
|
markdown-stress:
|
||||||
title: Markdown Stress
|
title: Markdown Stress
|
||||||
description: Stress markdown parsing and rendering with heavy chat payloads
|
description: Stress markdown parsing and rendering with heavy chat payloads
|
||||||
|
|||||||
@@ -1476,7 +1476,7 @@ pages:
|
|||||||
section:
|
section:
|
||||||
performance-visualizer:
|
performance-visualizer:
|
||||||
title: 性能可视化工具
|
title: 性能可视化工具
|
||||||
description: 切换 FPS/长任务/内存叠加
|
description: 显示 FPS、帧时间、长任务和浏览器支持的内存指标
|
||||||
markdown-stress:
|
markdown-stress:
|
||||||
title: Markdown Stress
|
title: Markdown Stress
|
||||||
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
||||||
|
|||||||
@@ -57,7 +57,41 @@ devtools:
|
|||||||
title: Lag 可视化
|
title: Lag 可视化
|
||||||
performance-visualizer:
|
performance-visualizer:
|
||||||
title: 性能可视化
|
title: 性能可视化
|
||||||
description: 切换 FPS/长任务/内存叠加
|
description: 检查实时性能指标和运行时诊断数据
|
||||||
|
controls:
|
||||||
|
enable-all:
|
||||||
|
label: 启用所有可用指标
|
||||||
|
description: 启用当前浏览器支持的所有指标。
|
||||||
|
start-recording: 开始录制
|
||||||
|
stop-recording: 停止录制({seconds} 秒)
|
||||||
|
export-last-recording: 导出上次录制
|
||||||
|
metrics:
|
||||||
|
fps:
|
||||||
|
label: FPS
|
||||||
|
description: 最近 10 秒内的每秒帧数。
|
||||||
|
frame-duration:
|
||||||
|
label: 帧时间(毫秒)
|
||||||
|
description: 最近 10 秒内动画帧之间的时间。
|
||||||
|
long-task:
|
||||||
|
label: 长任务
|
||||||
|
description: 耗时至少 50 毫秒的主线程任务。
|
||||||
|
unsupported: 当前浏览器不提供长任务计时。
|
||||||
|
memory:
|
||||||
|
label: JS 堆(MB)
|
||||||
|
description: Chromium performance.memory API 提供的 JavaScript 堆用量。
|
||||||
|
unsupported: 当前浏览器不提供 JavaScript 堆用量。
|
||||||
|
recording:
|
||||||
|
title: 上次录制
|
||||||
|
duration: 时长 {duration} 毫秒
|
||||||
|
limit: 录制会在 60 秒后停止。
|
||||||
|
overlay:
|
||||||
|
title: 性能
|
||||||
|
move: 移动性能浮窗
|
||||||
|
reset-position: 重置浮窗位置
|
||||||
|
record: 录制
|
||||||
|
stop: 停止
|
||||||
|
export: 导出上次录制
|
||||||
|
average: 平均
|
||||||
markdown-stress:
|
markdown-stress:
|
||||||
title: Markdown 压力测试
|
title: Markdown 压力测试
|
||||||
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
||||||
|
|||||||
Generated
+3
@@ -2491,6 +2491,9 @@ importers:
|
|||||||
embla-carousel-vue:
|
embla-carousel-vue:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 9.0.0-rc02(vue@3.5.32(typescript@5.9.3))
|
version: 9.0.0-rc02(vue@3.5.32(typescript@5.9.3))
|
||||||
|
es-toolkit:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 1.50.0
|
||||||
gpuu:
|
gpuu:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.0.7
|
version: 1.0.7
|
||||||
|
|||||||
Reference in New Issue
Block a user