diff --git a/apps/stage-web/package.json b/apps/stage-web/package.json index 085a803a6..f729f87a3 100644 --- a/apps/stage-web/package.json +++ b/apps/stage-web/package.json @@ -71,6 +71,7 @@ "driver.js": "catalog:", "drizzle-orm": "catalog:", "embla-carousel-vue": "catalog:", + "es-toolkit": "catalog:", "gpuu": "catalog:", "hono": "catalog:", "html2canvas": "catalog:", diff --git a/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue b/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue index 39783683e..0f2518f62 100644 --- a/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue +++ b/apps/stage-web/src/components/Devtools/PerformanceOverlay.vue @@ -1,24 +1,46 @@ - - - - Performance Overlay - - + + - - {{ recording ? 'Stop' : 'Record' }} - - + + + + + + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.title') }} + + + - - - {{ metric.label }} - - - avg {{ formatValue(metric.key, metric.stats.avg) }} - / - p95 {{ formatValue(metric.key, metric.stats.p95) }} - - - -- - - - - - + v-for="metric in metricsWithStats" + :key="metric.key" + :class="['flex flex-col gap-1']" + > + + + {{ t(metric.labelKey) }} + + + + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.overlay.average') }} + {{ formatValue(metric.key, metric.stats.avg) }} + / + p95 {{ formatValue(metric.key, metric.stats.p95) }} + + + -- + + + + + + + + + + - + diff --git a/apps/stage-web/src/components/Devtools/performance-overlay.browser.test.ts b/apps/stage-web/src/components/Devtools/performance-overlay.browser.test.ts new file mode 100644 index 000000000..9cf740028 --- /dev/null +++ b/apps/stage-web/src/components/Devtools/performance-overlay.browser.test.ts @@ -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) + }) +}) diff --git a/apps/stage-web/src/composables/perf/register-lag-sampler.ts b/apps/stage-web/src/composables/perf/register-lag-sampler.ts index 092316909..298b26f51 100644 --- a/apps/stage-web/src/composables/perf/register-lag-sampler.ts +++ b/apps/stage-web/src/composables/perf/register-lag-sampler.ts @@ -7,12 +7,28 @@ interface LagEnabled { memory: boolean } +type LagMetricSupport = Readonly> + +/** + * 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) { let rafId: number | undefined let lastTs: number | undefined let longTaskObserver: PerformanceObserver | undefined let memoryTimer: ReturnType | 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() { if (rafId !== undefined) { cancelAnimationFrame(rafId) @@ -58,7 +74,7 @@ export function createLagSampler(tracer: PerfTracer) { function startLongTaskObserver() { stopLongTaskObserver() - if (!('PerformanceObserver' in window)) + if (!supported.longtask) return try { @@ -89,7 +105,7 @@ export function createLagSampler(tracer: PerfTracer) { function startMemoryTimer() { stopMemoryTimer() const perfWithMemory = performance as Performance & { memory?: { usedJSHeapSize: number } } - if (!perfWithMemory.memory) + if (!supported.memory || !perfWithMemory.memory) return memoryTimer = setInterval(() => { @@ -105,13 +121,13 @@ export function createLagSampler(tracer: PerfTracer) { function start(enabled: LagEnabled) { stop() - if (enabled.fps || enabled.frameDuration) + if ((enabled.fps && supported.fps) || (enabled.frameDuration && supported.frameDuration)) startRaf() - if (enabled.longtask) + if (enabled.longtask && supported.longtask) startLongTaskObserver() - if (enabled.memory) + if (enabled.memory && supported.memory) startMemoryTimer() } @@ -122,6 +138,7 @@ export function createLagSampler(tracer: PerfTracer) { } return { + supported, start, stop, } diff --git a/apps/stage-web/src/pages/devtools/performance-visualizer.vue b/apps/stage-web/src/pages/devtools/performance-visualizer.vue index eafa829ee..da8fdfab5 100644 --- a/apps/stage-web/src/pages/devtools/performance-visualizer.vue +++ b/apps/stage-web/src/pages/devtools/performance-visualizer.vue @@ -1,111 +1,161 @@ - - - - toggleAll(Boolean(value))" - /> - - {{ recordingLabel }} - - - Export last recording - - + + + + - - - - - - + + + + + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.recording.limit') }} + - - - Last recording - - - Started at {{ lastRecording?.startedAt.toFixed(0) }} ms, duration - {{ (lastRecording!.stoppedAt - lastRecording!.startedAt).toFixed(0) }} ms - - - Samples: - FPS {{ lastRecording?.samples.fps.length }}, - Frames {{ lastRecording?.samples.frameDuration.length }}, - Long tasks {{ lastRecording?.samples.longtask.length }}, - Memory {{ lastRecording?.samples.memory.length }} - + + + + - - Overlay is visible when any metric is enabled. Recording caps at 60s. + + + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.recording.title') }} + + + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.recording.duration', { + duration: n(lastRecording.stoppedAt - lastRecording.startedAt, { maximumFractionDigits: 0 }), + }) }} + + + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.fps.label') }}: {{ n(lastRecording.samples.fps.length) }} + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.frame-duration.label') }}: {{ n(lastRecording.samples.frameDuration.length) }} + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.long-task.label') }}: {{ n(lastRecording.samples.longtask.length) }} + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.metrics.memory.label') }}: {{ n(lastRecording.samples.memory.length) }} + + + + + {{ t('tamagotchi.settings.devtools.pages.performance-visualizer.description') }} diff --git a/apps/stage-web/src/stores/devtools-lag.ts b/apps/stage-web/src/stores/devtools-lag.ts index 6acc5bc19..7c3efe1df 100644 --- a/apps/stage-web/src/stores/devtools-lag.ts +++ b/apps/stage-web/src/stores/devtools-lag.ts @@ -2,7 +2,7 @@ import type { TraceEvent } from '@proj-airi/stage-shared' import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-shared' 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' @@ -11,7 +11,7 @@ export type LagMetric = 'fps' | 'frameDuration' | 'longtask' | 'memory' interface Sample { ts: number value: number - meta?: Record + meta?: Record } interface RecordingSnapshot { @@ -49,7 +49,7 @@ function calcStats(values: number[]) { const sorted = [...values].sort((a, b) => a - b) const idx = Math.max(0, Math.floor(0.95 * (sorted.length - 1))) const p95 = sorted[idx] - const latest = values.at(-1) + const latest = values.at(-1) ?? 0 return { avg, p95, latest } } @@ -99,11 +99,14 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { const recording = ref(false) const recordingStartedAt = ref(null) + const recordingElapsedMs = ref(0) const recordingSamples = reactive(createEmptySamples()) const lastRecording = ref() let recordingTimeout: ReturnType | undefined + let recordingElapsedTimer: ReturnType | undefined const sampler = createLagSampler(defaultPerfTracer) + const supported = shallowRef(sampler.supported) let unsubscribeTracer: (() => void) | undefined let releaseTracer: (() => void) | undefined @@ -112,7 +115,7 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { recordingSamples[metric] = [] } - function applySample(metric: LagMetric, value: number, meta?: Record) { + function applySample(metric: LagMetric, value: number, meta?: Record) { const ts = performance.now() const cutoff = ts - windowMs @@ -132,8 +135,15 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { recording.value = true recordingStartedAt.value = performance.now() + recordingElapsedMs.value = 0 resetRecordingSamples() + recordingElapsedTimer = setInterval(() => { + if (recordingStartedAt.value === null) + return + + recordingElapsedMs.value = performance.now() - recordingStartedAt.value + }, 1000) recordingTimeout = setTimeout(stopRecording, 60000) } @@ -145,10 +155,17 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { clearTimeout(recordingTimeout) recordingTimeout = undefined } + if (recordingElapsedTimer) { + clearInterval(recordingElapsedTimer) + recordingElapsedTimer = undefined + } const stoppedAt = performance.now() + recordingElapsedMs.value = recordingStartedAt.value === null + ? 0 + : stoppedAt - recordingStartedAt.value const snapshot: RecordingSnapshot = { - startedAt: recordingStartedAt.value || stoppedAt, + startedAt: recordingStartedAt.value ?? stoppedAt, stoppedAt, samples: { fps: [...recordingSamples.fps], @@ -165,6 +182,13 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { return snapshot } + function toggleRecording(): RecordingSnapshot | undefined { + if (recording.value) + return stopRecording() + + startRecording() + } + function stopAll() { sampler.stop() if (unsubscribeTracer) { @@ -176,7 +200,10 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { } 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) { stopAll() return @@ -206,18 +233,18 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { if (!releaseTracer) releaseTracer = defaultPerfTracer.acquire('lag-overlay') sampler.start({ - fps: enabled.fps, - frameDuration: enabled.frameDuration, - longtask: enabled.longtask, - memory: enabled.memory, + fps: enabled.fps && supported.value.fps, + frameDuration: enabled.frameDuration && supported.value.frameDuration, + longtask: enabled.longtask && supported.value.longtask, + memory: enabled.memory && supported.value.memory, }) } function toggleAll(on: boolean) { - enabled.fps = on - enabled.frameDuration = on - enabled.longtask = on - enabled.memory = on + enabled.fps = on && supported.value.fps + enabled.frameDuration = on && supported.value.frameDuration + enabled.longtask = on && supported.value.longtask + enabled.memory = on && supported.value.memory ensureSampler() } @@ -250,11 +277,16 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { { deep: true }, ) - // Cleanup on tab close + function dispose() { + stopRecording() + stopAll() + } + if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - stopRecording() - stopAll() + window.addEventListener('beforeunload', dispose) + onScopeDispose(() => { + window.removeEventListener('beforeunload', dispose) + dispose() }) } @@ -262,9 +294,12 @@ export const useDevtoolsLagStore = defineStore('devtoolsLag', () => { enabled, buffers, recording, + recordingElapsedMs, lastRecording, + supported, startRecording, stopRecording, + toggleRecording, exportCsv, toggleAll, calcStats, diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 1337bb80e..e020a1b4c 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -1542,7 +1542,7 @@ pages: section: 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: title: Markdown Stress description: Stress markdown parsing and rendering with heavy chat payloads diff --git a/packages/i18n/src/locales/en/tamagotchi/settings.yaml b/packages/i18n/src/locales/en/tamagotchi/settings.yaml index 94d137943..ae9dbed89 100644 --- a/packages/i18n/src/locales/en/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/settings.yaml @@ -57,7 +57,41 @@ devtools: title: Lag Visualizer 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: title: Markdown Stress description: Stress markdown parsing and rendering with heavy chat payloads diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 62f8a7e0b..60faa1903 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -1476,7 +1476,7 @@ pages: section: performance-visualizer: title: 性能可视化工具 - description: 切换 FPS/长任务/内存叠加 + description: 显示 FPS、帧时间、长任务和浏览器支持的内存指标 markdown-stress: title: Markdown Stress description: 用大量聊天负载来测试 Markdown 的解析和渲染能力 diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml index 8bc352096..beaf08f07 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml @@ -57,7 +57,41 @@ devtools: title: Lag 可视化 performance-visualizer: 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: title: Markdown 压力测试 description: 用大量聊天负载来测试 Markdown 的解析和渲染能力 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4aeecf45..c6ece547f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2491,6 +2491,9 @@ importers: embla-carousel-vue: specifier: 'catalog:' version: 9.0.0-rc02(vue@3.5.32(typescript@5.9.3)) + es-toolkit: + specifier: 'catalog:' + version: 1.50.0 gpuu: specifier: 'catalog:' version: 1.0.7