From e3baf624b4224ed1075b815a074c9886e5beea87 Mon Sep 17 00:00:00 2001 From: Steve Golton Date: Thu, 20 Feb 2025 17:48:15 +0000 Subject: [PATCH] ui: Add proper interface for area selection details tabs This CL adds a new interface registerAreaSelectionTab() which plugins can use to add area selection details tabs under the area selection panel which appears in the current selection tab when an area is currently selected. This interface replaces the old registerAggregator() and registerDetailsPanel(), and an adapter is provided to adapt old aggregations to the new tab interface. Compared to the old aggregation interface, this new interface is a lot more flexible, simply allowing any arbitrary mithril vnodes to be rendered in the tab's content area, which permits more complex aggregation views such as flamegraphs. As a result, the various flamegraphs have been moved out of the core and into their relevant plugins. Change-Id: I61bd51b0bba0b3649bfbdd47f6dc48ddc2cb0d83 --- ui/src/components/aggregation_adapter.ts | 69 +++ .../aggregation_panel.ts | 57 +- .../attribute_modal_holder.ts | 4 +- .../pivot_table}/pivot_table.ts | 45 +- .../pivot_table_argument_popup.ts | 0 .../pivot_table_dragndrop_logic_unittest.ts | 0 .../pivot_table}/pivot_table_manager.ts | 29 +- .../pivot_table_query_generator.ts | 6 +- .../pivot_table_tree_builder_unittest.ts | 0 .../pivot_table}/pivot_table_types.ts | 6 +- .../pivot_table}/reorderable_cells.ts | 2 +- .../selection_aggregation_manager.ts | 66 +-- ui/src/core/default_plugins.ts | 1 + ui/src/core/flow_types.ts | 15 + ui/src/core/selection_manager.ts | 25 +- ui/src/core/tab_manager.ts | 7 - ui/src/core/trace_impl.ts | 14 - .../flow_events}/flow_events_panel.ts | 21 +- ui/src/core_plugins/flow_events/index.ts | 48 ++ ui/src/frontend/aggregation_tab.ts | 539 ------------------ .../{notes_editor_tab.ts => note_editor.ts} | 26 +- ui/src/frontend/ui_main.ts | 8 - .../viewer_page/current_selection_tab.ts | 156 +++-- .../viewer_page/flow_events_renderer.ts | 3 +- .../plugins/dev.perfetto.CpuProfile/index.ts | 91 +++ .../plugins/dev.perfetto.CpuSlices/index.ts | 12 +- .../frame_selection_aggregator.ts | 1 - ui/src/plugins/dev.perfetto.Frames/index.ts | 5 +- .../counter_selection_aggregator.ts | 10 + .../dev.perfetto.GenericAggregations/index.ts | 136 ++++- .../index.ts | 104 ++++ .../plugins/dev.perfetto.LinuxPerf/index.ts | 100 ++++ .../plugins/dev.perfetto.ThreadState/index.ts | 5 +- ui/src/plugins/org.kernel.Wattson/index.ts | 29 +- ui/src/public/selection.ts | 59 +- ui/src/public/tab.ts | 2 - 36 files changed, 874 insertions(+), 827 deletions(-) create mode 100644 ui/src/components/aggregation_adapter.ts rename ui/src/{frontend => components}/aggregation_panel.ts (76%) rename ui/src/{frontend/tables => components}/attribute_modal_holder.ts (91%) rename ui/src/{frontend => components/pivot_table}/pivot_table.ts (93%) rename ui/src/{frontend => components/pivot_table}/pivot_table_argument_popup.ts (100%) rename ui/src/{core => components/pivot_table}/pivot_table_dragndrop_logic_unittest.ts (100%) rename ui/src/{core => components/pivot_table}/pivot_table_manager.ts (96%) rename ui/src/{core => components/pivot_table}/pivot_table_query_generator.ts (96%) rename ui/src/{core => components/pivot_table}/pivot_table_tree_builder_unittest.ts (100%) rename ui/src/{core => components/pivot_table}/pivot_table_types.ts (97%) rename ui/src/{frontend => components/pivot_table}/reorderable_cells.ts (98%) rename ui/src/{core => components}/selection_aggregation_manager.ts (81%) rename ui/src/{frontend => core_plugins/flow_events}/flow_events_panel.ts (85%) create mode 100644 ui/src/core_plugins/flow_events/index.ts delete mode 100644 ui/src/frontend/aggregation_tab.ts rename ui/src/frontend/{notes_editor_tab.ts => note_editor.ts} (83%) diff --git a/ui/src/components/aggregation_adapter.ts b/ui/src/components/aggregation_adapter.ts new file mode 100644 index 0000000000..4478958f42 --- /dev/null +++ b/ui/src/components/aggregation_adapter.ts @@ -0,0 +1,69 @@ +// Copyright (C) 2025 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 m from 'mithril'; +import {AggregationPanel} from './aggregation_panel'; +import { + AreaSelection, + AreaSelectionAggregator, + areaSelectionsEqual, + AreaSelectionTab, +} from '../public/selection'; +import {Trace} from '../public/trace'; +import {SelectionAggregationManager} from './selection_aggregation_manager'; + +/** + * Creates an adapter that adapts an old style aggregation to a new area + * selection sub-tab. + */ +export function createAggregationToTabAdaptor( + trace: Trace, + aggregator: AreaSelectionAggregator, +): AreaSelectionTab { + const schemaSpecificity = + (aggregator.schema && Object.keys(aggregator.schema).length) ?? 0; + const kindRating = aggregator.trackKind === undefined ? 0 : 100; + const priority = kindRating + schemaSpecificity; + const aggMan = new SelectionAggregationManager(trace.engine, aggregator); + let currentSelection: AreaSelection | undefined; + + return { + id: aggregator.id, + name: aggregator.getTabName(), + priority, + render(selection: AreaSelection) { + if ( + currentSelection === undefined || + !areaSelectionsEqual(selection, currentSelection) + ) { + aggMan.aggregateArea(selection); + currentSelection = selection; + } + + const data = aggMan.aggregatedData; + if (!data) { + return undefined; + } + + return { + isLoading: false, + content: m(AggregationPanel, { + data, + trace, + model: aggMan, + }), + }; + }, + }; +} diff --git a/ui/src/frontend/aggregation_panel.ts b/ui/src/components/aggregation_panel.ts similarity index 76% rename from ui/src/frontend/aggregation_panel.ts rename to ui/src/components/aggregation_panel.ts index 47edeabeaa..3c8944a206 100644 --- a/ui/src/frontend/aggregation_panel.ts +++ b/ui/src/components/aggregation_panel.ts @@ -16,53 +16,35 @@ import m from 'mithril'; import { AggregateData, Column, + Sorting, ThreadStateExtra, - isEmptyData, } from '../public/aggregation'; -import {colorForState} from '../components/colorizer'; -import {DurationWidget} from '../components/widgets/duration'; -import {EmptyState} from '../widgets/empty_state'; -import {Anchor} from '../widgets/anchor'; -import {Icons} from '../base/semantic_icons'; -import {translateState} from '../components/sql_utils/thread_state'; -import {TraceImpl} from '../core/trace_impl'; +import {colorForState} from './colorizer'; +import {DurationWidget} from './widgets/duration'; +import {translateState} from './sql_utils/thread_state'; +import {Trace} from '../public/trace'; export interface AggregationPanelAttrs { - data?: AggregateData; - aggregatorId: string; - trace: TraceImpl; + readonly trace: Trace; + readonly data: AggregateData; + readonly model: AggState; +} + +export interface AggState { + getSortingPrefs(): Sorting | undefined; + toggleSortingColumn(column: string): void; } export class AggregationPanel implements m.ClassComponent { - private trace: TraceImpl; + private trace: Trace; constructor({attrs}: m.CVnode) { this.trace = attrs.trace; } view({attrs}: m.CVnode) { - if (!attrs.data || isEmptyData(attrs.data)) { - return m( - EmptyState, - { - className: 'pf-noselection', - title: 'No relevant tracks in selection', - }, - m( - Anchor, - { - icon: Icons.ChangeTab, - onclick: () => { - this.trace.tabs.showCurrentSelectionTab(); - }, - }, - 'Go to current selection tab', - ), - ); - } - return m( '.details-panel', m( @@ -77,7 +59,7 @@ export class AggregationPanel m( 'tr', attrs.data.columns.map((col) => - this.formatColumnHeading(attrs.trace, col, attrs.aggregatorId), + this.formatColumnHeading(col, attrs.model), ), ), m( @@ -93,8 +75,8 @@ export class AggregationPanel ); } - formatColumnHeading(trace: TraceImpl, col: Column, aggregatorId: string) { - const pref = trace.selection.aggregation.getSortingPrefs(aggregatorId); + formatColumnHeading(col: Column, model: AggState) { + const pref = model.getSortingPrefs(); let sortIcon = ''; if (pref && pref.column === col.columnId) { sortIcon = @@ -104,10 +86,7 @@ export class AggregationPanel 'th', { onclick: () => { - trace.selection.aggregation.toggleSortingColumn( - aggregatorId, - col.columnId, - ); + model.toggleSortingColumn(col.columnId); }, }, col.title, diff --git a/ui/src/frontend/tables/attribute_modal_holder.ts b/ui/src/components/attribute_modal_holder.ts similarity index 91% rename from ui/src/frontend/tables/attribute_modal_holder.ts rename to ui/src/components/attribute_modal_holder.ts index 8f94f5c2bd..dfa7b78260 100644 --- a/ui/src/frontend/tables/attribute_modal_holder.ts +++ b/ui/src/components/attribute_modal_holder.ts @@ -13,8 +13,8 @@ // limitations under the License. import m from 'mithril'; -import {showModal} from '../../widgets/modal'; -import {ArgumentPopup} from '../pivot_table_argument_popup'; +import {showModal} from '../widgets/modal'; +import {ArgumentPopup} from './pivot_table/pivot_table_argument_popup'; export class AttributeModalHolder { typedArgument = ''; diff --git a/ui/src/frontend/pivot_table.ts b/ui/src/components/pivot_table/pivot_table.ts similarity index 93% rename from ui/src/frontend/pivot_table.ts rename to ui/src/components/pivot_table/pivot_table.ts index 3060d070a8..63eccc17df 100644 --- a/ui/src/frontend/pivot_table.ts +++ b/ui/src/components/pivot_table/pivot_table.ts @@ -13,9 +13,9 @@ // limitations under the License. import m from 'mithril'; -import {SortDirection} from '../base/comparison_utils'; -import {sqliteString} from '../base/string_utils'; -import {DropDirection} from '../core/pivot_table_manager'; +import {SortDirection} from '../../base/comparison_utils'; +import {sqliteString} from '../../base/string_utils'; +import {DropDirection} from './pivot_table_manager'; import { PivotTableResult, Aggregation, @@ -24,28 +24,28 @@ import { PivotTree, TableColumn, COUNT_AGGREGATION, -} from '../core/pivot_table_types'; -import {AreaSelection} from '../public/selection'; -import {ColumnType} from '../trace_processor/query_result'; +} from './pivot_table_types'; +import {AreaSelection} from '../../public/selection'; +import {ColumnType} from '../../trace_processor/query_result'; import { aggregationIndex, areaFilters, sliceAggregationColumns, tables, -} from '../core/pivot_table_query_generator'; +} from './pivot_table_query_generator'; import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells'; -import {AttributeModalHolder} from './tables/attribute_modal_holder'; -import {DurationWidget} from '../components/widgets/duration'; -import {getSqlTableDescription} from '../components/widgets/sql/legacy_table/sql_table_registry'; -import {assertExists, assertFalse} from '../base/logging'; -import {TraceImpl} from '../core/trace_impl'; -import {PivotTableManager} from '../core/pivot_table_manager'; -import {extensions} from '../components/extensions'; -import {MenuItem, PopupMenu} from '../widgets/menu'; -import {Button} from '../widgets/button'; -import {popupMenuIcon} from '../widgets/table'; -import {SqlColumn} from '../components/widgets/sql/legacy_table/sql_column'; -import {Filter} from '../components/widgets/sql/legacy_table/filters'; +import {AttributeModalHolder} from '../attribute_modal_holder'; +import {DurationWidget} from '../widgets/duration'; +import {getSqlTableDescription} from '../widgets/sql/legacy_table/sql_table_registry'; +import {assertExists, assertFalse} from '../../base/logging'; +import {PivotTableManager} from './pivot_table_manager'; +import {extensions} from '../extensions'; +import {MenuItem, PopupMenu} from '../../widgets/menu'; +import {Button} from '../../widgets/button'; +import {popupMenuIcon} from '../../widgets/table'; +import {SqlColumn} from '../widgets/sql/legacy_table/sql_column'; +import {Filter} from '../widgets/sql/legacy_table/filters'; +import {Trace} from '../../public/trace'; interface PathItem { tree: PivotTree; @@ -53,8 +53,9 @@ interface PathItem { } interface PivotTableAttrs { - trace: TraceImpl; - selectionArea: AreaSelection; + readonly trace: Trace; + readonly selectionArea: AreaSelection; + readonly pivotMgr: PivotTableManager; } interface DrillFilter { @@ -117,7 +118,7 @@ export class PivotTable implements m.ClassComponent { private pivotMgr: PivotTableManager; constructor({attrs}: m.CVnode) { - this.pivotMgr = attrs.trace.pivotTable; + this.pivotMgr = attrs.pivotMgr; this.attributeModalHolder = new AttributeModalHolder((arg) => this.pivotMgr.setPivotSelected({ column: {kind: 'argument', argument: arg}, diff --git a/ui/src/frontend/pivot_table_argument_popup.ts b/ui/src/components/pivot_table/pivot_table_argument_popup.ts similarity index 100% rename from ui/src/frontend/pivot_table_argument_popup.ts rename to ui/src/components/pivot_table/pivot_table_argument_popup.ts diff --git a/ui/src/core/pivot_table_dragndrop_logic_unittest.ts b/ui/src/components/pivot_table/pivot_table_dragndrop_logic_unittest.ts similarity index 100% rename from ui/src/core/pivot_table_dragndrop_logic_unittest.ts rename to ui/src/components/pivot_table/pivot_table_dragndrop_logic_unittest.ts diff --git a/ui/src/core/pivot_table_manager.ts b/ui/src/components/pivot_table/pivot_table_manager.ts similarity index 96% rename from ui/src/core/pivot_table_manager.ts rename to ui/src/components/pivot_table/pivot_table_manager.ts index 551125d757..51cbcb0f47 100644 --- a/ui/src/core/pivot_table_manager.ts +++ b/ui/src/components/pivot_table/pivot_table_manager.ts @@ -12,28 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. +import {SortDirection} from '../../base/comparison_utils'; +import {assertTrue} from '../../base/logging'; +import {featureFlags} from '../../core/feature_flags'; +import {AreaSelection} from '../../public/selection'; +import {Engine} from '../../trace_processor/engine'; +import {ColumnType} from '../../trace_processor/query_result'; import { + aggregationIndex, + generateQueryFromState, +} from './pivot_table_query_generator'; +import { + Aggregation, + AggregationFunction, + COUNT_AGGREGATION, PivotTableQuery, PivotTableQueryMetadata, PivotTableResult, PivotTableState, - COUNT_AGGREGATION, + PivotTree, TableColumn, - toggleEnabled, tableColumnEquals, - AggregationFunction, + toggleEnabled, } from './pivot_table_types'; -import {AreaSelection} from '../public/selection'; -import { - aggregationIndex, - generateQueryFromState, -} from './pivot_table_query_generator'; -import {Aggregation, PivotTree} from './pivot_table_types'; -import {Engine} from '../trace_processor/engine'; -import {ColumnType} from '../trace_processor/query_result'; -import {SortDirection} from '../base/comparison_utils'; -import {assertTrue} from '../base/logging'; -import {featureFlags} from './feature_flags'; export const PIVOT_TABLE_REDUX_FLAG = featureFlags.register({ id: 'pivotTable', diff --git a/ui/src/core/pivot_table_query_generator.ts b/ui/src/components/pivot_table/pivot_table_query_generator.ts similarity index 96% rename from ui/src/core/pivot_table_query_generator.ts rename to ui/src/components/pivot_table/pivot_table_query_generator.ts index dfaa9196cf..4179746ea1 100644 --- a/ui/src/core/pivot_table_query_generator.ts +++ b/ui/src/components/pivot_table/pivot_table_query_generator.ts @@ -12,15 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {sqliteString} from '../base/string_utils'; +import {sqliteString} from '../../base/string_utils'; import { PivotTableQuery, PivotTableState, Aggregation, TableColumn, } from './pivot_table_types'; -import {AreaSelection} from '../public/selection'; -import {SLICE_TRACK_KIND} from '../public/track_kinds'; +import {AreaSelection} from '../../public/selection'; +import {SLICE_TRACK_KIND} from '../../public/track_kinds'; interface Table { name: string; diff --git a/ui/src/core/pivot_table_tree_builder_unittest.ts b/ui/src/components/pivot_table/pivot_table_tree_builder_unittest.ts similarity index 100% rename from ui/src/core/pivot_table_tree_builder_unittest.ts rename to ui/src/components/pivot_table/pivot_table_tree_builder_unittest.ts diff --git a/ui/src/core/pivot_table_types.ts b/ui/src/components/pivot_table/pivot_table_types.ts similarity index 97% rename from ui/src/core/pivot_table_types.ts rename to ui/src/components/pivot_table/pivot_table_types.ts index ef7fb57b61..eaed26d3ee 100644 --- a/ui/src/core/pivot_table_types.ts +++ b/ui/src/components/pivot_table/pivot_table_types.ts @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {SortDirection} from '../base/comparison_utils'; -import {AreaSelection} from '../public/selection'; -import {ColumnType} from '../trace_processor/query_result'; +import {SortDirection} from '../../base/comparison_utils'; +import {AreaSelection} from '../../public/selection'; +import {ColumnType} from '../../trace_processor/query_result'; // 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 diff --git a/ui/src/frontend/reorderable_cells.ts b/ui/src/components/pivot_table/reorderable_cells.ts similarity index 98% rename from ui/src/frontend/reorderable_cells.ts rename to ui/src/components/pivot_table/reorderable_cells.ts index c32759152b..6c387d8b72 100644 --- a/ui/src/frontend/reorderable_cells.ts +++ b/ui/src/components/pivot_table/reorderable_cells.ts @@ -13,7 +13,7 @@ // limitations under the License. import m from 'mithril'; -import {DropDirection} from '../core/pivot_table_manager'; +import {DropDirection} from './pivot_table_manager'; export interface ReorderableCell { content: m.Children; diff --git a/ui/src/core/selection_aggregation_manager.ts b/ui/src/components/selection_aggregation_manager.ts similarity index 81% rename from ui/src/core/selection_aggregation_manager.ts rename to ui/src/components/selection_aggregation_manager.ts index f293e12253..902a9dc13d 100644 --- a/ui/src/core/selection_aggregation_manager.ts +++ b/ui/src/components/selection_aggregation_manager.ts @@ -20,35 +20,29 @@ import {TrackDescriptor} from '../public/track'; import {Dataset, UnionDataset} from '../trace_processor/dataset'; import {Engine} from '../trace_processor/engine'; import {NUM} from '../trace_processor/query_result'; -import {raf} from './raf_scheduler'; export class SelectionAggregationManager { - private engine: Engine; private readonly limiter = new AsyncLimiter(); - private _aggregators = new Array(); - private _aggregatedData = new Map(); - private _sorting = new Map(); + private _sorting?: Sorting; private _currentArea: AreaSelection | undefined = undefined; + private _aggregatedData?: AggregateData; - constructor(engine: Engine) { - this.engine = engine; - } + constructor( + private readonly engine: Engine, + private readonly aggregator: AreaSelectionAggregator, + ) {} - registerAggregator(aggr: AreaSelectionAggregator) { - this._aggregators.push(aggr); + get aggregatedData(): AggregateData | undefined { + return this._aggregatedData; } aggregateArea(area: AreaSelection) { this.limiter.schedule(async () => { this._currentArea = area; - this._aggregatedData.clear(); - for (const aggr of this._aggregators) { - const data = await this.runAggregator(aggr, area); - if (data !== undefined) { - this._aggregatedData.set(aggr.id, data); - } - } - raf.scheduleFullRedraw(); + this._aggregatedData = undefined; + + const data = await this.runAggregator(area); + this._aggregatedData = data; }); } @@ -58,34 +52,32 @@ export class SelectionAggregationManager { // with the aggregation being displayed anyways once the promise completes. this.limiter.schedule(async () => { this._currentArea = undefined; - this._aggregatedData.clear(); - this._sorting.clear(); - raf.scheduleFullRedraw(); + this._aggregatedData = undefined; + this._sorting = undefined; }); } - getSortingPrefs(aggregatorId: string): Sorting | undefined { - return this._sorting.get(aggregatorId); + getSortingPrefs(): Sorting | undefined { + return this._sorting; } - toggleSortingColumn(aggregatorId: string, column: string) { - const sorting = this._sorting.get(aggregatorId); - + toggleSortingColumn(column: string) { + const sorting = this._sorting; if (sorting === undefined || sorting.column !== column) { // No sorting set for current column. - this._sorting.set(aggregatorId, { + this._sorting = { column, direction: 'DESC', - }); + }; } else if (sorting.direction === 'DESC') { // Toggle the direction if the column is currently sorted. - this._sorting.set(aggregatorId, { + this._sorting = { column, direction: 'ASC', - }); + }; } else { // If direction is currently 'ASC' toggle to no sorting. - this._sorting.delete(aggregatorId); + this._sorting = undefined; } // Re-run the aggregation. @@ -94,18 +86,10 @@ export class SelectionAggregationManager { } } - get aggregators(): ReadonlyArray { - return this._aggregators; - } - - getAggregatedData(aggregatorId: string): AggregateData | undefined { - return this._aggregatedData.get(aggregatorId); - } - private async runAggregator( - aggr: AreaSelectionAggregator, area: AreaSelection, ): Promise { + const aggr = this.aggregator; const dataset = this.createDatasetForAggregator(aggr, area.tracks); const viewExists = await aggr.createAggregateView( this.engine, @@ -119,7 +103,7 @@ export class SelectionAggregationManager { const defs = aggr.getColumnDefinitions(); const colIds = defs.map((col) => col.columnId); - const sorting = this._sorting.get(aggr.id); + const sorting = this._sorting; let sortClause = `${aggr.getDefaultSorting().column} ${ aggr.getDefaultSorting().direction }`; diff --git a/ui/src/core/default_plugins.ts b/ui/src/core/default_plugins.ts index 16e9600027..a9d78dabf7 100644 --- a/ui/src/core/default_plugins.ts +++ b/ui/src/core/default_plugins.ts @@ -80,6 +80,7 @@ export const defaultPlugins = [ 'org.kernel.Wattson', 'perfetto.CoreCommands', 'perfetto.ExampleTraces', + 'perfetto.FlowEvents', 'perfetto.GlobalGroups', 'perfetto.TrackUtils', ]; diff --git a/ui/src/core/flow_types.ts b/ui/src/core/flow_types.ts index 31b7595fb7..d5b7d95a28 100644 --- a/ui/src/core/flow_types.ts +++ b/ui/src/core/flow_types.ts @@ -54,3 +54,18 @@ export interface FlowPoint { } export type FlowDirection = 'Forward' | 'Backward'; + +export const ALL_CATEGORIES = '_all_'; + +export function getFlowCategories(flow: Flow): string[] { + const categories: string[] = []; + // v1 flows have their own categories + if (flow.category) { + categories.push(...flow.category.split(',')); + return categories; + } + const beginCats = flow.begin.sliceCategory.split(','); + const endCats = flow.end.sliceCategory.split(','); + categories.push(...new Set([...beginCats, ...endCats])); + return categories; +} diff --git a/ui/src/core/selection_manager.ts b/ui/src/core/selection_manager.ts index e55611e72b..1c0adf9264 100644 --- a/ui/src/core/selection_manager.ts +++ b/ui/src/core/selection_manager.ts @@ -18,8 +18,8 @@ import { Area, SelectionOpts, SelectionManager, - AreaSelectionAggregator, TrackEventSelection, + AreaSelectionTab, } from '../public/selection'; import {TimeSpan} from '../base/time'; import {raf} from './raf_scheduler'; @@ -29,7 +29,6 @@ import {Engine} from '../trace_processor/engine'; import {ScrollHelper} from './scroll_helper'; import {NoteManagerImpl} from './note_manager'; import {SearchResult} from '../public/search'; -import {SelectionAggregationManager} from './selection_aggregation_manager'; import {AsyncLimiter} from '../base/async_limiter'; import m from 'mithril'; import {SerializedSelection} from './state_serialization_schema'; @@ -55,11 +54,11 @@ interface SelectionDetailsPanel { export class SelectionManagerImpl implements SelectionManager { private readonly detailsPanelLimiter = new AsyncLimiter(); private _selection: Selection = {kind: 'empty'}; - private _aggregationManager: SelectionAggregationManager; private readonly detailsPanels = new WeakMap< Selection, SelectionDetailsPanel >(); + public readonly areaSelectionTabs: AreaSelectionTab[] = []; constructor( private readonly engine: Engine, @@ -67,15 +66,7 @@ export class SelectionManagerImpl implements SelectionManager { private noteManager: NoteManagerImpl, private scrollHelper: ScrollHelper, private onSelectionChange: (s: Selection, opts: SelectionOpts) => void, - ) { - this._aggregationManager = new SelectionAggregationManager( - engine.getProxy('SelectionAggregationManager'), - ); - } - - registerAreaSelectionAggregator(aggr: AreaSelectionAggregator): void { - this._aggregationManager.registerAggregator(aggr); - } + ) {} clear(): void { this.setSelection({kind: 'empty'}); @@ -330,12 +321,6 @@ export class SelectionManagerImpl implements SelectionManager { if (opts?.scrollToSelection) { this.scrollToCurrentSelection(); } - - if (this._selection.kind === 'area') { - this._aggregationManager.aggregateArea(this._selection); - } else { - this._aggregationManager.clear(); - } } selectSearchResult(searchResult: SearchResult) { @@ -488,7 +473,7 @@ export class SelectionManagerImpl implements SelectionManager { return undefined; } - get aggregation() { - return this._aggregationManager; + registerAreaSelectionTab(tab: AreaSelectionTab): void { + this.areaSelectionTabs.push(tab); } } diff --git a/ui/src/core/tab_manager.ts b/ui/src/core/tab_manager.ts index 3273122990..94306cda4b 100644 --- a/ui/src/core/tab_manager.ts +++ b/ui/src/core/tab_manager.ts @@ -62,13 +62,6 @@ export class TabManagerImpl implements TabManager, Disposable { }; } - registerDetailsPanel(section: DetailsPanel): Disposable { - this._detailsPanelRegistry.add(section); - return { - [Symbol.dispose]: () => this._detailsPanelRegistry.delete(section), - }; - } - resolveTab(uri: string): TabDescriptor | undefined { return this._registry.get(uri); } diff --git a/ui/src/core/trace_impl.ts b/ui/src/core/trace_impl.ts index 88d05f64ab..4e2cfdd4e1 100644 --- a/ui/src/core/trace_impl.ts +++ b/ui/src/core/trace_impl.ts @@ -33,7 +33,6 @@ import {SidebarMenuItem} from '../public/sidebar'; import {ScrollHelper} from './scroll_helper'; import {Selection, SelectionOpts} from '../public/selection'; import {SearchResult} from '../public/search'; -import {PivotTableManager} from './pivot_table_manager'; import {FlowManager} from './flow_manager'; import {AppContext, AppImpl} from './app_impl'; import {PluginManagerImpl} from './plugin_manager'; @@ -78,7 +77,6 @@ export class TraceContext implements Disposable { readonly flowMgr: FlowManager; readonly pluginSerializableState = createStore<{[key: string]: {}}>({}); readonly scrollHelper: ScrollHelper; - readonly pivotTableMgr; readonly trash = new DisposableStack(); readonly onTraceReady = new EvtSource(); @@ -118,10 +116,6 @@ export class TraceContext implements Disposable { } }; - this.pivotTableMgr = new PivotTableManager( - engine.getProxy('PivotTableManager'), - ); - this.flowMgr = new FlowManager( engine.getProxy('FlowManager'), this.trackMgr, @@ -148,10 +142,6 @@ export class TraceContext implements Disposable { this.tabMgr.showCurrentSelectionTab(); } - if (selection.kind === 'area') { - this.pivotTableMgr.setSelectionArea(selection); - } - this.flowMgr.updateFlows(selection); } @@ -364,10 +354,6 @@ export class TraceImpl implements Trace { return this.traceCtx.noteMgr; } - get pivotTable() { - return this.traceCtx.pivotTableMgr; - } - get flows() { return this.traceCtx.flowMgr; } diff --git a/ui/src/frontend/flow_events_panel.ts b/ui/src/core_plugins/flow_events/flow_events_panel.ts similarity index 85% rename from ui/src/frontend/flow_events_panel.ts rename to ui/src/core_plugins/flow_events/flow_events_panel.ts index acf5f6a6f1..2b41abaf56 100644 --- a/ui/src/frontend/flow_events_panel.ts +++ b/ui/src/core_plugins/flow_events/flow_events_panel.ts @@ -13,24 +13,9 @@ // limitations under the License. import m from 'mithril'; -import {Icons} from '../base/semantic_icons'; -import {Flow} from '../core/flow_types'; -import {TraceImpl} from '../core/trace_impl'; - -export const ALL_CATEGORIES = '_all_'; - -export function getFlowCategories(flow: Flow): string[] { - const categories: string[] = []; - // v1 flows have their own categories - if (flow.category) { - categories.push(...flow.category.split(',')); - return categories; - } - const beginCats = flow.begin.sliceCategory.split(','); - const endCats = flow.end.sliceCategory.split(','); - categories.push(...new Set([...beginCats, ...endCats])); - return categories; -} +import {Icons} from '../../base/semantic_icons'; +import {TraceImpl} from '../../core/trace_impl'; +import {ALL_CATEGORIES, getFlowCategories} from '../../core/flow_types'; export interface FlowEventsAreaSelectedPanelAttrs { trace: TraceImpl; diff --git a/ui/src/core_plugins/flow_events/index.ts b/ui/src/core_plugins/flow_events/index.ts new file mode 100644 index 0000000000..1311ff7149 --- /dev/null +++ b/ui/src/core_plugins/flow_events/index.ts @@ -0,0 +1,48 @@ +// Copyright (C) 2025 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 m from 'mithril'; +import {PerfettoPlugin} from '../../public/plugin'; +import {Trace} from '../../public/trace'; +import {TraceImpl} from '../../core/trace_impl'; +import {FlowEventsAreaSelectedPanel} from './flow_events_panel'; + +/** + * This plugin is a core plugin because for now flows are stored in the core and + * not exposed to plugins. In the future once we normalize how flows should + * work, we can reassess this and move it into wherever it needs to be. + */ +export default class implements PerfettoPlugin { + static readonly id = 'perfetto.FlowEvents'; + + async onTraceLoad(trace: Trace): Promise { + // This type assertion is allowed because we're a core plugin. + const traceImpl = trace as TraceImpl; + trace.selection.registerAreaSelectionTab({ + id: 'flow_events', + name: 'Flow Events', + priority: -100, + render() { + if (traceImpl.flows.selectedFlows.length > 0) { + return { + isLoading: false, + content: m(FlowEventsAreaSelectedPanel, {trace: traceImpl}), + }; + } else { + return undefined; + } + }, + }); + } +} diff --git a/ui/src/frontend/aggregation_tab.ts b/ui/src/frontend/aggregation_tab.ts deleted file mode 100644 index 708096a611..0000000000 --- a/ui/src/frontend/aggregation_tab.ts +++ /dev/null @@ -1,539 +0,0 @@ -// Copyright (C) 2024 The Android Open Source Project -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use size 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 m from 'mithril'; -import {AggregationPanel} from './aggregation_panel'; -import {isEmptyData} from '../public/aggregation'; -import {DetailsShell} from '../widgets/details_shell'; -import {Button, ButtonBar} from '../widgets/button'; -import {EmptyState} from '../widgets/empty_state'; -import {FlowEventsAreaSelectedPanel} from './flow_events_panel'; -import {PivotTable} from './pivot_table'; -import {AreaSelection} from '../public/selection'; -import {Monitor} from '../base/monitor'; -import { - CPU_PROFILE_TRACK_KIND, - PERF_SAMPLES_PROFILE_TRACK_KIND, - INSTRUMENTS_SAMPLES_PROFILE_TRACK_KIND, - SLICE_TRACK_KIND, -} from '../public/track_kinds'; -import { - QueryFlamegraph, - metricsFromTableOrSubquery, -} from '../components/query_flamegraph'; -import {DisposableStack} from '../base/disposable_stack'; -import {assertExists} from '../base/logging'; -import {TraceImpl} from '../core/trace_impl'; -import {Trace} from '../public/trace'; -import {Flamegraph} from '../widgets/flamegraph'; - -interface View { - readonly key: string; - readonly name: string; - readonly specificity?: { - readonly kind: number; - readonly schema: number; - }; - readonly content: m.Children; -} - -export type AreaDetailsPanelAttrs = {trace: TraceImpl}; - -class AreaDetailsPanel implements m.ClassComponent { - private trace: TraceImpl; - private monitor: Monitor; - private currentTab: string | undefined = undefined; - private cpuProfileFlamegraph?: QueryFlamegraph; - private perfSampleFlamegraph?: QueryFlamegraph; - private instrumentsSampleFlamegraph?: QueryFlamegraph; - private sliceFlamegraph?: QueryFlamegraph; - - constructor({attrs}: m.CVnode) { - this.trace = attrs.trace; - this.monitor = new Monitor([() => this.trace.selection.selection]); - } - - private getCurrentView(): string | undefined { - const types = this.getViews().map(({key}) => key); - - if (types.length === 0) { - return undefined; - } - - if (this.currentTab === undefined) { - return types[0]; - } - - if (!types.includes(this.currentTab)) { - return types[0]; - } - - return this.currentTab; - } - - private getViews(): View[] { - const views: View[] = []; - - for (const aggregator of this.trace.selection.aggregation.aggregators) { - const aggregatorId = aggregator.id; - const value = - this.trace.selection.aggregation.getAggregatedData(aggregatorId); - if (value !== undefined && !isEmptyData(value)) { - views.push({ - key: value.tabName, - name: value.tabName, - specificity: { - kind: aggregator.trackKind ? 1 : 0, - schema: aggregator.schema - ? Object.keys(aggregator.schema).length - : 0, - }, - content: m(AggregationPanel, { - aggregatorId, - data: value, - trace: this.trace, - }), - }); - } - } - - views.sort((a, b) => { - if (a.specificity === undefined || b.specificity === undefined) { - return 0; - } - - if (a.specificity.kind !== b.specificity.kind) { - return b.specificity.kind - a.specificity.kind; - } - - if (a.specificity.schema !== b.specificity.schema) { - return b.specificity.schema - a.specificity.schema; - } - - // If all else is equal, fall back to the registration order. - return 0; - }); - - const pivotTableState = this.trace.pivotTable.state; - const tree = pivotTableState.queryResult?.tree; - if ( - pivotTableState.selectionArea != undefined && - (tree === undefined || tree.children.size > 0 || tree?.rows.length > 0) - ) { - views.push({ - key: 'pivot_table', - name: 'Pivot Table', - content: m(PivotTable, { - trace: this.trace, - selectionArea: pivotTableState.selectionArea, - }), - }); - } - - this.addFlamegraphView(this.trace, this.monitor.ifStateChanged(), views); - - // Add this after all aggregation panels, to make it appear after 'Slices' - if (this.trace.flows.selectedFlows.length > 0) { - views.push({ - key: 'selected_flows', - name: 'Flow Events', - content: m(FlowEventsAreaSelectedPanel, {trace: this.trace}), - }); - } - - return views; - } - - view(): m.Children { - const views = this.getViews(); - const currentViewKey = this.getCurrentView(); - - const aggregationButtons = views.map(({key, name}) => { - return m(Button, { - onclick: () => { - this.currentTab = key; - }, - key, - label: name, - active: currentViewKey === key, - }); - }); - - if (currentViewKey === undefined) { - return this.renderEmptyState(); - } - - const content = views.find(({key}) => key === currentViewKey)?.content; - if (content === undefined) { - return this.renderEmptyState(); - } - - return m( - DetailsShell, - { - title: 'Area Selection', - description: m(ButtonBar, aggregationButtons), - }, - content, - ); - } - - private renderEmptyState(): m.Children { - return m( - EmptyState, - { - className: 'pf-noselection', - title: 'Unsupported area selection', - }, - 'No details available for this area selection', - ); - } - - private addFlamegraphView(trace: Trace, isChanged: boolean, views: View[]) { - this.cpuProfileFlamegraph = this.computeCpuProfileFlamegraph( - trace, - isChanged, - ); - if (this.cpuProfileFlamegraph !== undefined) { - views.push({ - key: 'cpu_profile_flamegraph_selection', - name: 'CPU Profile Sample Flamegraph', - content: this.cpuProfileFlamegraph.render(), - }); - } - this.perfSampleFlamegraph = this.computePerfSampleFlamegraph( - trace, - isChanged, - ); - if (this.perfSampleFlamegraph !== undefined) { - views.push({ - key: 'perf_sample_flamegraph_selection', - name: 'Perf Sample Flamegraph', - content: this.perfSampleFlamegraph.render(), - }); - } - this.instrumentsSampleFlamegraph = this.computeInstrumentsSampleFlamegraph( - trace, - isChanged, - ); - if (this.instrumentsSampleFlamegraph !== undefined) { - views.push({ - key: 'instruments_sample_flamegraph_selection', - name: 'Instruments Sample Flamegraph', - content: this.instrumentsSampleFlamegraph.render(), - }); - } - this.sliceFlamegraph = this.computeSliceFlamegraph(trace, isChanged); - if (this.sliceFlamegraph !== undefined) { - views.push({ - key: 'slice_flamegraph_selection', - name: 'Slice Flamegraph', - content: this.sliceFlamegraph.render(), - }); - } - } - - private computeCpuProfileFlamegraph(trace: Trace, isChanged: boolean) { - const currentSelection = trace.selection.selection; - if (currentSelection.kind !== 'area') { - return undefined; - } - if (!isChanged) { - // If the selection has not changed, just return a copy of the last seen - // attrs. - return this.cpuProfileFlamegraph; - } - const utids = []; - for (const trackInfo of currentSelection.tracks) { - if (trackInfo?.tags?.kind === CPU_PROFILE_TRACK_KIND) { - utids.push(trackInfo.tags?.utid); - } - } - if (utids.length === 0) { - return undefined; - } - const metrics = metricsFromTableOrSubquery( - ` - ( - select - id, - parent_id as parentId, - name, - mapping_name, - source_file, - cast(line_number AS text) as line_number, - self_count - from _callstacks_for_callsites!(( - select p.callsite_id - from cpu_profile_stack_sample p - where p.ts >= ${currentSelection.start} - and p.ts <= ${currentSelection.end} - and p.utid in (${utids.join(',')}) - )) - ) - `, - [ - { - name: 'CPU Profile Samples', - unit: '', - columnName: 'self_count', - }, - ], - 'include perfetto module callstacks.stack_profile', - [{name: 'mapping_name', displayName: 'Mapping'}], - [ - { - name: 'source_file', - displayName: 'Source File', - mergeAggregation: 'ONE_OR_NULL', - }, - { - name: 'line_number', - displayName: 'Line Number', - mergeAggregation: 'ONE_OR_NULL', - }, - ], - ); - return new QueryFlamegraph(trace, metrics, { - state: Flamegraph.createDefaultState(metrics), - }); - } - - private computePerfSampleFlamegraph(trace: Trace, isChanged: boolean) { - const currentSelection = trace.selection.selection; - if (currentSelection.kind !== 'area') { - return undefined; - } - if (!isChanged) { - // If the selection has not changed, just return a copy of the last seen - // attrs. - return this.perfSampleFlamegraph; - } - const upids = getUpidsFromPerfSampleAreaSelection(currentSelection); - const utids = getUtidsFromPerfSampleAreaSelection(currentSelection); - if (utids.length === 0 && upids.length === 0) { - return undefined; - } - const metrics = metricsFromTableOrSubquery( - ` - ( - select id, parent_id as parentId, name, self_count - from _callstacks_for_callsites!(( - select p.callsite_id - from perf_sample p - join thread t using (utid) - where p.ts >= ${currentSelection.start} - and p.ts <= ${currentSelection.end} - and ( - p.utid in (${utids.join(',')}) - or t.upid in (${upids.join(',')}) - ) - )) - ) - `, - [ - { - name: 'Perf Samples', - unit: '', - columnName: 'self_count', - }, - ], - 'include perfetto module linux.perf.samples', - ); - return new QueryFlamegraph(trace, metrics, { - state: Flamegraph.createDefaultState(metrics), - }); - } - - private computeInstrumentsSampleFlamegraph(trace: Trace, isChanged: boolean) { - const currentSelection = trace.selection.selection; - if (currentSelection.kind !== 'area') { - return undefined; - } - if (!isChanged) { - // If the selection has not changed, just return a copy of the last seen - // attrs. - return this.instrumentsSampleFlamegraph; - } - const upids = getUpidsFromInstrumentsSampleAreaSelection(currentSelection); - const utids = getUtidsFromInstrumentsSampleAreaSelection(currentSelection); - if (utids.length === 0 && upids.length === 0) { - return undefined; - } - const metrics = metricsFromTableOrSubquery( - ` - ( - select id, parent_id as parentId, name, self_count - from _callstacks_for_callsites!(( - select p.callsite_id - from instruments_sample p - join thread t using (utid) - where p.ts >= ${currentSelection.start} - and p.ts <= ${currentSelection.end} - and ( - p.utid in (${utids.join(',')}) - or t.upid in (${upids.join(',')}) - ) - )) - ) - `, - [ - { - name: 'Instruments Samples', - unit: '', - columnName: 'self_count', - }, - ], - 'include perfetto module appleos.instruments.samples', - ); - return new QueryFlamegraph(trace, metrics, { - state: Flamegraph.createDefaultState(metrics), - }); - } - - private computeSliceFlamegraph(trace: Trace, isChanged: boolean) { - const currentSelection = trace.selection.selection; - if (currentSelection.kind !== 'area') { - return undefined; - } - if (!isChanged) { - // If the selection has not changed, just return a copy of the last seen - // attrs. - return this.sliceFlamegraph; - } - const trackIds = []; - for (const trackInfo of currentSelection.tracks) { - if (trackInfo?.tags?.kind !== SLICE_TRACK_KIND) { - continue; - } - if (trackInfo.tags?.trackIds === undefined) { - continue; - } - trackIds.push(...trackInfo.tags.trackIds); - } - if (trackIds.length === 0) { - return undefined; - } - const metrics = metricsFromTableOrSubquery( - ` - ( - select * - from _viz_slice_ancestor_agg!(( - select s.id, s.dur - from slice s - left join slice t on t.parent_id = s.id - where s.ts >= ${currentSelection.start} - and s.ts <= ${currentSelection.end} - and s.track_id in (${trackIds.join(',')}) - and t.id is null - )) - ) - `, - [ - { - name: 'Duration', - unit: 'ns', - columnName: 'self_dur', - }, - { - name: 'Samples', - unit: '', - columnName: 'self_count', - }, - ], - 'include perfetto module viz.slices;', - ); - return new QueryFlamegraph(trace, metrics, { - state: Flamegraph.createDefaultState(metrics), - }); - } -} - -export class AggregationsTabs implements Disposable { - private trash = new DisposableStack(); - - constructor(trace: TraceImpl) { - const unregister = trace.tabs.registerDetailsPanel({ - render(selection) { - if (selection.kind === 'area') { - return m(AreaDetailsPanel, {trace}); - } else { - return undefined; - } - }, - }); - - this.trash.use(unregister); - } - - [Symbol.dispose]() { - this.trash.dispose(); - } -} - -function getUpidsFromPerfSampleAreaSelection(currentSelection: AreaSelection) { - const upids = []; - for (const trackInfo of currentSelection.tracks) { - if ( - trackInfo?.tags?.kind === PERF_SAMPLES_PROFILE_TRACK_KIND && - trackInfo.tags?.utid === undefined - ) { - upids.push(assertExists(trackInfo.tags?.upid)); - } - } - return upids; -} - -function getUtidsFromPerfSampleAreaSelection(currentSelection: AreaSelection) { - const utids = []; - for (const trackInfo of currentSelection.tracks) { - if ( - trackInfo?.tags?.kind === PERF_SAMPLES_PROFILE_TRACK_KIND && - trackInfo.tags?.utid !== undefined - ) { - utids.push(trackInfo.tags?.utid); - } - } - return utids; -} - -function getUpidsFromInstrumentsSampleAreaSelection( - currentSelection: AreaSelection, -) { - const upids = []; - for (const trackInfo of currentSelection.tracks) { - if ( - trackInfo?.tags?.kind === INSTRUMENTS_SAMPLES_PROFILE_TRACK_KIND && - trackInfo.tags?.utid === undefined - ) { - upids.push(assertExists(trackInfo.tags?.upid)); - } - } - return upids; -} - -function getUtidsFromInstrumentsSampleAreaSelection( - currentSelection: AreaSelection, -) { - const utids = []; - for (const trackInfo of currentSelection.tracks) { - if ( - trackInfo?.tags?.kind === INSTRUMENTS_SAMPLES_PROFILE_TRACK_KIND && - trackInfo.tags?.utid !== undefined - ) { - utids.push(trackInfo.tags?.utid); - } - } - return utids; -} diff --git a/ui/src/frontend/notes_editor_tab.ts b/ui/src/frontend/note_editor.ts similarity index 83% rename from ui/src/frontend/notes_editor_tab.ts rename to ui/src/frontend/note_editor.ts index 70f123eab5..07f1e0d808 100644 --- a/ui/src/frontend/notes_editor_tab.ts +++ b/ui/src/frontend/note_editor.ts @@ -17,9 +17,8 @@ import {assertUnreachable} from '../base/logging'; import {Icons} from '../base/semantic_icons'; import {Timestamp} from '../components/widgets/timestamp'; import {TraceImpl} from '../core/trace_impl'; -import {DetailsPanel} from '../public/details_panel'; import {Note, SpanNote} from '../public/note'; -import {Selection} from '../public/selection'; +import {NoteSelection} from '../public/selection'; import {Button} from '../widgets/button'; function getStartTimestamp(note: Note | SpanNote) { @@ -34,17 +33,16 @@ function getStartTimestamp(note: Note | SpanNote) { } } -export class NotesEditorTab implements DetailsPanel { - constructor(private trace: TraceImpl) {} - - render(selection: Selection) { - if (selection.kind !== 'note') { - return undefined; - } +interface NodeDetailsPanelAttrs { + readonly trace: TraceImpl; + readonly selection: NoteSelection; +} +export class NoteEditor implements m.ClassComponent { + view(vnode: m.CVnode) { + const {selection, trace} = vnode.attrs; const id = selection.id; - - const note = this.trace.notes.getNote(id); + const note = trace.notes.getNote(id); if (note === undefined) { return m('.', `No Note with id ${id}`); } @@ -72,7 +70,7 @@ export class NotesEditorTab implements DetailsPanel { }, onchange: (e: InputEvent) => { const newText = (e.target as HTMLInputElement).value; - this.trace.notes.changeNote(id, {text: newText}); + trace.notes.changeNote(id, {text: newText}); }, }), m( @@ -82,14 +80,14 @@ export class NotesEditorTab implements DetailsPanel { value: note.color, onchange: (e: Event) => { const newColor = (e.target as HTMLInputElement).value; - this.trace.notes.changeNote(id, {color: newColor}); + trace.notes.changeNote(id, {color: newColor}); }, }), ), m(Button, { label: 'Remove', icon: Icons.Delete, - onclick: () => this.trace.notes.removeNote(id), + onclick: () => trace.notes.removeNote(id), }), ), ); diff --git a/ui/src/frontend/ui_main.ts b/ui/src/frontend/ui_main.ts index 23fabce485..3c933bed2f 100644 --- a/ui/src/frontend/ui_main.ts +++ b/ui/src/frontend/ui_main.ts @@ -33,13 +33,11 @@ import {addQueryResultsTab} from '../components/query_table/query_result_tab'; import {Sidebar} from './sidebar'; import {Topbar} from './topbar'; import {shareTrace} from './trace_share_utils'; -import {AggregationsTabs} from './aggregation_tab'; import {OmniboxMode} from '../core/omnibox_manager'; import {DisposableStack} from '../base/disposable_stack'; import {Spinner} from '../widgets/spinner'; import {TraceImpl} from '../core/trace_impl'; import {AppImpl} from '../core/app_impl'; -import {NotesEditorTab} from './notes_editor_tab'; import {NotesListEditor} from './notes_list_editor'; import {getTimeSpanOfSelectionOrVisibleWindow} from '../public/utils'; import {DurationPrecision, TimestampFormat} from '../public/timeline'; @@ -109,12 +107,6 @@ export class UiMainPerTrace implements m.ClassComponent { document.title = `${trace.traceInfo.traceTitle || 'Trace'} - Perfetto UI`; this.maybeShowJsonWarning(); - // Register the aggregation tabs. - this.trash.use(new AggregationsTabs(trace)); - - // Register the notes manager+editor. - this.trash.use(trace.tabs.registerDetailsPanel(new NotesEditorTab(trace))); - this.trash.use( trace.tabs.registerTab({ uri: 'notes.manager', diff --git a/ui/src/frontend/viewer_page/current_selection_tab.ts b/ui/src/frontend/viewer_page/current_selection_tab.ts index 49c20035c6..a442169df2 100644 --- a/ui/src/frontend/viewer_page/current_selection_tab.ts +++ b/ui/src/frontend/viewer_page/current_selection_tab.ts @@ -20,6 +20,14 @@ import {EmptyState} from '../../widgets/empty_state'; import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout'; import {Section} from '../../widgets/section'; import {Tree, TreeNode} from '../../widgets/tree'; +import { + AreaSelection, + NoteSelection, + TrackSelection, +} from '../../public/selection'; +import {assertUnreachable} from '../../base/logging'; +import {Button, ButtonBar} from '../../widgets/button'; +import {NoteEditor} from '../note_editor'; export interface CurrentSelectionTabAttrs { readonly trace: TraceImpl; @@ -29,9 +37,10 @@ export class CurrentSelectionTab implements m.ClassComponent { private readonly fadeContext = new FadeContext(); + private currentAreaSubTabId?: string; view({attrs}: m.Vnode): m.Children { - const section = this.renderCSTabContent(attrs.trace); + const section = this.renderCurrentSelectionTabContent(attrs.trace); if (section.isLoading) { return m(FadeIn, section.content); } else { @@ -39,72 +48,109 @@ export class CurrentSelectionTab } } - private renderCSTabContent(trace: TraceImpl): { - isLoading: boolean; - content: m.Children; - } { - const currentSelection = trace.selection.selection; + private renderCurrentSelectionTabContent(trace: TraceImpl) { + const selection = trace.selection.selection; + const selectionKind = selection.kind; - switch (currentSelection.kind) { + switch (selectionKind) { case 'empty': - return { - isLoading: false, - content: m( - EmptyState, - { - className: 'pf-noselection', - title: 'Nothing selected', - }, - 'Selection details will appear here', - ), - }; + return this.renderEmptySelection('Nothing selected'); case 'track': - return { - isLoading: false, - content: this.renderTrackDetailsPanel( - trace, - currentSelection.trackUri, - ), - }; + return this.renderTrackSelection(trace, selection); case 'track_event': - const detailsPanel = trace.selection.getDetailsPanelForSelection(); - if (detailsPanel) { - return { - isLoading: detailsPanel.isLoading, - content: detailsPanel.render(), - }; - } - break; + return this.renderTrackEventSelection(trace); + case 'area': + return this.renderAreaSelection(trace, selection); + case 'note': + return this.renderNoteSelection(trace, selection); + default: + assertUnreachable(selectionKind); } + } - // Get the first "truthy" details panel - const panel = trace.tabs.detailsPanels - .map((dp) => { - return { - content: dp.render(currentSelection), - isLoading: dp.isLoading?.() ?? false, - }; - }) - .find(({content}) => content); - - if (panel) { - return panel; + private renderEmptySelection(message: string) { + return { + isLoading: false, + content: m(EmptyState, { + className: 'pf-noselection', + title: message, + }), + }; + } + + private renderTrackSelection(trace: TraceImpl, selection: TrackSelection) { + return { + isLoading: false, + content: this.renderTrackDetailsPanel(trace, selection.trackUri), + }; + } + + private renderTrackEventSelection(trace: TraceImpl) { + // The selection panel has already loaded the details panel for us... let's + // hope it's the right one! + const detailsPanel = trace.selection.getDetailsPanelForSelection(); + if (detailsPanel) { + return { + isLoading: detailsPanel.isLoading, + content: detailsPanel.render(), + }; } else { return { - isLoading: false, - content: m( - EmptyState, - { - className: 'pf-noselection', - title: 'No details available', - icon: 'warning', - }, - `Selection kind: '${currentSelection.kind}'`, - ), + isLoading: true, + content: 'Loading...', }; } } + private renderAreaSelection(trace: TraceImpl, selection: AreaSelection) { + const tabs = trace.selection.areaSelectionTabs.sort( + (a, b) => (b.priority ?? 0) - (a.priority ?? 0), + ); + + const renderedTabs = tabs + .map((tab) => [tab, tab.render(selection)] as const) + .filter(([_, content]) => content !== undefined); + + if (renderedTabs.length === 0) { + return this.renderEmptySelection('No details available for selection'); + } + + // Find the active tab or just pick the first one if that selected tab is + // not available. + const [activeTab, tabContent] = + renderedTabs.find(([tab]) => tab.id === this.currentAreaSubTabId) ?? + renderedTabs[0]; + + return { + isLoading: tabContent?.isLoading ?? false, + content: m( + DetailsShell, + { + title: 'Area Selection', + description: m( + ButtonBar, + renderedTabs.map(([tab]) => + m(Button, { + label: tab.name, + key: tab.id, + active: activeTab === tab, + onclick: () => (this.currentAreaSubTabId = tab.id), + }), + ), + ), + }, + tabContent?.content, + ), + }; + } + + private renderNoteSelection(trace: TraceImpl, selection: NoteSelection) { + return { + isLoading: false, + content: m(NoteEditor, {trace, selection}), + }; + } + private renderTrackDetailsPanel(trace: TraceImpl, trackUri: string) { const track = trace.tracks.getTrack(trackUri); if (track) { diff --git a/ui/src/frontend/viewer_page/flow_events_renderer.ts b/ui/src/frontend/viewer_page/flow_events_renderer.ts index 682d09cb07..fcbff08cca 100644 --- a/ui/src/frontend/viewer_page/flow_events_renderer.ts +++ b/ui/src/frontend/viewer_page/flow_events_renderer.ts @@ -20,10 +20,9 @@ import { VerticalBounds, } from '../../base/geom'; import {TimeScale} from '../../base/time_scale'; -import {Flow} from '../../core/flow_types'; +import {ALL_CATEGORIES, Flow, getFlowCategories} from '../../core/flow_types'; import {TraceImpl} from '../../core/trace_impl'; import {TrackNode} from '../../public/workspace'; -import {ALL_CATEGORIES, getFlowCategories} from '../flow_events_panel'; const TRACK_GROUP_CONNECTION_OFFSET = 5; const TRIANGLE_SIZE = 5; diff --git a/ui/src/plugins/dev.perfetto.CpuProfile/index.ts b/ui/src/plugins/dev.perfetto.CpuProfile/index.ts index 6a460fc960..d6a2953f23 100644 --- a/ui/src/plugins/dev.perfetto.CpuProfile/index.ts +++ b/ui/src/plugins/dev.perfetto.CpuProfile/index.ts @@ -21,6 +21,12 @@ import {getThreadUriPrefix} from '../../public/utils'; import {exists} from '../../base/utils'; import {TrackNode} from '../../public/workspace'; import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups'; +import {AreaSelection, areaSelectionsEqual} from '../../public/selection'; +import { + metricsFromTableOrSubquery, + QueryFlamegraph, +} from '../../components/query_flamegraph'; +import {Flamegraph} from '../../widgets/flamegraph'; export default class implements PerfettoPlugin { static readonly id = 'dev.perfetto.CpuProfile'; @@ -70,5 +76,90 @@ export default class implements PerfettoPlugin { const track = new TrackNode({uri, title, sortOrder: -40}); group?.addChildInOrder(track); } + + ctx.selection.registerAreaSelectionTab(createAreaSelectionTab(ctx)); } } + +function createAreaSelectionTab(trace: Trace) { + let previousSelection: undefined | AreaSelection; + let flamegraph: undefined | QueryFlamegraph; + + return { + id: 'cpu_profile_flamegraph', + name: 'CPU Profile Sample Flamegraph', + render(selection: AreaSelection) { + const changed = + previousSelection === undefined || + !areaSelectionsEqual(previousSelection, selection); + + if (changed) { + flamegraph = computeCpuProfileFlamegraph(trace, selection); + previousSelection = selection; + } + + if (flamegraph === undefined) { + return undefined; + } + + return {isLoading: false, content: flamegraph.render()}; + }, + }; +} + +function computeCpuProfileFlamegraph(trace: Trace, selection: AreaSelection) { + const utids = []; + for (const trackInfo of selection.tracks) { + if (trackInfo?.tags?.kind === CPU_PROFILE_TRACK_KIND) { + utids.push(trackInfo.tags?.utid); + } + } + if (utids.length === 0) { + return undefined; + } + const metrics = metricsFromTableOrSubquery( + ` + ( + select + id, + parent_id as parentId, + name, + mapping_name, + source_file, + cast(line_number AS text) as line_number, + self_count + from _callstacks_for_callsites!(( + select p.callsite_id + from cpu_profile_stack_sample p + where p.ts >= ${selection.start} + and p.ts <= ${selection.end} + and p.utid in (${utids.join(',')}) + )) + ) + `, + [ + { + name: 'CPU Profile Samples', + unit: '', + columnName: 'self_count', + }, + ], + 'include perfetto module callstacks.stack_profile', + [{name: 'mapping_name', displayName: 'Mapping'}], + [ + { + name: 'source_file', + displayName: 'Source File', + mergeAggregation: 'ONE_OR_NULL', + }, + { + name: 'line_number', + displayName: 'Line Number', + mergeAggregation: 'ONE_OR_NULL', + }, + ], + ); + return new QueryFlamegraph(trace, metrics, { + state: Flamegraph.createDefaultState(metrics), + }); +} diff --git a/ui/src/plugins/dev.perfetto.CpuSlices/index.ts b/ui/src/plugins/dev.perfetto.CpuSlices/index.ts index 30e1a1dc9d..c8d3421a61 100644 --- a/ui/src/plugins/dev.perfetto.CpuSlices/index.ts +++ b/ui/src/plugins/dev.perfetto.CpuSlices/index.ts @@ -22,6 +22,7 @@ import {TrackNode} from '../../public/workspace'; import {CpuSliceSelectionAggregator} from './cpu_slice_selection_aggregator'; import {CpuSliceByProcessSelectionAggregator} from './cpu_slice_by_process_selection_aggregator'; import ThreadPlugin from '../dev.perfetto.Thread'; +import {createAggregationToTabAdaptor} from '../../components/aggregation_adapter'; function uriForSchedTrack(cpu: number): string { return `/sched_cpu${cpu}`; @@ -32,11 +33,14 @@ export default class implements PerfettoPlugin { static readonly dependencies = [ThreadPlugin]; async onTraceLoad(ctx: Trace): Promise { - ctx.selection.registerAreaSelectionAggregator( - new CpuSliceSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor(ctx, new CpuSliceSelectionAggregator()), ); - ctx.selection.registerAreaSelectionAggregator( - new CpuSliceByProcessSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor( + ctx, + new CpuSliceByProcessSelectionAggregator(), + ), ); // ctx.traceInfo.cpus contains all cpus seen from all events. Filter the set diff --git a/ui/src/plugins/dev.perfetto.Frames/frame_selection_aggregator.ts b/ui/src/plugins/dev.perfetto.Frames/frame_selection_aggregator.ts index 0115a74be6..51ae808cb4 100644 --- a/ui/src/plugins/dev.perfetto.Frames/frame_selection_aggregator.ts +++ b/ui/src/plugins/dev.perfetto.Frames/frame_selection_aggregator.ts @@ -23,7 +23,6 @@ export const ACTUAL_FRAMES_SLICE_TRACK_KIND = 'ActualFramesSliceTrack'; export class FrameSelectionAggregator implements AreaSelectionAggregator { readonly id = 'frame_aggregation'; - readonly priority = 1; readonly schema = { ts: LONG, dur: LONG, diff --git a/ui/src/plugins/dev.perfetto.Frames/index.ts b/ui/src/plugins/dev.perfetto.Frames/index.ts index 6cfd392284..35daf67cf6 100644 --- a/ui/src/plugins/dev.perfetto.Frames/index.ts +++ b/ui/src/plugins/dev.perfetto.Frames/index.ts @@ -23,6 +23,7 @@ import { FrameSelectionAggregator, } from './frame_selection_aggregator'; import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups'; +import {createAggregationToTabAdaptor} from '../../components/aggregation_adapter'; // Build a standardized URI for a frames track function makeUri(upid: number, kind: 'expected_frames' | 'actual_frames') { @@ -36,8 +37,8 @@ export default class implements PerfettoPlugin { async onTraceLoad(ctx: Trace): Promise { this.addExpectedFrames(ctx); this.addActualFrames(ctx); - ctx.selection.registerAreaSelectionAggregator( - new FrameSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor(ctx, new FrameSelectionAggregator()), ); } diff --git a/ui/src/plugins/dev.perfetto.GenericAggregations/counter_selection_aggregator.ts b/ui/src/plugins/dev.perfetto.GenericAggregations/counter_selection_aggregator.ts index d098821f5b..06da0e5c5c 100644 --- a/ui/src/plugins/dev.perfetto.GenericAggregations/counter_selection_aggregator.ts +++ b/ui/src/plugins/dev.perfetto.GenericAggregations/counter_selection_aggregator.ts @@ -18,10 +18,20 @@ import {AreaSelection} from '../../public/selection'; import {COUNTER_TRACK_KIND} from '../../public/track_kinds'; import {Engine} from '../../trace_processor/engine'; import {AreaSelectionAggregator} from '../../public/selection'; +import {LONG, NUM} from '../../trace_processor/query_result'; export class CounterSelectionAggregator implements AreaSelectionAggregator { readonly id = 'counter_aggregation'; + // This just describes which counters we match, we don't actually use the + // resulting datasets, but it's a useful too to show what we actually match. + readonly trackKind = COUNTER_TRACK_KIND; + readonly schema = { + id: NUM, + ts: LONG, + value: NUM, + }; + async createAggregateView(engine: Engine, area: AreaSelection) { const trackIds: (string | number)[] = []; for (const trackInfo of area.tracks) { diff --git a/ui/src/plugins/dev.perfetto.GenericAggregations/index.ts b/ui/src/plugins/dev.perfetto.GenericAggregations/index.ts index 1d802f073b..3804e989fb 100644 --- a/ui/src/plugins/dev.perfetto.GenericAggregations/index.ts +++ b/ui/src/plugins/dev.perfetto.GenericAggregations/index.ts @@ -12,8 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. +import m from 'mithril'; +import {createAggregationToTabAdaptor} from '../../components/aggregation_adapter'; +import { + metricsFromTableOrSubquery, + QueryFlamegraph, +} from '../../components/query_flamegraph'; +import {PivotTableManager} from '../../components/pivot_table/pivot_table_manager'; +import {PivotTable} from '../../components/pivot_table/pivot_table'; import {PerfettoPlugin} from '../../public/plugin'; +import { + AreaSelection, + areaSelectionsEqual, + AreaSelectionTab, +} from '../../public/selection'; import {Trace} from '../../public/trace'; +import {SLICE_TRACK_KIND} from '../../public/track_kinds'; +import {Flamegraph} from '../../widgets/flamegraph'; import {CounterSelectionAggregator} from './counter_selection_aggregator'; import {SliceSelectionAggregator} from './slice_selection_aggregator'; @@ -25,12 +40,125 @@ export default class implements PerfettoPlugin { static readonly id = 'dev.perfetto.GenericAggregations'; async onTraceLoad(ctx: Trace): Promise { - ctx.selection.registerAreaSelectionAggregator( - new CounterSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor(ctx, new CounterSelectionAggregator()), ); - ctx.selection.registerAreaSelectionAggregator( - new SliceSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor(ctx, new SliceSelectionAggregator()), ); + + ctx.selection.registerAreaSelectionTab(createPivotTableTab(ctx)); + ctx.selection.registerAreaSelectionTab(createSliceFlameGraphPanel(ctx)); + } +} + +function createPivotTableTab(trace: Trace): AreaSelectionTab { + // Add the pivot table and its manager + const pivotTableMgr = new PivotTableManager(trace.engine); + let previousSelection: undefined | AreaSelection; + return { + id: 'pivot_table', + name: 'Pivot Table', + render(selection) { + if ( + previousSelection === undefined || + !areaSelectionsEqual(previousSelection, selection) + ) { + pivotTableMgr.setSelectionArea(selection); + previousSelection = selection; + } + + const pivotTableState = pivotTableMgr.state; + const tree = pivotTableState.queryResult?.tree; + + if ( + pivotTableState.selectionArea != undefined && + (tree === undefined || tree.children.size > 0 || tree?.rows.length > 0) + ) { + return { + isLoading: false, + content: m(PivotTable, { + trace, + selectionArea: selection, + pivotMgr: pivotTableMgr, + }), + }; + } else { + return undefined; + } + }, + }; +} + +function createSliceFlameGraphPanel(trace: Trace) { + let previousSelection: AreaSelection | undefined; + let sliceFlamegraph: QueryFlamegraph | undefined; + return { + id: 'slice_flamegraph_selection', + name: 'Slice Flamegraph', + render(selection: AreaSelection) { + const selectionChanged = + previousSelection === undefined || + !areaSelectionsEqual(previousSelection, selection); + previousSelection = selection; + if (selectionChanged) { + sliceFlamegraph = computeSliceFlamegraph(trace, selection); + } + + if (sliceFlamegraph === undefined) { + return undefined; + } + + return {isLoading: false, content: sliceFlamegraph.render()}; + }, + }; +} + +function computeSliceFlamegraph(trace: Trace, currentSelection: AreaSelection) { + const trackIds = []; + for (const trackInfo of currentSelection.tracks) { + if (trackInfo?.tags?.kind !== SLICE_TRACK_KIND) { + continue; + } + if (trackInfo.tags?.trackIds === undefined) { + continue; + } + trackIds.push(...trackInfo.tags.trackIds); + } + if (trackIds.length === 0) { + return undefined; } + const metrics = metricsFromTableOrSubquery( + ` + ( + select * + from _viz_slice_ancestor_agg!(( + select s.id, s.dur + from slice s + left join slice t on t.parent_id = s.id + where s.ts >= ${currentSelection.start} + and s.ts <= ${currentSelection.end} + and s.track_id in (${trackIds.join(',')}) + and t.id is null + )) + ) + `, + [ + { + name: 'Duration', + unit: 'ns', + columnName: 'self_dur', + }, + { + name: 'Samples', + unit: '', + columnName: 'self_count', + }, + ], + 'include perfetto module viz.slices;', + ); + return new QueryFlamegraph(trace, metrics, { + state: Flamegraph.createDefaultState(metrics), + }); } diff --git a/ui/src/plugins/dev.perfetto.InstrumentsSamplesProfile/index.ts b/ui/src/plugins/dev.perfetto.InstrumentsSamplesProfile/index.ts index b60148b125..96ccd33b42 100644 --- a/ui/src/plugins/dev.perfetto.InstrumentsSamplesProfile/index.ts +++ b/ui/src/plugins/dev.perfetto.InstrumentsSamplesProfile/index.ts @@ -25,6 +25,12 @@ import { import {getThreadUriPrefix} from '../../public/utils'; import {TrackNode} from '../../public/workspace'; import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups'; +import {AreaSelection, areaSelectionsEqual} from '../../public/selection'; +import { + metricsFromTableOrSubquery, + QueryFlamegraph, +} from '../../components/query_flamegraph'; +import {Flamegraph} from '../../widgets/flamegraph'; export interface Data extends TrackData { tsStarts: BigInt64Array; @@ -110,6 +116,8 @@ export default class implements PerfettoPlugin { ctx.onTraceReady.addListener(async () => { await selectInstrumentsSample(ctx); }); + + ctx.selection.registerAreaSelectionTab(createAreaSelectionTab(ctx)); } } @@ -133,3 +141,99 @@ async function selectInstrumentsSample(ctx: Trace) { trackUris: [makeUriForProc(upid)], }); } + +function createAreaSelectionTab(trace: Trace) { + let previousSelection: undefined | AreaSelection; + let flamegraph: undefined | QueryFlamegraph; + + return { + id: 'instruments_sample_flamegraph', + name: 'Instruments Sample Flamegraph', + render(selection: AreaSelection) { + const changed = + previousSelection === undefined || + !areaSelectionsEqual(previousSelection, selection); + + if (changed) { + flamegraph = computeInstrumentsSampleFlamegraph(trace, selection); + previousSelection = selection; + } + + if (flamegraph === undefined) { + return undefined; + } + + return {isLoading: false, content: flamegraph.render()}; + }, + }; +} + +function computeInstrumentsSampleFlamegraph( + trace: Trace, + currentSelection: AreaSelection, +) { + const upids = getUpidsFromInstrumentsSampleAreaSelection(currentSelection); + const utids = getUtidsFromInstrumentsSampleAreaSelection(currentSelection); + if (utids.length === 0 && upids.length === 0) { + return undefined; + } + const metrics = metricsFromTableOrSubquery( + ` + ( + select id, parent_id as parentId, name, self_count + from _callstacks_for_callsites!(( + select p.callsite_id + from instruments_sample p + join thread t using (utid) + where p.ts >= ${currentSelection.start} + and p.ts <= ${currentSelection.end} + and ( + p.utid in (${utids.join(',')}) + or t.upid in (${upids.join(',')}) + ) + )) + ) + `, + [ + { + name: 'Instruments Samples', + unit: '', + columnName: 'self_count', + }, + ], + 'include perfetto module appleos.instruments.samples', + ); + return new QueryFlamegraph(trace, metrics, { + state: Flamegraph.createDefaultState(metrics), + }); +} + +function getUpidsFromInstrumentsSampleAreaSelection( + currentSelection: AreaSelection, +) { + const upids = []; + for (const trackInfo of currentSelection.tracks) { + if ( + trackInfo?.tags?.kind === INSTRUMENTS_SAMPLES_PROFILE_TRACK_KIND && + trackInfo.tags?.utid === undefined + ) { + upids.push(assertExists(trackInfo.tags?.upid)); + } + } + return upids; +} + +function getUtidsFromInstrumentsSampleAreaSelection( + currentSelection: AreaSelection, +) { + const utids = []; + for (const trackInfo of currentSelection.tracks) { + if ( + trackInfo?.tags?.kind === INSTRUMENTS_SAMPLES_PROFILE_TRACK_KIND && + trackInfo.tags?.utid !== undefined + ) { + utids.push(trackInfo.tags?.utid); + } + } + return utids; +} diff --git a/ui/src/plugins/dev.perfetto.LinuxPerf/index.ts b/ui/src/plugins/dev.perfetto.LinuxPerf/index.ts index ce2bb85c4c..23a24edf83 100644 --- a/ui/src/plugins/dev.perfetto.LinuxPerf/index.ts +++ b/ui/src/plugins/dev.perfetto.LinuxPerf/index.ts @@ -13,7 +13,12 @@ // limitations under the License. import {assertExists} from '../../base/logging'; +import { + metricsFromTableOrSubquery, + QueryFlamegraph, +} from '../../components/query_flamegraph'; import {PerfettoPlugin} from '../../public/plugin'; +import {AreaSelection, areaSelectionsEqual} from '../../public/selection'; import {Trace} from '../../public/trace'; import { COUNTER_TRACK_KIND, @@ -22,6 +27,7 @@ import { import {getThreadUriPrefix} from '../../public/utils'; import {TrackNode} from '../../public/workspace'; import {NUM, NUM_NULL, STR, STR_NULL} from '../../trace_processor/query_result'; +import {Flamegraph} from '../../widgets/flamegraph'; import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups'; import StandardGroupsPlugin from '../dev.perfetto.StandardGroups'; import TraceProcessorTrackPlugin from '../dev.perfetto.TraceProcessorTrack'; @@ -190,6 +196,8 @@ export default class implements PerfettoPlugin { .getOrCreateStandardGroup(trace.workspace, 'HARDWARE'); hardwareGroup.addChildInOrder(perfCountersGroup); } + + trace.selection.registerAreaSelectionTab(createAreaSelectionTab(trace)); } } @@ -213,3 +221,95 @@ async function selectPerfSample(trace: Trace) { trackUris: [makeUriForProc(upid)], }); } + +function createAreaSelectionTab(trace: Trace) { + let previousSelection: undefined | AreaSelection; + let flamegraph: undefined | QueryFlamegraph; + + return { + id: 'perf_sample_flamegraph', + name: 'Perf Sample Flamegraph', + render(selection: AreaSelection) { + const changed = + previousSelection === undefined || + !areaSelectionsEqual(previousSelection, selection); + + if (changed) { + flamegraph = computePerfSampleFlamegraph(trace, selection); + previousSelection = selection; + } + + if (flamegraph === undefined) { + return undefined; + } + + return {isLoading: false, content: flamegraph.render()}; + }, + }; +} + +function getUpidsFromPerfSampleAreaSelection(currentSelection: AreaSelection) { + const upids = []; + for (const trackInfo of currentSelection.tracks) { + if ( + trackInfo?.tags?.kind === PERF_SAMPLES_PROFILE_TRACK_KIND && + trackInfo.tags?.utid === undefined + ) { + upids.push(assertExists(trackInfo.tags?.upid)); + } + } + return upids; +} + +function getUtidsFromPerfSampleAreaSelection(currentSelection: AreaSelection) { + const utids = []; + for (const trackInfo of currentSelection.tracks) { + if ( + trackInfo?.tags?.kind === PERF_SAMPLES_PROFILE_TRACK_KIND && + trackInfo.tags?.utid !== undefined + ) { + utids.push(trackInfo.tags?.utid); + } + } + return utids; +} + +function computePerfSampleFlamegraph( + trace: Trace, + currentSelection: AreaSelection, +) { + const upids = getUpidsFromPerfSampleAreaSelection(currentSelection); + const utids = getUtidsFromPerfSampleAreaSelection(currentSelection); + if (utids.length === 0 && upids.length === 0) { + return undefined; + } + const metrics = metricsFromTableOrSubquery( + ` + ( + select id, parent_id as parentId, name, self_count + from _callstacks_for_callsites!(( + select p.callsite_id + from perf_sample p + join thread t using (utid) + where p.ts >= ${currentSelection.start} + and p.ts <= ${currentSelection.end} + and ( + p.utid in (${utids.join(',')}) + or t.upid in (${upids.join(',')}) + ) + )) + ) + `, + [ + { + name: 'Perf Samples', + unit: '', + columnName: 'self_count', + }, + ], + 'include perfetto module linux.perf.samples', + ); + return new QueryFlamegraph(trace, metrics, { + state: Flamegraph.createDefaultState(metrics), + }); +} diff --git a/ui/src/plugins/dev.perfetto.ThreadState/index.ts b/ui/src/plugins/dev.perfetto.ThreadState/index.ts index ad9465f475..af999a1336 100644 --- a/ui/src/plugins/dev.perfetto.ThreadState/index.ts +++ b/ui/src/plugins/dev.perfetto.ThreadState/index.ts @@ -22,6 +22,7 @@ import {removeFalsyValues} from '../../base/array_utils'; import {TrackNode} from '../../public/workspace'; import {ThreadStateSelectionAggregator} from './thread_state_selection_aggregator'; import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups'; +import {createAggregationToTabAdaptor} from '../../components/aggregation_adapter'; function uriForThreadStateTrack(upid: number | null, utid: number): string { return `${getThreadUriPrefix(upid, utid)}_state`; @@ -34,8 +35,8 @@ export default class implements PerfettoPlugin { async onTraceLoad(ctx: Trace): Promise { const {engine} = ctx; - ctx.selection.registerAreaSelectionAggregator( - new ThreadStateSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor(ctx, new ThreadStateSelectionAggregator()), ); const result = await engine.query(` diff --git a/ui/src/plugins/org.kernel.Wattson/index.ts b/ui/src/plugins/org.kernel.Wattson/index.ts index 1b61403f1c..a4c0704ea1 100644 --- a/ui/src/plugins/org.kernel.Wattson/index.ts +++ b/ui/src/plugins/org.kernel.Wattson/index.ts @@ -26,6 +26,7 @@ import {WattsonProcessSelectionAggregator} from './process_aggregator'; import {WattsonThreadSelectionAggregator} from './thread_aggregator'; import {Engine} from '../../trace_processor/engine'; import {NUM} from '../../trace_processor/query_result'; +import {createAggregationToTabAdaptor} from '../../components/aggregation_adapter'; export default class implements PerfettoPlugin { static readonly id = `org.kernel.Wattson`; @@ -83,17 +84,29 @@ export default class implements PerfettoPlugin { // Register selection aggregators. // NOTE: the registration order matters because the laste two aggregators // depend on views created by the first two. - ctx.selection.registerAreaSelectionAggregator( - new WattsonEstimateSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor( + ctx, + new WattsonEstimateSelectionAggregator(), + ), ); - ctx.selection.registerAreaSelectionAggregator( - new WattsonThreadSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor( + ctx, + new WattsonThreadSelectionAggregator(), + ), ); - ctx.selection.registerAreaSelectionAggregator( - new WattsonProcessSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor( + ctx, + new WattsonProcessSelectionAggregator(), + ), ); - ctx.selection.registerAreaSelectionAggregator( - new WattsonPackageSelectionAggregator(), + ctx.selection.registerAreaSelectionTab( + createAggregationToTabAdaptor( + ctx, + new WattsonPackageSelectionAggregator(), + ), ); } } diff --git a/ui/src/public/selection.ts b/ui/src/public/selection.ts index 1c11bfb3e8..5f6d755130 100644 --- a/ui/src/public/selection.ts +++ b/ui/src/public/selection.ts @@ -12,15 +12,66 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {time, duration, TimeSpan} from '../base/time'; +import m from 'mithril'; +import {duration, time, TimeSpan} from '../base/time'; import {Dataset, DatasetSchema} from '../trace_processor/dataset'; import {Engine} from '../trace_processor/engine'; import {ColumnDef, Sorting, ThreadStateExtra} from './aggregation'; import {TrackDescriptor} from './track'; +import {arrayEquals} from '../base/array_utils'; + +export interface ContentWithLoadingFlag { + readonly isLoading: boolean; + readonly content: m.Children; +} + +export interface AreaSelectionTab { + // Unique id for this tab. + readonly id: string; + + // A name for this tab. + readonly name: string; + + // Defines the sort order of this tab - higher values appear first. + readonly priority?: number; + + /** + * Called every Mithril render cycle to render the content of the tab. The + * returned content will be displayed inside the current selection tab. + * + * If undefined is returned then the tab handle will be hidden, which gives + * the tab the option to dynamically remove itself from the list of tabs if it + * has nothing relevant to show. + * + * The |isLoading| flag is used to avoid flickering. If set to true, we keep + * hold of the the previous vnodes, rendering them instead, for up to 50ms + * before switching to the new content. This avoids very fast load times + * from causing flickering loading screens, which can be somewhat jarring. + */ + render(selection: AreaSelection): ContentWithLoadingFlag | undefined; +} + +/** + * Compare two area selections for equality. Returns true if the selections are + * equivalent, false otherwise. + */ +export function areaSelectionsEqual(a: AreaSelection, b: AreaSelection) { + if (a.start !== b.start) return false; + if (a.end !== b.end) return false; + if (!arrayEquals(a.trackUris, b.trackUris)) { + return false; + } + return true; +} export interface SelectionManager { readonly selection: Selection; + /** + * Provides a list of registered area selection tabs. + */ + readonly areaSelectionTabs: ReadonlyArray; + findTimeRangeOfSelection(): TimeSpan | undefined; clear(): void; @@ -63,7 +114,11 @@ export interface SelectionManager { selectArea(args: Area, opts?: SelectionOpts): void; scrollToCurrentSelection(): void; - registerAreaSelectionAggregator(aggr: AreaSelectionAggregator): void; + + /** + * Register a new tab under the area selection details panel. + */ + registerAreaSelectionTab(tab: AreaSelectionTab): void; } /** diff --git a/ui/src/public/tab.ts b/ui/src/public/tab.ts index 12258c228d..97d706510c 100644 --- a/ui/src/public/tab.ts +++ b/ui/src/public/tab.ts @@ -13,11 +13,9 @@ // limitations under the License. import m from 'mithril'; -import {DetailsPanel} from './details_panel'; export interface TabManager { registerTab(tab: TabDescriptor): void; - registerDetailsPanel(detailsPanel: DetailsPanel): Disposable; showTab(uri: string): void; hideTab(uri: string): void; addDefaultTab(uri: string): void;