diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/config/config_interfaces.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/config/config_interfaces.ts index 41db0acd17..7e8ef88d53 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/config/config_interfaces.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/config/config_interfaces.ts @@ -15,7 +15,10 @@ import m from 'mithril'; import {TargetPlatformId} from '../interfaces/target_platform'; import {TraceConfigBuilder} from './trace_config_builder'; -import {RecordPluginSchema, RecordSessionSchema} from '../serialization_schema'; +import { + ProbesSessionSchema, + RecordPluginSchema, +} from '../serialization_schema'; /** * A sub-page of the Record page. @@ -59,8 +62,8 @@ export type RecordSubpage = { // Save-restore the page state into the JSON object that is saved in // localstorage and shared when sharing a config. - serialize(state: RecordSessionSchema): void; - deserialize(state: RecordSessionSchema): void; + serialize(state: ProbesSessionSchema): void; + deserialize(state: ProbesSessionSchema): void; } | { kind: 'GLOBAL_PAGE'; diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/buffer_config_page.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/buffer_config_page.ts index a43272536a..8840851caf 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/buffer_config_page.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/buffer_config_page.ts @@ -19,7 +19,7 @@ import {Slider} from './widgets/slider'; import {RecordMode, TraceConfigBuilder} from '../config/trace_config_builder'; import {ConfigManager} from '../config/config_manager'; import {RecordSubpage} from '../config/config_interfaces'; -import {RecordSessionSchema} from '../serialization_schema'; +import {ProbesSessionSchema} from '../serialization_schema'; import {Toggle} from './widgets/toggle'; type RecMgrAttrs = {recMgr: RecordingManager}; @@ -34,7 +34,7 @@ export function bufferConfigPage(recMgr: RecordingManager): RecordSubpage { render() { return m(BufferConfigPage, {recMgr}); }, - serialize(state: RecordSessionSchema) { + serialize(state: ProbesSessionSchema) { const tc: TraceConfigBuilder = recMgr.recordConfig.traceConfig; state.mode = tc.mode; state.bufSizeKb = tc.defaultBuffer.sizeKb; @@ -43,7 +43,7 @@ export function bufferConfigPage(recMgr: RecordingManager): RecordSubpage { state.fileWritePeriodMs = tc.fileWritePeriodMs; state.compression = tc.compression; }, - async deserialize(state: RecordSessionSchema) { + async deserialize(state: ProbesSessionSchema) { const tc: TraceConfigBuilder = recMgr.recordConfig.traceConfig; tc.mode = state.mode; tc.defaultBuffer.sizeKb = state.bufSizeKb; diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/record_page.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/record_page.ts index 3712668793..c9acf1671f 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/record_page.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/record_page.ts @@ -128,9 +128,20 @@ export class RecordPageV2 implements m.ClassComponent { private renderSubpage(page: RecordSubpage): m.Children { switch (page.kind) { case 'PROBES_PAGE': - return page.probes - .filter((p) => supportsPlatform(p, this.recMgr.currentPlatform)) - .map((probe) => m(Probe, {cfgMgr: this.recMgr.recordConfig, probe})); + return [ + this.recMgr.hasCustomTraceConfig && + m( + Callout, + {intent: Intent.Primary, icon: 'upload_file'}, + 'Using imported custom config. Changes to probe settings ' + + 'below will not take effect.', + ), + ...page.probes + .filter((p) => supportsPlatform(p, this.recMgr.currentPlatform)) + .map((probe) => + m(Probe, {cfgMgr: this.recMgr.recordConfig, probe}), + ), + ]; case 'GLOBAL_PAGE': case 'SESSION_PAGE': return page.render(); @@ -201,8 +212,19 @@ export class RecordPageV2 implements m.ClassComponent { }, }), ), + this.recMgr.hasCustomTraceConfig && + m( + '.pf-custom-config-notice', + m(Icon, {icon: 'upload_file'}), + m('span', 'Imported config overrides probe settings'), + ), m( 'ul', + { + className: this.recMgr.hasCustomTraceConfig + ? 'pf-probes-disabled' + : '', + }, this.getSortedProbes(Array.from(pages.values())).map((rc) => this.renderMenuEntry(rc), ), @@ -252,7 +274,7 @@ export class RecordPageV2 implements m.ClassComponent { showModal({title: 'Restore error', content: res.error}); return; } - this.recMgr.app.navigate('#!/record/cmdline'); + this.recMgr.app.navigate('#!/record'); } } diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/target_selection_page.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/target_selection_page.ts index 71b0a3d825..1fac325a80 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/target_selection_page.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/pages/target_selection_page.ts @@ -27,13 +27,16 @@ import {DisposableStack} from '../../../base/disposable_stack'; import {CurrentTracingSession, RecordingManager} from '../recording_manager'; import {download} from '../../../base/download_utils'; import {RecordSubpage} from '../config/config_interfaces'; -import {RecordPluginSchema} from '../serialization_schema'; +import {RecordPluginSchema, SavedSessionSchema} from '../serialization_schema'; import {Checkbox} from '../../../widgets/checkbox'; import {linkify} from '../../../widgets/anchor'; import {getPresetsForPlatform} from '../presets'; import {Icons} from '../../../base/semantic_icons'; import {shareRecordConfig} from '../config/config_sharing'; import {Card} from '../../../widgets/card'; +import {showModal} from '../../../widgets/modal'; +import {traceConfigToPb} from '../../../base/proto_utils_wasm'; +import protos from '../../../protos'; type RecMgrAttrs = {recMgr: RecordingManager}; @@ -68,7 +71,7 @@ export function targetSelectionPage(recMgr: RecordingManager): RecordSubpage { // Restore config const hasSavedProbes = state.lastSession !== undefined && - state.lastSession.probes !== undefined && + state.lastSession.kind === 'probes' && Object.keys(state.lastSession.probes).length > 0; if (state.selectedConfigId || hasSavedProbes) { @@ -185,6 +188,7 @@ class RecordConfigSelector implements m.ClassComponent { const isEmptySelected = recMgr.selectedConfigId === undefined && recMgr.isConfigModified === false && + !recMgr.hasCustomTraceConfig && !recMgr.recordConfig.hasActiveProbes(); return [ @@ -225,62 +229,63 @@ class RecordConfigSelector implements m.ClassComponent { private renderSavedConfigsSection(recMgr: RecordingManager) { const hasActiveProbes = recMgr.recordConfig.hasActiveProbes(); + const hasUnsavedCustomConfig = + recMgr.hasCustomTraceConfig && recMgr.selectedConfigId === undefined; const shouldHighlightSave = + hasUnsavedCustomConfig || (hasActiveProbes && recMgr.selectedConfigId === undefined) || recMgr.isConfigModified === true; - const hasSavedConfigs = recMgr.savedConfigs.length > 0; - const showSection = hasSavedConfigs || shouldHighlightSave; - if (!showSection) { - return null; - } return [ m('h3', 'User configs'), m('.pf-config-selector__grid', [ // Saved configs - ...recMgr.savedConfigs.map((config) => { + ...recMgr.savedConfigs.map((saved) => { const isSelected = - recMgr.selectedConfigId === `saved:${config.name}` && + recMgr.selectedConfigId === `saved:${saved.name}` && recMgr.isConfigModified === false; + const config = saved.config; + const isCustom = config.kind === 'custom'; return m( Card, { className: 'pf-preset-card' + (isSelected ? ' pf-preset-card--selected' : ''), - onclick: () => - recMgr.loadConfig({ - config: config.config, - configId: `saved:${config.name}`, - configName: config.name, - }), + onclick: () => this.loadSavedConfig(recMgr, saved), tabindex: 0, }, - m(Icon, {icon: 'bookmark'}), - m('.pf-preset-card__title', config.name), + m(Icon, {icon: isCustom ? 'description' : 'bookmark'}), + m('.pf-preset-card__title', saved.name), + isCustom && + m( + '.pf-preset-card__subtitle', + `Imported from ${config.customConfigFileName ?? 'textproto'}`, + ), m('.pf-preset-card__actions', [ - m(Button, { - icon: 'save', - compact: true, - title: 'Overwrite with current settings', - onclick: (e: Event) => { - e.stopPropagation(); - if ( - confirm( - `Overwrite config "${config.name}" with current settings?`, - ) - ) { - recMgr.saveConfig(config.name, recMgr.serializeSession()); - recMgr.app.raf.scheduleFullRedraw(); - } - }, - }), + !isCustom && + m(Button, { + icon: 'save', + compact: true, + title: 'Overwrite with current settings', + onclick: (e: Event) => { + e.stopPropagation(); + if ( + confirm( + `Overwrite config "${saved.name}" with current settings?`, + ) + ) { + recMgr.saveConfig(saved.name); + recMgr.app.raf.scheduleFullRedraw(); + } + }, + }), m(Button, { icon: 'share', compact: true, title: 'Share configuration', onclick: (e: Event) => { e.stopPropagation(); - shareRecordConfig(config.config); + shareRecordConfig(saved.config); }, }), m(Button, { @@ -289,8 +294,8 @@ class RecordConfigSelector implements m.ClassComponent { title: 'Delete configuration', onclick: (e: Event) => { e.stopPropagation(); - if (confirm(`Delete "${config.name}"?`)) { - recMgr.deleteConfig(config.name); + if (confirm(`Delete "${saved.name}"?`)) { + recMgr.deleteConfig(saved.name); recMgr.app.raf.scheduleFullRedraw(); } }, @@ -315,26 +320,70 @@ class RecordConfigSelector implements m.ClassComponent { ); return; } - const savedConfig = recMgr.serializeSession(); - recMgr.saveConfig(trimmedName, savedConfig); - recMgr.loadConfig({ - config: savedConfig, - configId: `saved:${trimmedName}`, - configName: trimmedName, - }); + const saved = recMgr.saveConfig(trimmedName); + this.loadSavedConfig(recMgr, saved); recMgr.app.raf.scheduleFullRedraw(); } }, tabindex: 0, }, - m(Icon, {icon: 'tune'}), - m('.pf-preset-card__title', 'Custom'), + m(Icon, {icon: hasUnsavedCustomConfig ? 'description' : 'tune'}), + m( + '.pf-preset-card__title', + hasUnsavedCustomConfig + ? recMgr.customConfigFileName ?? 'Imported config' + : 'Custom', + ), m('.pf-preset-card__subtitle', 'Click to save'), ), + this.renderImportCard(recMgr), ]), ]; } + private loadSavedConfig(recMgr: RecordingManager, saved: SavedSessionSchema) { + recMgr.loadConfig({ + config: saved.config, + configId: `saved:${saved.name}`, + configName: saved.name, + }); + } + + private renderImportCard(recMgr: RecordingManager) { + return m( + Card, + { + className: 'pf-preset-card pf-preset-card--dashed', + onclick: () => this.openImportDialog(recMgr), + tabindex: 0, + }, + m(Icon, {icon: 'upload_file'}), + m('.pf-preset-card__title', 'Import'), + m('.pf-preset-card__subtitle', 'Load textproto'), + ); + } + + private openImportDialog(recMgr: RecordingManager) { + const input = document.createElement('input'); + input.type = 'file'; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + const text = await file.text(); + const res = await traceConfigToPb(text); + if (!res.ok) { + showModal({ + title: 'Import error', + content: `Failed to parse config: ${res.error}`, + }); + return; + } + const config = protos.TraceConfig.decode(res.value); + recMgr.setCustomTraceConfig(config, file.name); + }; + input.click(); + } + private renderCard( icon: string, title: string, diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/presets.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/presets.ts index 289c6fd99d..a8dcbbf53f 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/presets.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/presets.ts @@ -107,6 +107,7 @@ const CHROME_DEFAULT_PRESET: Preset = { subtitle: 'Common Chrome trace events', icon: 'public', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 256 * 1024, durationMs: 30_000, @@ -133,6 +134,7 @@ const CHROME_V8_PRESET: Preset = { subtitle: 'JavaScript, wasm & GC', icon: 'mode_fan', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 256 * 1024, durationMs: 30_000, @@ -174,6 +176,7 @@ export const ANDROID_PRESETS: Preset[] = [ subtitle: 'The default config for general purpose tracing', icon: 'auto_awesome', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 64 * 1024, durationMs: 10_000, @@ -206,6 +209,7 @@ export const ANDROID_PRESETS: Preset[] = [ subtitle: 'Battery usage and power consumption', icon: 'battery_profile', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 64 * 1024, durationMs: 30_000, @@ -231,6 +235,7 @@ export const ANDROID_PRESETS: Preset[] = [ subtitle: 'Thermal throttling and mitigation', icon: 'thermostat', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 64 * 1024, durationMs: 30_000, @@ -259,6 +264,7 @@ export const ANDROID_PRESETS: Preset[] = [ subtitle: 'Graphics pipeline and system compositor', icon: 'layers', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 64 * 1024, durationMs: 30000, @@ -297,6 +303,7 @@ export const LINUX_PRESETS: Preset[] = [ subtitle: 'General purpose CPU and system tracing', icon: 'auto_awesome', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 64 * 1024, durationMs: 10_000, @@ -318,6 +325,7 @@ export const LINUX_PRESETS: Preset[] = [ subtitle: 'CPU scheduling and process activity', icon: 'schedule', session: { + kind: 'probes', mode: 'STOP_WHEN_FULL', bufSizeKb: 64 * 1024, durationMs: 10_000, diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/recording_manager.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/recording_manager.ts index 982f304b5f..50854bd438 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/recording_manager.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/recording_manager.ts @@ -21,6 +21,7 @@ import {ConfigManager} from './config/config_manager'; import {RecordingTarget} from './interfaces/recording_target'; import {RecordingTargetProvider} from './interfaces/recording_target_provider'; import { + PROBES_SESSION_SCHEMA, RECORD_PLUGIN_SCHEMA, RECORD_SESSION_SCHEMA, RecordPluginSchema, @@ -31,6 +32,7 @@ import {TargetPlatformId} from './interfaces/target_platform'; import {TracingSession} from './interfaces/tracing_session'; import {uuidv4} from '../../base/uuid'; import {Time, Timecode} from '../../base/time'; +import {base64Decode, base64Encode} from '../../base/string_utils'; import {getPresetsForPlatform} from './presets'; @@ -58,6 +60,8 @@ export class RecordingManager { private loadedConfigGeneration = 0; private initiallyConfigModified = false; autoOpenTraceWhenTracingEnds = true; + private _customTraceConfig?: protos.TraceConfig; + private _customConfigFileName?: string; constructor(readonly app: App) {} @@ -138,9 +142,35 @@ export class RecordingManager { } genTraceConfig(): protos.TraceConfig { + if (this._customTraceConfig !== undefined) { + return this._customTraceConfig; + } return this.recordConfig.genTraceConfig(this.currentPlatform); } + setCustomTraceConfig(config: protos.TraceConfig, fileName: string) { + this._customTraceConfig = config; + this._customConfigFileName = fileName; + this.selectedConfigId = undefined; + this.selectedConfigName = undefined; + this.app.raf.scheduleFullRedraw(); + } + + clearCustomTraceConfig() { + this._customTraceConfig = undefined; + this._customConfigFileName = undefined; + this.clearSelectedConfig(); + this.app.raf.scheduleFullRedraw(); + } + + get hasCustomTraceConfig(): boolean { + return this._customTraceConfig !== undefined; + } + + get customConfigFileName(): string | undefined { + return this._customConfigFileName; + } + async startTracing(): Promise { if (this._tracingSession !== undefined) { this._tracingSession.session?.cancel(); @@ -159,18 +189,26 @@ export class RecordingManager { ); } - saveConfig(name: string, config: RecordSessionSchema) { - const existing = this.savedConfigs.find((c) => c.name === name); - if (existing) { - existing.config = config; + saveConfig(name: string): SavedSessionSchema { + const entry: SavedSessionSchema = { + name, + config: this.serializeSession(), + }; + const idx = this.savedConfigs.findIndex((c) => c.name === name); + if (idx >= 0) { + this.savedConfigs[idx] = entry; } else { - this.savedConfigs.push({name, config}); + this.savedConfigs.push(entry); } this.persistIntoLocalStorage(); + return entry; } deleteConfig(name: string) { this.savedConfigs = this.savedConfigs.filter((c) => c.name !== name); + if (this.selectedConfigId === `saved:${name}`) { + this.clearSelectedConfig(); + } this.persistIntoLocalStorage(); } @@ -225,8 +263,16 @@ export class RecordingManager { } serializeSession(): RecordSessionSchema { + if (this._customTraceConfig !== undefined) { + const encoded = protos.TraceConfig.encode(this._customTraceConfig); + return { + kind: 'custom' as const, + customTraceConfigBase64: base64Encode(encoded.finish()), + customConfigFileName: this._customConfigFileName, + }; + } // Initialize with default values. - const state: RecordSessionSchema = RECORD_SESSION_SCHEMA.parse({}); + const state = PROBES_SESSION_SCHEMA.parse({}); for (const page of this.pages.values()) { if (page.kind === 'SESSION_PAGE') { page.serialize(state); @@ -238,6 +284,16 @@ export class RecordingManager { } loadSession(state: RecordSessionSchema): void { + if (state.kind === 'custom') { + const bytes = base64Decode(state.customTraceConfigBase64); + this._customTraceConfig = protos.TraceConfig.decode(bytes); + this._customConfigFileName = state.customConfigFileName; + this.clearSelectedConfig(); + return; + } + // Probes session — clear any custom config and restore probe settings. + this._customTraceConfig = undefined; + this._customConfigFileName = undefined; for (const page of this.pages.values()) { if (page.kind === 'SESSION_PAGE') { page.deserialize(state); @@ -299,7 +355,8 @@ export class RecordingManager { } clearSession() { - const emptySession = RECORD_SESSION_SCHEMA.parse({}); + this.clearCustomTraceConfig(); + const emptySession = PROBES_SESSION_SCHEMA.parse({}); return this.loadSession(emptySession); } } diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema.ts index 6388c18f56..e70c279380 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema.ts @@ -44,23 +44,37 @@ export const PROBES_SCHEMA = z .default({}); export type ProbesSchema = z.infer; -// The schema that holds the settings for a recording session, that is, the -// state of the probes and the buffer size & type. -// This does NOT include the state of the other recording pages (e.g. the -// Target device selector, the "saved sessions", etc) -export const RECORD_SESSION_SCHEMA = z - .object({ - mode: z - .enum(['STOP_WHEN_FULL', 'RING_BUFFER', 'LONG_TRACE']) - .default('STOP_WHEN_FULL'), - bufSizeKb: z.number().default(64 * 1024), - durationMs: z.number().default(10_000), - maxFileSizeMb: z.number().default(500), - fileWritePeriodMs: z.number().default(2500), - compression: z.boolean().default(false), - probes: PROBES_SCHEMA, - }) - .prefault({}); +// A probe-based session config built from the UI probe settings. +export const PROBES_SESSION_SCHEMA = z.object({ + // Old configs won't have 'kind', so default to 'probes'. + kind: z.literal('probes').default('probes'), + mode: z + .enum(['STOP_WHEN_FULL', 'RING_BUFFER', 'LONG_TRACE']) + .default('STOP_WHEN_FULL'), + bufSizeKb: z.number().default(64 * 1024), + durationMs: z.number().default(10_000), + maxFileSizeMb: z.number().default(500), + fileWritePeriodMs: z.number().default(2500), + compression: z.boolean().default(false), + probes: PROBES_SCHEMA, +}); +export type ProbesSessionSchema = z.infer; + +// A custom session config from an imported TraceConfig textproto. +const CUSTOM_SESSION_SCHEMA = z.object({ + kind: z.literal('custom'), + customTraceConfigBase64: z.string(), + customConfigFileName: z.string().optional(), +}); +export type CustomSessionSchema = z.infer; + +// The schema that holds the settings for a recording session. +// z.union tries custom first (requires kind:'custom'), then falls back to +// probes (where kind defaults to 'probes' for backward compat). +export const RECORD_SESSION_SCHEMA = z.union([ + CUSTOM_SESSION_SCHEMA, + PROBES_SESSION_SCHEMA, +]); export type RecordSessionSchema = z.infer; // The schema for the target selection page. @@ -75,9 +89,10 @@ export const TARGET_SCHEMA = z .default({}); export type TargetSchema = z.infer; +// A saved session: name + the full session config (probes or custom). export const SAVED_SESSION_SCHEMA = z.object({ name: z.string(), - config: RECORD_SESSION_SCHEMA, + config: RECORD_SESSION_SCHEMA.default(() => PROBES_SESSION_SCHEMA.parse({})), }); export type SavedSessionSchema = z.infer; diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema_unittest.ts b/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema_unittest.ts index 68ffd275d4..7209e24ee5 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema_unittest.ts +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/serialization_schema_unittest.ts @@ -23,6 +23,8 @@ describe('RECORD_SESSION_SCHEMA', () => { it('uses defaults when empty object is provided', () => { const result = RECORD_SESSION_SCHEMA.parse({}); + expect(result.kind).toBe('probes'); + if (result.kind !== 'probes') throw new Error('unexpected kind'); expect(result.mode).toBe('STOP_WHEN_FULL'); expect(result.bufSizeKb).toBe(64 * 1024); expect(result.durationMs).toBe(10_000); @@ -31,6 +33,19 @@ describe('RECORD_SESSION_SCHEMA', () => { expect(result.compression).toBe(false); expect(result.probes).toEqual({}); }); + + it('parses custom session config', () => { + const result = RECORD_SESSION_SCHEMA.parse({ + kind: 'custom', + customTraceConfigBase64: 'dGVzdA==', + customConfigFileName: 'test.textproto', + }); + + expect(result.kind).toBe('custom'); + if (result.kind !== 'custom') throw new Error('Expected custom'); + expect(result.customTraceConfigBase64).toBe('dGVzdA=='); + expect(result.customConfigFileName).toBe('test.textproto'); + }); }); describe('SAVED_SESSION_SCHEMA', () => { @@ -42,6 +57,8 @@ describe('SAVED_SESSION_SCHEMA', () => { const result = SAVED_SESSION_SCHEMA.parse(input); expect(result.name).toBe('My Saved Config'); + expect(result.config.kind).toBe('probes'); + if (result.config.kind !== 'probes') throw new Error('Expected probes'); expect(result.config.mode).toBe('STOP_WHEN_FULL'); expect(result.config.bufSizeKb).toBe(64 * 1024); expect(result.config.durationMs).toBe(10_000); @@ -74,6 +91,10 @@ describe('RECORD_PLUGIN_SCHEMA', () => { it('uses RECORD_SESSION_SCHEMA defaults when lastSession is omitted', () => { const result = RECORD_PLUGIN_SCHEMA.parse({}); + expect(result.lastSession.kind).toBe('probes'); + if (result.lastSession.kind !== 'probes') { + throw new Error('Expected probes'); + } expect(result.lastSession.mode).toBe('STOP_WHEN_FULL'); expect(result.lastSession.bufSizeKb).toBe(64 * 1024); expect(result.lastSession.durationMs).toBe(10_000); diff --git a/ui/src/plugins/dev.perfetto.RecordTraceV2/styles.scss b/ui/src/plugins/dev.perfetto.RecordTraceV2/styles.scss index afb208c00b..b903f0c6f1 100644 --- a/ui/src/plugins/dev.perfetto.RecordTraceV2/styles.scss +++ b/ui/src/plugins/dev.perfetto.RecordTraceV2/styles.scss @@ -187,6 +187,24 @@ } } // pf-record-page__menu +.pf-probes-disabled { + opacity: 0.4; + pointer-events: none; +} + +.pf-custom-config-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin: 4px 8px; + font-size: 0.95em; + font-weight: 500; + color: var(--pf-color-primary); + background: color-mix(in srgb, var(--pf-color-primary) 10%, transparent); + border-radius: 4px; +} + .pf-record-page__section { grid-area: section; transition: opacity 0.25s ease;