ui: add a table view to flamegraphs Add a Flame/Table toggle to the flamegraph filter bar. Table mode shows the same tree as the canvas — one expandable row per node with self and cumulative values and the share of the root total — so metric selection, filters and the top-down/bottom-up/pivot views apply to both modes. The table is a DataGrid in id-based tree mode. QueryFlamegraph backs it with a source over the nodes the canvas already holds, so nothing extra is loaded: expanding walks only the visible part of the tree and the grid virtualises the DOM. Callers without a resident node array can pass a SQL data source instead, which fetches one level per expansion. The Flamegraph widget delegates the rendering to a renderTableView hook, keeping the widgets layer free of DataGrid dependencies. Change-Id: Idb509db6acd2fb22cefd51d7775628251aeb56b8
diff --git a/ui/src/components/flamegraph_table.ts b/ui/src/components/flamegraph_table.ts new file mode 100644 index 0000000..9a9733a --- /dev/null +++ b/ui/src/components/flamegraph_table.ts
@@ -0,0 +1,177 @@ +// Copyright (C) 2026 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 {displaySize} from '../widgets/flamegraph'; +import type {FlamegraphNode, FlamegraphQueryData} from '../widgets/flamegraph'; +import {DataGrid} from './widgets/datagrid/datagrid'; +import {InMemoryDataSource} from './widgets/datagrid/in_memory_data_source'; +import type { + DataSource, + DataSourceModel, + DataSourceRows, +} from './widgets/datagrid/data_source'; +import type {IdBasedTree} from './widgets/datagrid/model'; +import type {Row, SqlValue} from '../trace_processor/query_result'; + +const DEFAULT_TREE: IdBasedTree = { + idField: 'id', + parentIdField: 'parentId', + treeColumn: 'name', + expandedIds: new Set<bigint>(), +}; + +export interface FlamegraphTableAttrs { + // Tree-capable DataGrid source: FlamegraphTreeDataSource over resident + // nodes, or a SQL source that fetches one level per expansion. + readonly source: DataSource; + readonly unit: string; + readonly tree: IdBasedTree | undefined; + readonly onTreeChanged: (tree: IdBasedTree | undefined) => void; +} + +// Tree table over a flamegraph: one expandable row per node with self and +// cumulative values and share of the root total. DataGrid id-based tree mode +// keeps expansion O(visible rows) and the DOM virtualised. +export class FlamegraphTable implements m.ClassComponent<FlamegraphTableAttrs> { + view({attrs}: m.CVnode<FlamegraphTableAttrs>): m.Children { + const {unit} = attrs; + const fmtValue = (value: SqlValue) => + typeof value === 'number' ? displaySize(value, unit) : ''; + return m(DataGrid, { + className: 'pf-flamegraph-table__grid', + fillHeight: true, + // The flamegraph's own filter bar is the filtering surface; row-level + // grid filters have no meaning on a tree walk. + disableFilterControls: true, + schema: { + name: {title: 'Name', columnType: 'text'}, + total: { + title: 'Total', + columnType: 'quantitative', + cellRenderer: fmtValue, + }, + self: { + title: 'Self', + columnType: 'quantitative', + cellRenderer: fmtValue, + }, + percent: { + title: '% of total', + columnType: 'quantitative', + cellRenderer: (value: SqlValue) => + typeof value === 'number' ? `${value.toFixed(1)}%` : '', + }, + }, + data: attrs.source, + initialColumns: [ + {id: 'name', field: 'name'}, + {id: 'total', field: 'total', sort: 'DESC'}, + {id: 'self', field: 'self'}, + {id: 'percent', field: 'percent'}, + ], + tree: attrs.tree ?? DEFAULT_TREE, + onTreeChanged: attrs.onTreeChanged, + }); + } +} + +// Tree-mode source over the flamegraph nodes the canvas already holds. +// Children are indexed once; useRows() walks only the expanded rows, sorting +// siblings by the requested column (total descending by default). +export class FlamegraphTreeDataSource extends InMemoryDataSource { + private readonly children = new Map<number, FlamegraphNode[]>(); + private readonly roots: FlamegraphNode[] = []; + private readonly total: number; + + constructor(data: FlamegraphQueryData) { + super([]); + this.total = data.allRootsCumulativeValue; + const ids = new Set(data.nodes.map((n) => n.id)); + for (const n of data.nodes) { + if (ids.has(n.parentId)) { + let siblings = this.children.get(n.parentId); + if (siblings === undefined) { + siblings = []; + this.children.set(n.parentId, siblings); + } + siblings.push(n); + } else { + this.roots.push(n); + } + } + } + + useRows(model: DataSourceModel): DataSourceRows { + if (model.mode !== 'tree') { + return super.useRows(model); + } + const rows = this.visibleRows(model); + return {rows, totalRows: rows.length, isPending: false}; + } + + exportData(model: DataSourceModel): Promise<readonly Row[]> { + if (model.mode !== 'tree') { + return super.exportData(model); + } + return Promise.resolve(this.visibleRows(model)); + } + + private visibleRows(model: DataSourceModel & {mode: 'tree'}): readonly Row[] { + const {expandedIds, collapsedIds} = model.tree; + const isExpanded = (id: number) => + collapsedIds !== undefined + ? !collapsedIds.has(BigInt(id)) + : (expandedIds?.has(BigInt(id)) ?? false); + const cmp = this.comparator(model.sort); + const rows: Row[] = []; + const visit = (nodes: FlamegraphNode[], depth: number) => { + for (const n of [...nodes].sort(cmp)) { + const children = this.children.get(n.id); + rows.push({ + id: n.id, + parentId: n.parentId, + name: n.name, + total: n.cumulativeValue, + self: n.selfValue, + percent: + this.total === 0 ? 0 : (n.cumulativeValue / this.total) * 100, + __id: n.id, + __depth: depth, + __has_children: children === undefined ? 0 : 1, + }); + if (children !== undefined && isExpanded(n.id)) { + visit(children, depth + 1); + } + } + }; + visit(this.roots, 0); + return rows; + } + + private comparator( + sort: {alias: string; direction: 'ASC' | 'DESC'} | undefined, + ): (a: FlamegraphNode, b: FlamegraphNode) => number { + const dir = sort?.direction === 'ASC' ? 1 : -1; + switch (sort?.alias) { + case 'name': + return (a, b) => dir * a.name.localeCompare(b.name); + case 'self': + return (a, b) => dir * (a.selfValue - b.selfValue); + default: // 'total', 'percent' and the initial (unsorted) state. + return (a, b) => dir * (a.cumulativeValue - b.cumulativeValue); + } + } +}
diff --git a/ui/src/components/query_flamegraph.ts b/ui/src/components/query_flamegraph.ts index bf895fc..8c571ed 100644 --- a/ui/src/components/query_flamegraph.ts +++ b/ui/src/components/query_flamegraph.ts
@@ -40,6 +40,8 @@ type FlamegraphOptionalMarker, } from '../widgets/flamegraph'; import type {Trace} from '../public/trace'; +import {FlamegraphTable, FlamegraphTreeDataSource} from './flamegraph_table'; +import type {IdBasedTree} from './widgets/datagrid/model'; import {sqliteString} from '../base/string_utils'; import {userFilterToRegex} from '../widgets/flamegraph_regex'; import {SharedAsyncDisposable} from '../base/shared_disposable'; @@ -195,14 +197,21 @@ // data for the widget by querying an `Engine`. export class QueryFlamegraph implements AsyncDisposable { private data?: FlamegraphQueryData; + private tableTree?: IdBasedTree; + private tableSource?: FlamegraphTreeDataSource; + private tableSourceData?: FlamegraphQueryData; private readonly queryLimiter = new AsyncLimiter(); private readonly dependencies: ReadonlyArray< SharedAsyncDisposable<AsyncDisposable> >; private lastAttrs?: QueryFlamegraphAttrs; + // displayMode is excluded: toggling Flame/Table must not refetch. private monitor = new Monitor([ () => this.lastAttrs?.metrics, - () => this.lastAttrs?.state, + () => this.lastAttrs?.state?.filters, + () => this.lastAttrs?.state?.view, + () => this.lastAttrs?.state?.selectedMetricId, + () => this.lastAttrs?.state?.addedMetricIds, ]); constructor( @@ -227,6 +236,9 @@ this.fetchData(metrics, state); } } + const unit = + metrics?.find((x) => state?.selectedMetricId === (x.id ?? x.name)) + ?.unit ?? ''; return m(Flamegraph, { metrics: metrics ?? [], data: this.data, @@ -234,10 +246,26 @@ view: {kind: 'TOP_DOWN'}, selectedMetricId: '', addedMetricIds: [], + displayMode: 'flamegraph', filters: [], }, addableMetrics, onAddMetric, + renderTableView: (data) => { + if (this.tableSource === undefined || this.tableSourceData !== data) { + this.tableSourceData = data; + this.tableSource = new FlamegraphTreeDataSource(data); + this.tableTree = undefined; + } + return m(FlamegraphTable, { + source: this.tableSource, + unit, + tree: this.tableTree, + onTreeChanged: (tree) => { + this.tableTree = tree; + }, + }); + }, onStateChange, }); }
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts index 4506dbb..92dd98a 100644 --- a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts +++ b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
@@ -385,6 +385,7 @@ ? METRIC_DOMINATED_OBJECT_SIZE : METRIC_OBJECT_SIZE, addedMetricIds: [], + displayMode: 'flamegraph', filters: [], view: { kind: 'PIVOT',
diff --git a/ui/src/widgets/flamegraph.scss b/ui/src/widgets/flamegraph.scss index 99afd0d..9bce5a6 100644 --- a/ui/src/widgets/flamegraph.scss +++ b/ui/src/widgets/flamegraph.scss
@@ -96,6 +96,11 @@ .pf-virtual-canvas { height: 100%; } + + .pf-flamegraph-table { + height: 100%; + overflow: hidden; + } } .pf-flamegraph-tooltip-popup {
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts index 72cb93a..af36739 100644 --- a/ui/src/widgets/flamegraph.ts +++ b/ui/src/widgets/flamegraph.ts
@@ -221,6 +221,7 @@ .object({ selectedMetricId: z.string().readonly(), addedMetricIds: z.array(z.string()).default([]), + displayMode: z.enum(['flamegraph', 'table']).default('flamegraph'), filters: z.array(FLAMEGRAPH_FILTER_SCHEMA), view: FLAMEGRAPH_VIEW_SCHEMA, }) @@ -252,6 +253,10 @@ readonly data: FlamegraphQueryData | undefined; readonly addableMetrics?: ReadonlyArray<FlamegraphAddableMetric>; + // When set, a Flame/Table toggle appears and 'table' mode renders this + // instead of the canvas. Both modes consume the same post-filter tree. + readonly renderTableView?: (data: FlamegraphQueryData) => m.Children; + readonly onStateChange: (filters: FlamegraphState) => void; readonly onAddMetric?: (metric: FlamegraphAddableMetric) => void; } @@ -556,6 +561,13 @@ ), ); } + if (attrs.state.displayMode === 'table' && attrs.renderTableView) { + return m( + '.pf-flamegraph', + this.renderFilterBar(attrs), + m('.pf-flamegraph-table', attrs.renderTableView(attrs.data)), + ); + } const {minDepth, maxDepth} = attrs.data; const canvasHeight = Math.max(maxDepth - minDepth + PADDING_NODE_COUNT, PADDING_NODE_COUNT) * @@ -721,6 +733,7 @@ return { selectedMetricId: metricId(metrics[0]), addedMetricIds: [], + displayMode: 'flamegraph', filters: [], view: {kind: 'TOP_DOWN'}, }; @@ -754,6 +767,7 @@ filters: state.filters, view: state.view, addedMetricIds: state.addedMetricIds, + displayMode: state.displayMode, selectedMetricId: metricId(metrics[0]), }; } @@ -1076,6 +1090,25 @@ m(RadioGroup.Button, {value: 'bottom-up'}, 'Bottom Up'), ], ), + attrs.renderTableView !== undefined && [ + m('.pf-flamegraph-filter-bar-separator'), + m( + RadioGroup, + { + selectedValue: this.attrs.state.displayMode, + onValueChange: (value) => { + this.attrs.onStateChange({ + ...this.attrs.state, + displayMode: value as 'flamegraph' | 'table', + }); + }, + }, + [ + m(RadioGroup.Button, {value: 'flamegraph'}, 'Flame'), + m(RadioGroup.Button, {value: 'table'}, 'Table'), + ], + ), + ], attrs.data !== undefined && attrs.data.nodes.length > 0 && [ m('.pf-flamegraph-filter-bar-separator'), @@ -1864,7 +1897,10 @@ } } -function displaySize(totalSize: number, unit: string): string { +// Formats a value in the metric's unit ('B' and 'ns' are human-scaled with +// 1024/1000 steps, 'count' and '' verbatim, anything else K/M/G-prefixed). +// Exported so other views showing flamegraph metrics can format identically. +export function displaySize(totalSize: number, unit: string): string { if (unit === '' || unit === 'count') return totalSize.toLocaleString(); if (totalSize === 0) return `0 ${unit}`; let step: number;