Add metric visualisations
Allow plugins to add vega visualisations for specific TraceProcessor
metrics. These visualisations appear on the metrics page next to the
given metrics.
Change-Id: I6762613444d65c3e136ee0f172254cb6e39063ba
diff --git a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256 b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
index dbe8f50..cb72f34 100644
--- a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
@@ -1 +1 @@
-1e9e74a4d2d5d3d8b600d4eb6d0fa3c3174c7fe99238637533f7cbed7bd89877
\ No newline at end of file
+660213bbb3da8bd84385b3e22809f4c8d3b6eae57fcdf95cd4fd336deb3cc1a5
\ No newline at end of file
diff --git a/ui/src/assets/metrics_page.scss b/ui/src/assets/metrics_page.scss
index 1487e34..b49b772 100644
--- a/ui/src/assets/metrics_page.scss
+++ b/ui/src/assets/metrics_page.scss
@@ -14,26 +14,22 @@
@import "widgets/theme";
+.metrics-page-picker {
+ display: flex;
+}
+
+.metrics-page-picker > * {
+ margin-right: 1rem;
+}
+
.metrics-page {
padding: 30px;
font-family: "Roboto", sans-serif;
- overflow-y: auto;
+ overflow-y: scroll;
+ overflow-x: hidden;
- .metric-run-button {
- background-color: #262f3c;
- color: #fff;
- border-radius: 4px;
- padding: 5px 10px;
- font-weight: bold;
- font-family: "Roboto";
- }
-
- select {
- margin: 10px;
- font-family: "Roboto";
- font-size: 1em;
- border: 1px solid black;
- background-color: #eee;
+ & > * {
+ margin-bottom: 1rem;
}
pre {
diff --git a/ui/src/assets/widgets/vega_view.scss b/ui/src/assets/widgets/vega_view.scss
index d0d31ed..df20e99 100644
--- a/ui/src/assets/widgets/vega_view.scss
+++ b/ui/src/assets/widgets/vega_view.scss
@@ -15,19 +15,10 @@
@import "theme";
.pf-vega-view {
- display: grid;
- align-items: center;
- justify-items: center;
- grid-template-rows: 1fr;
- grid-template-columns: 1fr;
border-radius: $pf-border-radius;
border: solid 1px $pf-colour-thin-border;
color: $pf-minimal-foreground;
-}
-
-.pf-vega-view > * {
- grid-column-start: 1;
- grid-row-start: 1;
+ position: relative;
}
.pf-vega-view-status {
@@ -36,4 +27,6 @@
height: 100%;
width: 100%;
padding: 1rem;
+ position: absolute;
+ top: 0;
}
diff --git a/ui/src/base/object_utils.ts b/ui/src/base/object_utils.ts
index 38ece5c..d8cdd38 100644
--- a/ui/src/base/object_utils.ts
+++ b/ui/src/base/object_utils.ts
@@ -29,3 +29,23 @@
}
return o;
}
+
+export function shallowEquals(a: any, b: any) {
+ if (a === b) {
+ return true;
+ }
+ if (a === undefined || b === undefined) {
+ return false;
+ }
+ for (const key of Object.keys(a)) {
+ if (a[key] !== b[key]) {
+ return false;
+ }
+ }
+ for (const key of Object.keys(b)) {
+ if (a[key] !== b[key]) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/ui/src/base/object_utils_unittest.ts b/ui/src/base/object_utils_unittest.ts
index b28ebb3..4668c4c 100644
--- a/ui/src/base/object_utils_unittest.ts
+++ b/ui/src/base/object_utils_unittest.ts
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {lookupPath} from './object_utils';
+import {lookupPath, shallowEquals} from './object_utils';
test('lookupPath', () => {
const nested = {baz: 'qux'};
@@ -29,3 +29,21 @@
expect(lookupPath(value, [])).toBe(value);
expect(lookupPath(value, ['baz'])).toBe(nested);
});
+
+test('shallowEquals', () => {
+ const one = 1;
+ const foo = 'Foo!';
+ const nestedFoo = {
+ foo,
+ };
+ const nestedFooDupe = {
+ foo,
+ };
+
+ expect(shallowEquals({}, {})).toBe(true);
+ expect(shallowEquals({one}, {})).toBe(false);
+ expect(shallowEquals({}, {one})).toBe(false);
+ expect(shallowEquals({one}, {one})).toBe(true);
+ expect(shallowEquals({nestedFoo}, {nestedFoo})).toBe(true);
+ expect(shallowEquals({nestedFoo}, {nestedFooDupe})).toBe(false);
+});
diff --git a/ui/src/base/result.ts b/ui/src/base/result.ts
new file mode 100644
index 0000000..653eb48
--- /dev/null
+++ b/ui/src/base/result.ts
@@ -0,0 +1,65 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export enum ResultStatus {
+ PENDING = 'pending',
+ SUCCESS = 'success',
+ ERROR = 'error',
+}
+
+export interface PendingResult {
+ status: ResultStatus.PENDING;
+}
+
+export interface ErrorResult {
+ status: ResultStatus.ERROR;
+ error: string;
+}
+
+export interface SuccessResult<T> {
+ status: ResultStatus.SUCCESS;
+ data: T;
+}
+
+export type Result<T> = PendingResult|ErrorResult|SuccessResult<T>;
+
+export function isError<T>(result: Result<T>): result is ErrorResult {
+ return result.status === ResultStatus.ERROR;
+}
+
+export function isPending<T>(result: Result<T>): result is PendingResult {
+ return result.status === ResultStatus.PENDING;
+}
+
+export function isSuccess<T>(result: Result<T>): result is SuccessResult<T> {
+ return result.status === ResultStatus.SUCCESS;
+}
+
+export function pending(): PendingResult {
+ return {status: ResultStatus.PENDING};
+}
+
+export function error(message: string): ErrorResult {
+ return {
+ status: ResultStatus.ERROR,
+ error: message,
+ };
+}
+
+export function success<T>(data: T): SuccessResult<T> {
+ return {
+ status: ResultStatus.SUCCESS,
+ data,
+ };
+}
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 6dfbac2..48ef058 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -1008,34 +1008,6 @@
state.lastRecordingError = undefined;
},
- requestSelectedMetric(state: StateDraft, _: {}): void {
- if (!state.metrics.availableMetrics) throw Error('No metrics available');
- if (state.metrics.selectedIndex === undefined) {
- throw Error('No metric selected');
- }
- state.metrics.requestedMetric =
- state.metrics.availableMetrics[state.metrics.selectedIndex];
- },
-
- resetMetricRequest(state: StateDraft, args: {name: string}): void {
- if (state.metrics.requestedMetric !== args.name) return;
- state.metrics.requestedMetric = undefined;
- },
-
- setAvailableMetrics(state: StateDraft, args: {availableMetrics: string[]}):
- void {
- state.metrics.availableMetrics = args.availableMetrics;
- if (args.availableMetrics.length > 0) state.metrics.selectedIndex = 0;
- },
-
- setMetricSelectedIndex(state: StateDraft, args: {index: number}): void {
- if (!state.metrics.availableMetrics ||
- args.index >= state.metrics.availableMetrics.length) {
- throw Error('metric selection out of bounds');
- }
- state.metrics.selectedIndex = args.index;
- },
-
togglePerfDebug(state: StateDraft, _: {}): void {
state.perfDebug = !state.perfDebug;
},
diff --git a/ui/src/common/empty_state.ts b/ui/src/common/empty_state.ts
index 6ba8b6a..c6d4248 100644
--- a/ui/src/common/empty_state.ts
+++ b/ui/src/common/empty_state.ts
@@ -102,7 +102,6 @@
scrollingTracks: [],
areas: {},
queries: {},
- metrics: {},
permalink: {},
notes: {},
visualisedArgs: [],
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index d295e49..46db5e1 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -22,6 +22,7 @@
import {
Command,
EngineProxy,
+ MetricVisualisation,
PluginContext,
PluginInfo,
Store,
@@ -209,6 +210,17 @@
}
});
}
+
+ metricVisualisations(): MetricVisualisation[] {
+ return Array.from(this.contexts.values()).flatMap((ctx) => {
+ const tracePlugin = ctx.tracePlugin;
+ if (tracePlugin && tracePlugin.metricVisualisations) {
+ return tracePlugin.metricVisualisations();
+ } else {
+ return [];
+ }
+ });
+ }
}
// TODO(hjd): Sort out the story for global singletons like these:
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index cc20223..69cbb9a 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -115,7 +115,8 @@
// 33. Add plugins state.
// 34. Add additional pendingDeeplink fields (query, pid).
// 35. Add force to OmniboxState
-export const STATE_VERSION = 35;
+// 36. Remove metrics
+export const STATE_VERSION = 36;
export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
@@ -428,12 +429,6 @@
sorting?: Sorting;
}
-export interface MetricsState {
- availableMetrics?: string[]; // Undefined until list is loaded.
- selectedIndex?: number;
- requestedMetric?: string; // Unset after metric request is handled.
-}
-
// Auxiliary metadata needed to parse the query result, as well as to render it
// correctly. Generated together with the text of query and passed without the
// change to the query response.
@@ -566,7 +561,6 @@
debugTrackId?: string;
lastTrackReloadRequest?: number;
queries: ObjectById<QueryConfig>;
- metrics: MetricsState;
permalink: PermalinkConfig;
notes: ObjectById<Note|AreaNote>;
status: Status;
diff --git a/ui/src/controller/metrics_controller.ts b/ui/src/controller/metrics_controller.ts
deleted file mode 100644
index 42b61ac..0000000
--- a/ui/src/controller/metrics_controller.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {Actions} from '../common/actions';
-import {Engine} from '../common/engine';
-import {QueryError} from '../common/query_result';
-import {globals} from '../frontend/globals';
-import {publishMetricResult} from '../frontend/publish';
-
-import {Controller} from './controller';
-
-export class MetricsController extends Controller<'main'> {
- private engine: Engine;
- private currentlyRunningMetric?: string;
-
- constructor(args: {engine: Engine}) {
- super('main');
- this.engine = args.engine;
- this.run();
- }
-
- private async computeMetric(name: string) {
- if (name === this.currentlyRunningMetric) return;
- this.currentlyRunningMetric = name;
- try {
- const metricResult = await this.engine.computeMetric([name], 'prototext');
- const resultString =
- metricResult instanceof Uint8Array ? '' : metricResult;
- publishMetricResult({
- name,
- resultString,
- });
- } catch (e) {
- if (e instanceof QueryError) {
- // Reroute error to be displated differently when metric is run through
- // metric page.
- publishMetricResult({name, error: e.message});
- } else {
- throw e;
- }
- }
- globals.dispatch(Actions.resetMetricRequest({name}));
- this.currentlyRunningMetric = undefined;
- }
-
- run() {
- const {requestedMetric} = globals.state.metrics;
- if (!requestedMetric) return;
- this.computeMetric(requestedMetric);
- }
-}
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index fb6405c..981f85a 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -110,7 +110,6 @@
import {FtraceController} from './ftrace_controller';
import {LoadingManager} from './loading_manager';
import {LogsController} from './logs_controller';
-import {MetricsController} from './metrics_controller';
import {
PIVOT_TABLE_REDUX_FLAG,
PivotTableController,
@@ -338,8 +337,7 @@
Child('ftrace', FtraceController, {engine, app: globals}));
childControllers.push(
- Child('traceError', TraceErrorController, {engine}));
- childControllers.push(Child('metrics', MetricsController, {engine}));
+ Child('traceError', TraceErrorController, {engine}));
return childControllers;
@@ -854,7 +852,6 @@
for (const it = metricsResult.iter({name: STR}); it.valid(); it.next()) {
availableMetrics.push(it.name);
}
- globals.dispatch(Actions.setAvailableMetrics({availableMetrics}));
const availableMetricsSet = new Set<string>(availableMetrics);
for (const [flag, metric] of FLAGGED_METRICS) {
diff --git a/ui/src/frontend/metrics_page.ts b/ui/src/frontend/metrics_page.ts
index 9692028..233927d 100644
--- a/ui/src/frontend/metrics_page.ts
+++ b/ui/src/frontend/metrics_page.ts
@@ -14,71 +14,274 @@
import m from 'mithril';
-import {Actions} from '../common/actions';
+import {
+ error,
+ isError,
+ isPending,
+ pending,
+ Result,
+ success,
+} from '../base/result';
+import {EngineProxy} from '../common/engine';
+import {pluginManager, PluginManager} from '../common/plugins';
+import {STR} from '../common/query_result';
+import {raf} from '../core/raf_scheduler';
+import {MetricVisualisation} from '../public';
+
import {globals} from './globals';
import {createPage} from './pages';
-import {Button} from './widgets/button';
+import {Select} from './widgets/select';
+import {Spinner} from './widgets/spinner';
+import {VegaView} from './widgets/vega_view';
-function getCurrSelectedMetric() {
- const {availableMetrics, selectedIndex} = globals.state.metrics;
- if (!availableMetrics) return undefined;
- if (selectedIndex === undefined) return undefined;
- return availableMetrics[selectedIndex];
+type Format = 'json'|'prototext'|'proto';
+const FORMATS: Format[] = ['json', 'prototext', 'proto'];
+
+function getEngine(): EngineProxy|undefined {
+ const engineId = globals.getCurrentEngine()?.id;
+ if (engineId === undefined) {
+ return undefined;
+ }
+ const engine = globals.engines.get(engineId)?.getProxy('MetricsPage');
+ return engine;
}
-class MetricResult implements m.ClassComponent {
- view() {
- const metricResult = globals.metricResult;
- if (metricResult === undefined) return undefined;
- const currSelection = getCurrSelectedMetric();
- if (!(metricResult && metricResult.name === currSelection)) {
- return undefined;
- }
- if (metricResult.error !== undefined) {
- return m('pre.metric-error', metricResult.error);
- }
- if (metricResult.resultString !== undefined) {
- return m('pre', metricResult.resultString);
- }
- return undefined;
+async function getMetrics(engine: EngineProxy): Promise<string[]> {
+ const metrics: string[] = [];
+ const metricsResult = await engine.query('select name from trace_metrics');
+ for (const it = metricsResult.iter({name: STR}); it.valid(); it.next()) {
+ metrics.push(it.name);
+ }
+ return metrics;
+}
+
+async function getMetric(
+ engine: EngineProxy, metric: string, format: Format): Promise<string> {
+ const result = await engine.computeMetric([metric], format);
+ if (result instanceof Uint8Array) {
+ return `Uint8Array<len=${result.length}>`;
+ } else {
+ return result;
}
}
-class MetricPicker implements m.ClassComponent {
- view() {
- const {availableMetrics, selectedIndex} = globals.state.metrics;
- if (availableMetrics === undefined) return 'Loading metrics...';
- if (availableMetrics.length === 0) return 'No metrics available';
- if (selectedIndex === undefined) {
- throw Error('Should not happen when avaibleMetrics is non-empty');
+class MetricsController {
+ engine: EngineProxy;
+ plugins: PluginManager;
+ private _metrics: string[];
+ private _selected?: string;
+ private _result: Result<string>;
+ private _format: Format;
+ private _json: any;
+
+ constructor(plugins: PluginManager, engine: EngineProxy) {
+ this.plugins = plugins;
+ this.engine = engine;
+ this._metrics = [];
+ this._result = success('');
+ this._json = {};
+ this._format = 'json';
+ getMetrics(this.engine).then((metrics) => {
+ this._metrics = metrics;
+ });
+ }
+
+ get metrics(): string[] {
+ return this._metrics;
+ }
+
+ get visualisations(): MetricVisualisation[] {
+ return this.plugins.metricVisualisations().filter(
+ (v) => v.metric === this.selected);
+ }
+
+ set selected(metric: string|undefined) {
+ if (this._selected === metric) {
+ return;
+ }
+ this._selected = metric;
+ this.update();
+ }
+
+ get selected(): string|undefined {
+ return this._selected;
+ }
+
+ set format(format: Format) {
+ if (this._format === format) {
+ return;
+ }
+ this._format = format;
+ this.update();
+ }
+
+ get format(): Format {
+ return this._format;
+ }
+
+ get result(): Result<string> {
+ return this._result;
+ }
+
+ get resultAsJson(): any {
+ console.log(this._json);
+ return this._json;
+ }
+
+ private update() {
+ const selected = this._selected;
+ const format = this._format;
+ if (selected === undefined) {
+ this._result = success('');
+ this._json = {};
+ } else {
+ this._result = pending();
+ this._json = {};
+ getMetric(this.engine, selected, format)
+ .then((result) => {
+ if (this._selected === selected && this._format === format) {
+ this._result = success(result);
+ if (format === 'json') {
+ this._json = JSON.parse(result);
+ }
+ }
+ })
+ .catch((e) => {
+ if (this._selected === selected && this._format === format) {
+ this._result = error(e);
+ this._json = {};
+ }
+ })
+ .finally(() => {
+ raf.scheduleFullRedraw();
+ });
+ }
+ raf.scheduleFullRedraw();
+ }
+}
+
+interface MetricResultAttrs {
+ result: Result<string>;
+}
+
+class MetricResultView implements m.ClassComponent<MetricResultAttrs> {
+ view({attrs}: m.CVnode<MetricResultAttrs>) {
+ const result = attrs.result;
+ if (isPending(result)) {
+ return m(Spinner);
}
- return m('div', [
- 'Select a metric:',
- m('select',
- {
- selectedIndex: globals.state.metrics.selectedIndex,
- onchange: (e: InputEvent) => {
- globals.dispatch(Actions.setMetricSelectedIndex(
- {index: (e.target as HTMLSelectElement).selectedIndex}));
+ if (isError(result)) {
+ return m('pre.metric-error', result.error);
+ }
+
+ return m('pre', result.data);
+ }
+}
+
+interface MetricPickerAttrs {
+ controller: MetricsController;
+}
+
+class MetricPicker implements m.ClassComponent<MetricPickerAttrs> {
+ view({attrs}: m.CVnode<MetricPickerAttrs>) {
+ const {controller} = attrs;
+ return m(
+ '.metrics-page-picker',
+ m(Select,
+ {
+ value: controller.selected,
+ oninput: (e: Event) => {
+ if (!e.target) return;
+ controller.selected = (e.target as HTMLSelectElement).value;
+ },
},
- },
- availableMetrics.map(
- (metric) => m('option', {value: metric, key: metric}, metric))),
- m(Button, {
- onclick: () => globals.dispatch(Actions.requestSelectedMetric({})),
- label: 'Run',
+ controller.metrics.map(
+ (metric) =>
+ m('option',
+ {
+ value: metric,
+ key: metric,
+ },
+ metric))),
+ m(
+ Select,
+ {
+ oninput: (e: Event) => {
+ if (!e.target) return;
+ controller.format =
+ (e.target as HTMLSelectElement).value as Format;
+ },
+ },
+ FORMATS.map((f) => {
+ return m('option', {
+ selected: controller.format === f,
+ key: f,
+ value: f,
+ label: f,
+ });
+ }),
+ ),
+ );
+ }
+}
+
+interface MetricVizViewAttrs {
+ visualisation: MetricVisualisation;
+ data: any;
+}
+
+class MetricVizView implements m.ClassComponent<MetricVizViewAttrs> {
+ view({attrs}: m.CVnode<MetricVizViewAttrs>) {
+ return m(
+ '',
+ m(VegaView, {
+ spec: attrs.visualisation.spec,
+ data: {
+ metric: attrs.data,
+ },
+ }),
+ );
+ }
+};
+
+class MetricPageContents implements m.ClassComponent {
+ controller?: MetricsController;
+
+ oncreate() {
+ const engine = getEngine();
+ if (engine !== undefined) {
+ this.controller = new MetricsController(pluginManager, engine);
+ }
+ }
+
+ view() {
+ const controller = this.controller;
+ if (controller === undefined) {
+ return m('');
+ }
+
+ const json = controller.resultAsJson;
+
+ return [
+ m(MetricPicker, {
+ controller,
}),
- ]);
+ (controller.format === 'json') &&
+ controller.visualisations.map((visualisation) => {
+ let data = json;
+ for (const p of visualisation.path) {
+ data = data[p] ?? [];
+ }
+ return m(MetricVizView, {visualisation, data});
+ }),
+ m(MetricResultView, {result: controller.result}),
+ ];
}
}
export const MetricsPage = createPage({
view() {
- return m(
- '.metrics-page',
- m(MetricPicker),
- m(MetricResult),
- );
+ return m('.metrics-page', m(MetricPageContents));
},
});
diff --git a/ui/src/frontend/widgets/vega_view.ts b/ui/src/frontend/widgets/vega_view.ts
index 145ca4c..6c69203 100644
--- a/ui/src/frontend/widgets/vega_view.ts
+++ b/ui/src/frontend/widgets/vega_view.ts
@@ -16,6 +16,9 @@
import * as vega from 'vega';
import * as vegaLite from 'vega-lite';
+import {Disposable} from '../../base/disposable';
+import {shallowEquals} from '../../base/object_utils';
+import {SimpleResizeObserver} from '../../base/resize_observer';
import {getErrorMessage} from '../../common/errors';
import {raf} from '../../core/raf_scheduler';
@@ -86,9 +89,16 @@
}
set data(value: VegaViewData) {
- if (this._data !== value) {
- this._data = value;
- this.updateView();
+ if (this._data === value || shallowEquals(this._data, value)) {
+ return;
+ }
+ this._data = value;
+ this.updateView();
+ }
+
+ onResize() {
+ if (this.view) {
+ this.view.resize();
}
}
@@ -180,12 +190,16 @@
export class VegaView implements m.ClassComponent<VegaViewAttrs> {
private wrapper?: VegaWrapper;
+ private resize?: Disposable;
oncreate({dom, attrs}: m.CVnodeDOM<VegaViewAttrs>) {
const wrapper = new VegaWrapper(dom.firstElementChild!);
wrapper.spec = attrs.spec;
wrapper.data = attrs.data;
this.wrapper = wrapper;
+ this.resize = new SimpleResizeObserver(dom, () => {
+ wrapper.onResize();
+ });
}
onupdate({attrs}: m.CVnodeDOM<VegaViewAttrs>) {
@@ -196,6 +210,10 @@
}
onremove() {
+ if (this.resize) {
+ this.resize.dispose();
+ this.resize = undefined;
+ }
if (this.wrapper) {
this.wrapper.dispose();
this.wrapper = undefined;
diff --git a/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts b/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts
new file mode 100644
index 0000000..8f01d35
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts
@@ -0,0 +1,69 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+ EngineProxy,
+ MetricVisualisation,
+ PluginContext,
+ Store,
+ TracePlugin,
+} from '../../public';
+
+const SPEC = `
+{
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+ "width": "container",
+ "height": 300,
+ "description": ".",
+ "data": {
+ "name": "metric"
+ },
+ "mark": "bar",
+ "encoding": {
+ "x": {"field": "client_process", "type": "nominal"},
+ "y": {"field": "client_dur", "aggregate": "max"}
+ }
+}
+`;
+
+interface State {}
+
+class AndroidBinderVizPlugin implements TracePlugin {
+ static migrate(_initialState: unknown): State {
+ return {};
+ }
+
+ constructor(_store: Store<State>, _engine: EngineProxy) {
+ // No-op
+ }
+
+ dispose(): void {
+ // No-op
+ }
+
+ metricVisualisations(): MetricVisualisation[] {
+ return [{
+ metric: 'android_binder',
+ spec: SPEC,
+ path: ['android_binder', 'unaggregated_txn_breakdown'],
+ }];
+ }
+}
+
+export const plugin = {
+ pluginId: 'dev.perfetto.AndroidBinderVizPlugin',
+ activate(ctx: PluginContext) {
+ ctx.registerTracePluginFactory(AndroidBinderVizPlugin);
+ },
+};
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index 92450b0..086628a 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -51,6 +51,39 @@
callback: (...args: any[]) => void;
}
+export interface MetricVisualisation {
+ // The name of the metric e.g. 'android_camera'
+ metric: string;
+
+ // A vega or vega-lite visualisation spec.
+ // The data from the metric under path will be exposed as a
+ // datasource named "metric" in Vega(-Lite)
+ spec: string;
+
+ // A path index into the metric.
+ // For example if the metric returns the folowing protobuf:
+ // {
+ // foo {
+ // bar {
+ // baz: { name: "a" }
+ // baz: { name: "b" }
+ // baz: { name: "c" }
+ // }
+ // }
+ // }
+ // That becomes the following json:
+ // { "foo": { "bar": { "baz": [
+ // {"name": "a"},
+ // {"name": "b"},
+ // {"name": "c"},
+ // ]}}}
+ // And given path = ["foo", "bar", "baz"]
+ // We extract:
+ // [ {"name": "a"}, {"name": "b"}, {"name": "c"} ]
+ // And pass that to the vega(-lite) visualisation.
+ path: string[];
+}
+
// All trace plugins must implement this interface.
export interface TracePlugin extends Disposable {
commands?: () => Command[];
@@ -59,6 +92,10 @@
// potential tracks. Zero or more of the provided tracks may be
// instantiated depending on the users choices.
tracks?: () => Promise<TrackInfo[]>;
+
+ // Metric visualisations. These extend the metrics page with
+ // visualisations for specific metrics.
+ metricVisualisations?: () => MetricVisualisation[];
}
// This interface defines what a plugin factory should look like.