| # UI plugins |
| The Perfetto UI can be extended with plugins. These plugins are shipped part of |
| Perfetto. |
| |
| ## Create a plugin |
| The guide below explains how to create a plugin for the Perfetto UI. You can |
| browse the public plugin API [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/public). |
| |
| ### Prepare for UI development |
| First we need to prepare the UI development environment. You will need to use a |
| MacOS or Linux machine. Follow the steps below or see the [Getting |
| Started](./getting-started) guide for more detail. |
| |
| ```sh |
| git clone https://android.googlesource.com/platform/external/perfetto/ |
| cd perfetto |
| ./tools/install-build-deps --ui |
| ``` |
| |
| ### Copy the plugin skeleton |
| ```sh |
| cp -r ui/src/plugins/com.example.Skeleton ui/src/plugins/<your-plugin-name> |
| ``` |
| Now edit `ui/src/plugins/<your-plugin-name>/index.ts`. Search for all instances |
| of `SKELETON: <instruction>` in the file and follow the instructions. |
| |
| Notes on naming: |
| - Don't name the directory `XyzPlugin` just `Xyz`. |
| - The `pluginId` and directory name must match. |
| - Plugins should be prefixed with the reversed components of a domain name you |
| control. For example if `example.com` is your domain your plugin should be |
| named `com.example.Foo`. |
| - Core plugins maintained by the Perfetto team should use `dev.perfetto.Foo`. |
| - Commands should have ids with the pattern `example.com#DoSomething` |
| - Command's ids should be prefixed with the id of the plugin which provides |
| them. |
| - Command names should have the form "Verb something something", and should be |
| in normal sentence case. I.e. don't capitalize the first letter of each word. |
| - Good: "Pin janky frame timeline tracks" |
| - Bad: "Tracks are Displayed if Janky" |
| |
| ### Start the dev server |
| ```sh |
| ./ui/run-dev-server |
| ``` |
| Now navigate to [localhost:10000](http://localhost:10000/) |
| |
| ### Enable your plugin |
| - Navigate to the plugins page: |
| [localhost:10000/#!/plugins](http://localhost:10000/#!/plugins). |
| - Ctrl-F for your plugin name and enable it. |
| - Enabling/disabling plugins requires a restart of the UI, so refresh the page |
| to start your plugin. |
| |
| Later you can request for your plugin to be enabled by default. Follow the |
| [default plugins](#default-plugins) section for this. |
| |
| ### Upload your plugin for review |
| - Update `ui/src/plugins/<your-plugin-name>/OWNERS` to include your email. |
| - Follow the [Contributing](./getting-started#contributing) instructions to |
| upload your CL to the codereview tool. |
| - Once uploaded add `stevegolton@google.com` as a reviewer for your CL. |
| |
| ## Plugin Lifecycle |
| To demonstrate the plugin's lifecycle, this is a minimal plugin that implements |
| the key lifecycle hooks: |
| |
| ```ts |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| |
| static onActivate(app: App): void { |
| // Called once on app startup |
| console.log('MyPlugin::onActivate()', app.pluginId); |
| // Note: It's rare that plugins would need this hook as most plugins are |
| // interested in trace details. Thus, this function can usually be omitted. |
| } |
| |
| constructor(trace: Trace) { |
| // Called each time a trace is loaded |
| console.log('MyPlugin::constructor()', trace.traceInfo.traceTitle); |
| } |
| |
| async onTraceLoad(trace: Trace): Promise<void> { |
| // Called each time a trace is loaded |
| console.log('MyPlugin::onTraceLoad()', trace.traceInfo.traceTitle); |
| // Note this function returns a promise, so any any async calls should be |
| // completed before this promise resolves as the app using this promise for |
| // timing and plugin synchronization. |
| } |
| } |
| ``` |
| |
| You can run this plugin with devtools to see the log messages in the console, |
| which should give you a feel for the plugin lifecycle. Try opening a few traces |
| one after another. |
| |
| `onActivate()` runs shortly after Perfetto starts up, before a trace is loaded. |
| This is where the you'll configure your plugin's capabilities that aren't trace |
| dependent. At this point the plugin's class is not instantiated, so you'll |
| notice `onActivate()` hook is a static class member. `onActivate()` is only ever |
| called once, regardless of the number of traces loaded. |
| |
| `onActivate()` is passed an `App` object which the plugin can use to configure |
| core capabilities such as commands, sidebar items and pages. Capabilities |
| registered on the App interface are persisted throughout the lifetime of the app |
| (practically forever until the tab is closed), in contrast to what happens for |
| the same methods on the `Trace` object (see below). |
| |
| The plugin class in instantiated when a trace is loaded (a new plugin instance |
| is created for each trace). `onTraceLoad()` is called immediately after the |
| class is instantiated, which is where you'll configure your plugin's trace |
| dependent capabilities. |
| |
| `onTraceLoad()` is passed a `Trace` object which the plugin can use to configure |
| entities that are scoped to a specific trace, such as tracks and tabs. `Trace` |
| is a superset of `App`, so anything you can do with `App` you can also do with |
| `Trace`, however, capabilities registered on `Trace` will typically be discarded |
| when a new trace is loaded. |
| |
| A plugin will typically register capabilities with the core and return quickly. |
| But these capabilities usually contain objects and callbacks which are called |
| into later by the core during the runtime of the app. Most capabilities require |
| a `Trace` or an `App` to do anything useful so these are usually bound into the |
| capabilities at registration time using JavaScript classes or closures. |
| |
| ```ts |
| // Toy example: Code will not compile. |
| async onTraceLoad(trace: Trace) { |
| // `trace` is captured in the closure and used later by the app |
| trace.regsterXYZ(() => trace.xyz); |
| } |
| ``` |
| |
| That way, the callback is bound to a specific trace object which and the trace |
| object can outlive the runtime of the `onTraceLoad()` function, which is a very |
| common pattern in Perfetto plugins. |
| |
| > Note: Some capabilities can be registered on either the `App` or the `Trace` |
| > object (i.e. in `onActivate()` or in `onTraceLoad()`), if in doubt about which |
| > one to use, use `onTraceLoad()` as this is more than likely the one you want. |
| > Most plugins add tracks and tabs that depend on the trace. You'd usually have |
| > to be doing something out of the ordinary if you need to use `onActivate()`. |
| |
| ### Performance |
| `onActivate()` and `onTraceLoad()` should generally complete as quickly as |
| possible, however sometimes `onTraceLoad()` may need to perform async operations |
| on trace processor such as performing queries and/or creating views and tables. |
| Thus, `onTraceLoad()` should return a promise (or you can simply make it an |
| async function). When this promise resolves it tells the core that the plugin is |
| fully initialized. |
| |
| > Note: It's important that any async operations done in onTraceLoad() are |
| > awaited so that all async operations are completed by the time the promise is |
| > resolved. This is so that plugins can be properly timed and synchronized. |
| |
| |
| ```ts |
| // GOOD |
| async onTraceLoad(trace: Trace) { |
| await trace.engine.query(...); |
| } |
| |
| // BAD |
| async onTraceLoad(trace: Trace) { |
| // Note the missing await! |
| trace.engine.query(...); |
| } |
| ``` |
| |
| ## Extension Points |
| Plugins can extend functionality of Perfetto by registering capabilities via |
| extension points on the `App` or `Trace` objects. |
| |
| The following sections delve into more detail on each extension point and |
| provide examples of how they can be used. |
| |
| ### Commands |
| Commands are user issuable shortcuts for actions in the UI. They are invoked via |
| the command palette which can be opened by pressing Ctrl+Shift+P (or Cmd+Shift+P |
| on Mac), or by typing a '>' into the omnibox. |
| |
| To add a command, add a call to `registerCommand()` on either your |
| `onActivate()` or `onTraceLoad()` hooks. The recommendation is to register |
| commands in `onTraceLoad()` by default unless you very specifically want the |
| command to be available before a trace has loaded. |
| |
| Example of a command that doesn't require a trace. |
| ```ts |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| static onActivate(app: App) { |
| app.commands.registerCommand({ |
| id: `${app.pluginId}#SayHello`, |
| name: 'Say hello', |
| callback: () => console.log('Hello, world!'), |
| }); |
| } |
| } |
| ``` |
| |
| Example of a command that requires a trace object - in this case the trace |
| title. |
| ```ts |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace) { |
| trace.commands.registerCommand({ |
| id: `${trace.pluginId}#LogTraceTitle`, |
| name: 'Log trace title', |
| callback: () => console.log(trace.info.traceTitle), |
| }); |
| } |
| } |
| ``` |
| |
| > Notice that the trace object is captured in the closure, so it can be used |
| > after the onTraceLoad() function has returned. This is a very common pattern |
| > in Perfetto plugins. |
| |
| Command arguments explained: |
| - `id` is a unique string which identifies this command. The `id` should be |
| prefixed with the plugin id followed by a `#`. All command `id`s must be unique |
| system-wide. |
| - `name` is a human readable name for the command, which is shown in the command |
| palette. |
| - `callback()` is the callback which actually performs the action. |
| |
| #### Async commands |
| It's common that commands will perform async operations in their callbacks. It's |
| recommended to use async/await for this rather than `.then().catch()`. The |
| easiest way to do this is to make the callback an async function. |
| |
| ```ts |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace) { |
| trace.commands.registerCommand({ |
| id: `${trace.pluginId}#QueryTraceProcessor`, |
| name: 'Query trace processor', |
| callback: async () => { |
| const results = await trace.engine.query(...); |
| // use results... |
| }, |
| }); |
| } |
| } |
| ``` |
| |
| If the callback is async (i.e. it returns a promise), nothing special happens. |
| The command is still fire-n-forget as far as the core is concerned. |
| |
| Examples: |
| - [com.example.ExampleSimpleCommand](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/com.example.ExampleSimpleCommand/index.ts). |
| - [perfetto.CoreCommands](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/core_plugins/commands/index.ts). |
| - [com.example.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/com.example.ExampleState/index.ts). |
| |
| ### Hotkeys |
| A hotkey may be associated with a command at registration time. |
| |
| ```typescript |
| ctx.commands.registerCommand({ |
| ... |
| defaultHotkey: 'Shift+H', |
| }); |
| ``` |
| |
| Despite the fact that the hotkey is a string, its format is checked at compile |
| time using typescript's [template literal |
| types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html). |
| |
| See |
| [hotkey.ts](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/base/hotkeys.ts) |
| for more details on how the hotkey syntax works, and for the available keys and |
| modifiers. |
| |
| Note this is referred to as the 'default' hotkey because we may introduce a |
| feature in the future where users can modify their hotkeys, though this doesn't |
| exist at the moment. |
| |
| ### Tracks |
| In order to add a new track to the timeline, you'll need to create two entities: |
| - A track 'renderer' which controls what the track looks like and how it fetches |
| data from trace processor. |
| - A track 'node' controls where the track appears in the workspace. |
| |
| Track renderers are powerful but complex, so it's, so it's strongly advised not |
| to create your own. Instead, by far the easiest way to get started with tracks |
| is to use the `createQuerySliceTrack` and `createQueryCounterTrack` helpers. |
| |
| Example: |
| ```ts |
| import {createQuerySliceTrack} from '../../public/lib/tracks/query_slice_track'; |
| |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace) { |
| const title = 'My Track'; |
| const uri = `${trace.pluginId}#MyTrack`; |
| const query = 'select * from slice where track_id = 123'; |
| |
| // Create a new track renderer based on a query |
| const track = await createQuerySliceTrack({ |
| trace, |
| uri, |
| data: { |
| sqlSource: query, |
| }, |
| }); |
| |
| // Register the track renderer with the core |
| trace.tracks.registerTrack({uri, title, track}); |
| |
| // Create a track node that references the track renderer using its uri |
| const track = new TrackNode({uri, title}); |
| |
| // Add the track node to the current workspace |
| trace.workspace.addChildInOrder(track); |
| } |
| } |
| ``` |
| |
| See [the source](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/public/lib/tracks/query_slice_track.ts) |
| for detailed usage. |
| |
| You can also add a counter track using `createQueryCounterTrack` which works in |
| a similar way. |
| |
| ```ts |
| import {createQueryCounterTrack} from '../../public/lib/tracks/query_counter_track'; |
| |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace) { |
| const title = 'My Counter Track'; |
| const uri = `${trace.pluginId}#MyCounterTrack`; |
| const query = 'select * from counter where track_id = 123'; |
| |
| // Create a new track renderer based on a query |
| const track = await createQueryCounterTrack({ |
| trace, |
| uri, |
| data: { |
| sqlSource: query, |
| }, |
| }); |
| |
| // Register the track renderer with the core |
| trace.tracks.registerTrack({uri, title, track}); |
| |
| // Create a track node that references the track renderer using its uri |
| const track = new TrackNode({uri, title}); |
| |
| // Add the track node to the current workspace |
| trace.workspace.addChildInOrder(track); |
| } |
| } |
| ``` |
| |
| See [the source](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/public/lib/tracks/query_counter_track.ts) |
| for detailed usage. |
| |
| #### Grouping Tracks |
| Any track can have children. Just add child nodes any `TrackNode` object using |
| its `addChildXYZ()` methods. Nested tracks are rendered as a collapsible tree. |
| |
| ```ts |
| const group = new TrackNode({title: 'Group'}); |
| trace.workspace.addChildInOrder(group); |
| group.addChildLast(new TrackNode({title: 'Child Track A'})); |
| group.addChildLast(new TrackNode({title: 'Child Track B'})); |
| group.addChildLast(new TrackNode({title: 'Child Track C'})); |
| ``` |
| |
| Tracks nodes with children can be collapsed and expanded manually by the user at |
| runtime, or programmatically using their `expand()` and `collapse()` methods. By |
| default tracks are collapsed, so to have tracks automatically expanded on |
| startup you'll need to call `expand()` after adding the track node. |
| |
| ```ts |
| group.expand(); |
| ``` |
| |
|  |
| |
| Summary tracks are behave slightly differently to ordinary tracks. Summary |
| tracks: |
| - Are rendered with a light blue background when collapsed, dark blue when |
| expanded. |
| - Stick to the top of the viewport when scrolling. |
| - Area selections made on the track apply to child tracks instead of the summary |
| track itself. |
| |
| To create a summary track, set the `isSummary: true` option in its initializer |
| list at creation time or set its `isSummary` property to true after creation. |
| |
| ```ts |
| const group = new TrackNode({title: 'Group', isSummary: true}); |
| // ~~~ or ~~~ |
| group.isSummary = true; |
| ``` |
| |
|  |
| |
| Examples |
| - [com.example.ExampleNestedTracks](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/com.example.ExampleNestedTracks/index.ts). |
| |
| #### Track Ordering |
| Tracks can be manually reordered using the `addChildXYZ()` functions available on |
| the track node api, including `addChildFirst()`, `addChildLast()`, |
| `addChildBefore()`, and `addChildAfter()`. |
| |
| See [the workspace source](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/public/workspace.ts) for detailed usage. |
| |
| However, when several plugins add tracks to the same node or the workspace, no |
| single plugin has complete control over the sorting of child nodes within this |
| node. Thus, the sortOrder property is be used to decentralize the sorting logic |
| between plugins. |
| |
| In order to do this we simply give the track a `sortOrder` and call |
| `addChildInOrder()` on the parent node and the track will be placed before the |
| first track with a higher `sortOrder` in the list. (i.e. lower `sortOrder`s appear |
| higher in the stack). |
| |
| ```ts |
| // PluginA |
| workspace.addChildInOrder(new TrackNode({title: 'Foo', sortOrder: 10})); |
| |
| // Plugin B |
| workspace.addChildInOrder(new TrackNode({title: 'Bar', sortOrder: -10})); |
| ``` |
| |
| Now it doesn't matter which order plugin are initialized, track `Bar` will |
| appear above track `Foo` (unless reordered later). |
| |
| If no `sortOrder` is defined, the track assumes a `sortOrder` of 0. |
| |
| > It is recommended to always use `addChildInOrder()` in plugins when adding |
| > tracks to the `workspace`, especially if you want your plugin to be enabled by |
| > default, as this will ensure it respects the sortOrder of other plugins. |
| |
| |
| ### Tabs |
| Tabs are a useful way to display contextual information about the trace, the |
| current selection, or to show the results of an operation. |
| |
| To register a tab from a plugin, use the `Trace.registerTab` method. |
| |
| ```ts |
| class MyTab implements Tab { |
| render(): m.Children { |
| return m('div', 'Hello from my tab'); |
| } |
| |
| getTitle(): string { |
| return 'My Tab'; |
| } |
| } |
| |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace) { |
| trace.registerTab({ |
| uri: `${trace.pluginId}#MyTab`, |
| content: new MyTab(), |
| }); |
| } |
| } |
| ``` |
| |
| You'll need to pass in a tab-like object, something that implements the `Tab` |
| interface. Tabs only need to define their title and a render function which |
| specifies how to render the tab. |
| |
| Registered tabs don't appear immediately - we need to show it first. All |
| registered tabs are displayed in the tab dropdown menu, and can be shown or |
| hidden by clicking on the entries in the drop down menu. |
| |
| Tabs can also be hidden by clicking the little x in the top right of their |
| handle. |
| |
| Alternatively, tabs may be shown or hidden programmatically using the tabs API. |
| |
| ```ts |
| trace.tabs.showTab(`${trace.pluginId}#MyTab`); |
| trace.tabs.hideTab(`${trace.pluginId}#MyTab`); |
| ``` |
| |
| Tabs have the following properties: |
| - Each tab has a unique URI. |
| - Only once instance of the tab may be open at a time. Calling showTab multiple |
| times with the same URI will only activate the tab, not add a new instance of |
| the tab to the tab bar. |
| |
| #### Ephemeral Tabs |
| |
| By default, tabs are registered as 'permanent' tabs. These tabs have the |
| following additional properties: |
| - They appear in the tab dropdown. |
| - They remain once closed. The plugin controls the lifetime of the tab object. |
| |
| Ephemeral tabs, by contrast, have the following properties: |
| - They do not appear in the tab dropdown. |
| - When they are hidden, they will be automatically unregistered. |
| |
| Ephemeral tabs can be registered by setting the `isEphemeral` flag when |
| registering the tab. |
| |
| ```ts |
| trace.registerTab({ |
| isEphemeral: true, |
| uri: `${trace.pluginId}#MyTab`, |
| content: new MyEphemeralTab(), |
| }); |
| ``` |
| |
| Ephemeral tabs are usually added as a result of some user action, such as |
| running a command. Thus, it's common pattern to register a tab and show the tab |
| simultaneously. |
| |
| Motivating example: |
| ```ts |
| import m from 'mithril'; |
| import {uuidv4} from '../../base/uuid'; |
| |
| class MyNameTab implements Tab { |
| constructor(private name: string) {} |
| render(): m.Children { |
| return m('h1', `Hello, ${this.name}!`); |
| } |
| getTitle(): string { |
| return 'My Name Tab'; |
| } |
| } |
| |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace): Promise<void> { |
| trace.registerCommand({ |
| id: `${trace.pluginId}#AddNewEphemeralTab`, |
| name: 'Add new ephemeral tab', |
| callback: () => handleCommand(trace), |
| }); |
| } |
| } |
| |
| function handleCommand(trace: Trace): void { |
| const name = prompt('What is your name'); |
| if (name) { |
| const uri = `${trace.pluginId}#MyName${uuidv4()}`; |
| // This makes the tab available to perfetto |
| ctx.registerTab({ |
| isEphemeral: true, |
| uri, |
| content: new MyNameTab(name), |
| }); |
| |
| // This opens the tab in the tab bar |
| ctx.tabs.showTab(uri); |
| } |
| } |
| ``` |
| |
| ### Details Panels & The Current Selection Tab |
| The "Current Selection" tab is a special tab that cannot be hidden. It remains |
| permanently in the left-most tab position in the tab bar. Its purpose is to |
| display details about the current selection. |
| |
| Plugins may register interest in providing content for this tab using the |
| `PluginContentTrace.registerDetailsPanel()` method. |
| |
| For example: |
| |
| ```ts |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace) { |
| trace.registerDetailsPanel({ |
| render(selection: Selection) { |
| if (canHandleSelection(selection)) { |
| return m('div', 'Details for selection'); |
| } else { |
| return undefined; |
| } |
| } |
| }); |
| } |
| } |
| ``` |
| |
| This function takes an object that implements the `DetailsPanel` interface, |
| which only requires a render function to be implemented that takes the current |
| selection object and returns either mithril vnodes or a falsy value. |
| |
| Every render cycle, render is called on all registered details panels, and the |
| first registered panel to return a truthy value will be used. |
| |
| Currently the winning details panel takes complete control over this tab. Also, |
| the order that these panels are called in is not defined, so if we have multiple |
| details panels competing for the same selection, the one that actually shows up |
| is undefined. This is a limitation of the current approach and will be updated |
| to a more democratic contribution model in the future. |
| |
| ### Sidebar Menu Items |
| Plugins can add new entries to the sidebar menu which appears on the left hand |
| side of the UI. These entries can include: |
| - Commands |
| - Links |
| - Arbitrary Callbacks |
| |
| #### Commands |
| If a command is referenced, the command name and hotkey are displayed on the |
| sidebar item. |
| ```ts |
| trace.commands.registerCommand({ |
| id: 'sayHi', |
| name: 'Say hi', |
| callback: () => window.alert('hi'), |
| defaultHotkey: 'Shift+H', |
| }); |
| |
| trace.sidebar.addMenuItem({ |
| commandId: 'sayHi', |
| section: 'support', |
| icon: 'waving_hand', |
| }); |
| ``` |
| |
| #### Links |
| If an href is present, the sidebar will be used as a link. This can be an |
| internal link to a page, or an external link. |
| ```ts |
| trace.sidebar.addMenuItem({ |
| section: 'navigation', |
| text: 'Plugins', |
| href: '#!/plugins', |
| }); |
| ``` |
| |
| #### Callbacks |
| Sidebar items can be instructed to execute arbitrary callbacks when the button |
| is clicked. |
| ```ts |
| trace.sidebar.addMenuItem({ |
| section: 'current_trace', |
| text: 'Copy secrets to clipboard', |
| action: () => copyToClipboard('...'), |
| }); |
| ``` |
| |
| If the action returns a promise, the sidebar item will show a little spinner |
| animation until the promise returns. |
| |
| ```ts |
| trace.sidebar.addMenuItem({ |
| section: 'current_trace', |
| text: 'Prepare the data...', |
| action: () => new Promise((r) => setTimeout(r, 1000)), |
| }); |
| ``` |
| Optional params for all types of sidebar items: |
| - `icon` - A material design icon to be displayed next to the sidebar menu item. |
| See full list [here](https://fonts.google.com/icons). |
| - `tooltip` - Displayed on hover |
| - `section` - Where to place the menu item. |
| - `navigation` |
| - `current_trace` |
| - `convert_trace` |
| - `example_traces` |
| - `support` |
| - `sortOrder` - The higher the sortOrder the higher the bar. |
| |
| See the [sidebar source](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/public/sidebar.ts) |
| for more detailed usage. |
| |
| ### Pages |
| Pages are entities that can be routed via the URL args, and whose content take |
| up the entire available space to the right of the sidebar and underneath the |
| topbar. Examples of pages are the timeline, record page, and query page, just to |
| name a few common examples. |
| |
| E.g. |
| ``` |
| http://ui.perfetto.dev/#!/viewer <-- 'viewer' is is the current page. |
| ``` |
| |
| Pages are added from a plugin by calling the `pages.registerPage` function. |
| |
| Pages can be trace-less or trace-ful. Trace-less pages are pages that are to be |
| displayed when no trace is loaded - i.e. the record page. Trace-ful pages are |
| displayed only when a trace is loaded, as they typically require a trace to work |
| with. |
| |
| You'll typically register trace-less pages in your plugin's `onActivate()` |
| function and trace-full pages in either `onActivate()` or `onTraceLoad()`. If |
| users navigate to a trace-ful page before a trace is loaded the homepage will be |
| shown instead. |
| |
| > Note: You don't need to bind the `Trace` object for pages unlike other |
| > extension points, Perfetto will inject a trace object for you. |
| |
| Pages should be mithril components that accept `PageWithTraceAttrs` for |
| trace-ful pages or `PageAttrs` for trace-less pages. |
| |
| Example of a trace-less page: |
| ```ts |
| import m from 'mithril'; |
| import {PageAttrs} from '../../public/page'; |
| |
| class MyPage implements m.ClassComponent<PageAttrs> { |
| view(vnode: m.CVnode<PageAttrs>) { |
| return `The trace title is: ${vnode.attrs.trace.traceInfo.traceTitle}`; |
| } |
| } |
| |
| // ~~~ snip ~~~ |
| |
| app.pages.registerPage({route: '/mypage', page: MyPage, traceless: true}); |
| ``` |
| |
| ```ts |
| import m from 'mithril'; |
| import {PageWithTraceAttrs} from '../../public/page'; |
| |
| class MyPage implements m.ClassComponent<PageWithTraceAttrs> { |
| view(_vnode_: m.CVnode<PageWithTraceAttrs>) { |
| return 'Hello from my page'; |
| } |
| } |
| |
| // ~~~ snip ~~~ |
| |
| app.pages.registerPage({route: '/mypage', page: MyPage}); |
| ``` |
| |
| Examples: |
| - [dev.perfetto.ExplorePage](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExplorePage/index.ts). |
| |
| |
| ### Metric Visualisations |
| TBD |
| |
| Examples: |
| - [dev.perfetto.AndroidBinderViz](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts). |
| |
| ### State |
| NOTE: It is important to consider version skew when using persistent state. |
| |
| Plugins can persist information into permalinks. This allows plugins to |
| gracefully handle permalinking and is an opt-in - not automatic - mechanism. |
| |
| Persistent plugin state works using a `Store<T>` where `T` is some JSON |
| serializable object. `Store` is implemented |
| [here](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/base/store.ts). |
| `Store` allows for reading and writing `T`. Reading: |
| ```typescript |
| interface Foo { |
| bar: string; |
| } |
| |
| const store: Store<Foo> = getFooStoreSomehow(); |
| |
| // store.state is immutable and must not be edited. |
| const foo = store.state.foo; |
| const bar = foo.bar; |
| |
| console.log(bar); |
| ``` |
| |
| Writing: |
| ```typescript |
| interface Foo { |
| bar: string; |
| } |
| |
| const store: Store<Foo> = getFooStoreSomehow(); |
| |
| store.edit((draft) => { |
| draft.foo.bar = 'Hello, world!'; |
| }); |
| |
| console.log(store.state.foo.bar); |
| // > Hello, world! |
| ``` |
| |
| First define an interface for your specific plugin state. |
| ```typescript |
| interface MyState { |
| favouriteSlices: MySliceInfo[]; |
| } |
| ``` |
| |
| To access permalink state, call `mountStore()` on your `Trace` |
| object, passing in a migration function. |
| ```typescript |
| default export class implements PerfettoPlugin { |
| static readonly id = 'com.example.MyPlugin'; |
| async onTraceLoad(trace: Trace): Promise<void> { |
| const store = trace.mountStore(migrate); |
| } |
| } |
| |
| function migrate(initialState: unknown): MyState { |
| // ... |
| } |
| ``` |
| |
| When it comes to migration, there are two cases to consider: |
| - Loading a new trace |
| - Loading from a permalink |
| |
| In case of a new trace, your migration function is called with `undefined`. In |
| this case you should return a default version of `MyState`: |
| ```typescript |
| const DEFAULT = {favouriteSlices: []}; |
| |
| function migrate(initialState: unknown): MyState { |
| if (initialState === undefined) { |
| // Return default version of MyState. |
| return DEFAULT; |
| } else { |
| // Migrate old version here. |
| } |
| } |
| ``` |
| |
| In the permalink case, your migration function is called with the state of the |
| plugin store at the time the permalink was generated. This may be from an older |
| or newer version of the plugin. |
| |
| **Plugins must not make assumptions about the contents of `initialState`!** |
| |
| In this case you need to carefully validate the state object. This could be |
| achieved in several ways, none of which are particularly straight forward. State |
| migration is difficult! |
| |
| One brute force way would be to use a version number. |
| |
| ```typescript |
| interface MyState { |
| version: number; |
| favouriteSlices: MySliceInfo[]; |
| } |
| |
| const VERSION = 3; |
| const DEFAULT = {favouriteSlices: []}; |
| |
| function migrate(initialState: unknown): MyState { |
| if (initialState && (initialState as {version: any}).version === VERSION) { |
| // Version number checks out, assume the structure is correct. |
| return initialState as State; |
| } else { |
| // Null, undefined, or bad version number - return default value. |
| return DEFAULT; |
| } |
| } |
| ``` |
| |
| You'll need to remember to update your version number when making changes! |
| Migration should be unit-tested to ensure compatibility. |
| |
| Examples: |
| - [dev.perfetto.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleState/index.ts). |
| |
| ## Guide to the plugin API |
| The plugin interfaces are defined in |
| [ui/src/public/](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/public). |
| |
| ## Default plugins |
| Some plugins are enabled by default. These plugins are held to a higher quality |
| than non-default plugins since changes to those plugins effect all users of the |
| UI. The list of default plugins is specified at |
| [ui/src/core/default_plugins.ts](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/core/default_plugins.ts). |
| |
| ## Misc notes |
| - Plugins must be licensed under |
| [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) the same as all other |
| code in the repository. |
| - Plugins are the responsibility of the OWNERS of that plugin to maintain, not |
| the responsibility of the Perfetto team. All efforts will be made to keep the |
| plugin API stable and existing plugins working however plugins that remain |
| unmaintained for long periods of time will be disabled and ultimately deleted. |
| |