Perfetto UI is a Single Page Web Application written in TypeScript using the Mithril framework. It lives in ui/ and powers ui.perfetto.dev. The UI embeds TraceProcessor via WebAssembly.
The UI codebase is organized as follows:
ui/src/ ├── base/ # Core utilities (time, color, arrays, logging, disposables) ├── widgets/ # Reusable UI components (Button, Menu, Modal, Popup, etc.) ├── components/ # Higher-level components (aggregation panels, query tables) ├── core/ # Core application logic and managers ├── public/ # Public API surface for plugins ├── plugins/ # Optional third-party/external plugins ├── core_plugins/ # Essential core plugins (cannot be disabled) ├── frontend/ # Main frontend rendering code ├── trace_processor/# Engine communication layer (query results, SQL utilities) ├── test/ # Playwright integration tests └── assets/ # SCSS stylesheets and static assets
When possible (if the API surface allows) feature functionality should be encapsulated in a plugin in src/plugins.
core_plugins/ (e.g., dev.perfetto.CoreCommands, dev.perfetto.Notes) contain essential functionality. They cannot be disabled by users and are always active.plugins/ (e.g., dev.perfetto.Sched, com.android.AndroidStartup) are optional. Users can enable/disable them via feature flags. These are organized by reverse-DNS naming (e.g., com.android.*, dev.perfetto.*, org.chromium.*).To build and serve the UI for development:
# From the repository root ui/build # Builds the UI ui/run-dev-server # Starts the development server with live reload
The UI uses:
Plugins are the primary extension mechanism for the UI. They follow this structure:
import {PerfettoPlugin} from '../../public/plugin'; import {Trace} from '../../public/trace'; import {App} from '../../public/app'; export default class MyPlugin implements PerfettoPlugin { // Unique reverse-DNS identifier static readonly id = 'com.example.MyPlugin'; // Optional: Human-readable description static readonly description = 'Does something useful'; // Optional: Declare dependencies on other plugins static readonly dependencies = [OtherPlugin]; // Called when the plugin is activated (before trace load) static onActivate(app: App): void { // Register commands, sidebar items, pages that don't need a trace } // Called when a trace is loaded async onTraceLoad(trace: Trace): Promise<void> { // Register tracks, tabs, commands that need trace data // Query the trace processor, add tracks to the workspace } }
Plugin Lifecycle:
onActivate() - Called when the plugin is enabled, before any trace is loaded. Use for registering global commands, pages, and sidebar items.onTraceLoad() - Called when a trace is loaded. Use for registering tracks, tabs, and commands that depend on trace data.trace.onTraceReady event - Fired after all plugins have finished onTraceLoad(). Use for automations that need all tracks to be available.Key APIs available to plugins:
trace.engine - Run SQL queries against TraceProcessortrace.tracks - Register and find trackstrace.selection - Manage selection statetrace.commands - Register commandstrace.tabs - Register tabs in the details paneltrace.timeline - Access timeline statetrace.workspace - Manage the track tree structureThe UI uses Mithril.js. Follow these patterns:
Component Structure:
import m from 'mithril'; interface MyComponentAttrs { readonly value: string; readonly onChange: (newValue: string) => void; } export class MyComponent implements m.ClassComponent<MyComponentAttrs> { // Local state private expanded = false; view({attrs}: m.CVnode<MyComponentAttrs>): m.Children { return m('.my-component', m(Button, {label: attrs.value, onclick: () => this.expanded = !this.expanded}), this.expanded && m('.details', 'Expanded content'), ); } }
Mithril Rules:
m.redraw() most of the times. We automatically schedules redraws: (1) in Mithril's DOM event handlers; (2) after trace processor queries complete. But NOT after manually registered JS event handlers.constructor for initialization if no DOM access is needed, or onCreate if DOM is needed.ui/src/widgets/) over creating new components.readonly for attrs properties to prevent accidental mutation. We like things to be immutable.Conditional Rendering with State Preservation: Use the Gate component when you need to conditionally show/hide content while preserving component state:
import {Gate} from '../base/mithril_utils'; m(Gate, {open: this.isVisible}, m(ExpensiveComponent));
The ui/src/widgets/ directory contains reusable components. Always check here before creating new UI elements:
Button, ButtonBar, ButtonGroup - Various button stylesPopupMenu, Menu, MenuItem, MenuDivider - Dropdown menusPopup - Floating popup containersModal - Modal dialogsTextInput, Select, Checkbox, Switch - Form controlsTree - Tree view componentDataGrid - Tabular data grid componentTabs - Tabbed interfaceSpinner - Loading indicatorEmptyState - Empty state placeholderUsing Widgets:
import {Button, ButtonVariant} from '../widgets/button'; import {Popup} from '../widgets/popup'; m(Button, { label: 'Click me', icon: 'search', variant: ButtonVariant.Filled, onclick: () => { /* handle click */ }, });
Follow these guidelines for TypeScript code:
any as much as you can: Use @typescript-eslint/no-explicit-any rule if you really need it. In most cases it's enough to use unknown and type guards instead._unused) to satisfy @typescript-eslint/no-unused-vars.readonly for interface properties and function parameters.ui/src/base/ for utilities before writing your own:time.ts, duration.ts - Time handlinglogging.ts - assertTrue(), assertExists(), assertFalse()disposable_stack.ts - Resource cleanupdeferred.ts - Promise utilitiesstring_utils.ts - String manipulationarray_utils.ts - Array helpersPlugins query data using SQL through the TraceProcessor engine:
async onTraceLoad(trace: Trace): Promise<void> { const result = await trace.engine.query(` SELECT ts, dur, name FROM slice WHERE name LIKE '%mySlice%' LIMIT 100 `); // Use typed iteration const iter = result.iter({ ts: LONG, // bigint dur: LONG, // bigint name: STR, // string }); for (; iter.valid(); iter.next()) { console.log(iter.ts, iter.dur, iter.name); } }
Rarely you need to create a new Track from scrach. In most cases you can use higher level components in ui/src/components/tracks/, especially DatasetSliceTrack (examples in /docs/contributing/ui-plugins.md). Look at those examples first and keep creating a track via trace.tracks.registerTrack as a last-resort.
Stylesheets live in ui/src/assets/ and component-specific .scss files alongside components.
pf- prefix for all CSS classes (Perfetto namespace).pf-component, .pf-component__element, .pf-component--modifiertheme_provider.scss for colorsThe following patterns are consistently enforced during code review. Adhering to these will significantly speed up the review process.
Prefer undefined over null:
// Bad function getValue(): string | null { return null; } // Good function getValue(): string | undefined { return undefined; }
Use ReadonlyArray<T> for arrays that shouldn't be modified:
// Bad function process(items: string[]): void { ... } // Good function process(items: ReadonlyArray<string>): void { ... }
Use classNames() utility for building CSS class strings:
import {classNames} from '../base/classnames'; // Bad const cls = 'pf-row' + (isSelected ? ' pf-row--selected' : '') + (isDisabled ? ' pf-row--disabled' : ''); // Good const cls = classNames('pf-row', isSelected && 'pf-row--selected', isDisabled && 'pf-row--disabled');
Use assertUnreachable() in switch default cases:
import {assertUnreachable} from '../base/logging'; switch (value) { case 'a': return handleA(); case 'b': return handleB(); default: assertUnreachable(value); // TypeScript will error if cases aren't exhaustive }
Variables should be camelCase:
// Bad const trace_processor_id = 123; // Good const traceProcessorId = 123;
Never use inline styles - use stylesheets:
// Bad m('div', {style: {color: 'red', padding: '10px'}}, 'content') // Good m('.pf-my-component', 'content') // with styles in .scss file
All CSS classes must have the pf- prefix:
// Bad .my-component { ... } .row { ... } // Good .pf-my-component { ... } .pf-my-component__row { ... }
Never hard-code colors - use theme variables:
// Bad .pf-my-component { color: #333; background: white; } // Good .pf-my-component { color: var(--pf-color-foreground); background: var(--pf-color-background); }
Don't use oncreate/lifecycle hooks for things that can be done in view():
// Bad - splitting code across lifecycle methods hurts readability. oncreate() { this.computedValue = inexpensiveComputation(); } // Good - compute in view. If expensive initialize in the constructor. view() { const computedValue = inexpensiveComputation(); return m('div', computedValue); }
Use the Anchor widget for links:
import {Anchor} from '../widgets/anchor'; import {Icons} from '../widgets/icons'; // Bad m('a', {href: 'https://example.com', target: '_blank'}, 'Link') // Good m(Anchor, {href: 'https://example.com', icon: Icons.ExternalLink}, 'Link')
Settings/flags should use reverse-DNS format:
// Bad const settingId = 'trackHeightMinPx'; // Good const settingId = 'dev.perfetto.TrackHeightMinPx';
Command IDs should be descriptive but omit redundant plugin name:
// Bad (if plugin is com.android.OrganizeNestedTracks) const commandId = 'com.android.OrganizeNestedTracks#organizeNestedTracks'; // Good const commandId = 'com.android.OrganizeNestedTracks';
Copyright years should be current when creating new files: But don't touch years when editing existing files.
// Bad (if current year is 2025) // Copyright (C) 2024 The Android Open Source Project // Good // Copyright (C) 2025 The Android Open Source Project
Use Zod for parsing objects of unknown types:
import {z} from 'zod'; // Bad - unsafe type assertion const config = JSON.parse(data) as MyConfig; // Good - validated parsing const ConfigSchema = z.object({ name: z.string(), value: z.number(), }); const config = ConfigSchema.parse(JSON.parse(data));
Unit tests are run with:
$ui/run-unittests
TypeScript unit tests follow the pattern *_unittest.ts and use Jest.
Integration tests use Playwright:
ui/run-integrationtests