xref: /aosp_15_r20/external/perfetto/ui/src/frontend/pivot_table.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2022 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {SortDirection} from '../base/comparison_utils';
17import {sqliteString} from '../base/string_utils';
18import {DropDirection} from '../core/pivot_table_manager';
19import {
20  PivotTableResult,
21  Aggregation,
22  AggregationFunction,
23  columnKey,
24  PivotTree,
25  TableColumn,
26  COUNT_AGGREGATION,
27} from '../core/pivot_table_types';
28import {AreaSelection} from '../public/selection';
29import {raf} from '../core/raf_scheduler';
30import {ColumnType} from '../trace_processor/query_result';
31import {
32  aggregationIndex,
33  areaFilters,
34  sliceAggregationColumns,
35  tables,
36} from '../core/pivot_table_query_generator';
37import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells';
38import {AttributeModalHolder} from './tables/attribute_modal_holder';
39import {DurationWidget} from '../components/widgets/duration';
40import {getSqlTableDescription} from '../components/widgets/sql/table/sql_table_registry';
41import {assertExists, assertFalse} from '../base/logging';
42import {Filter, SqlColumn} from '../components/widgets/sql/table/column';
43import {argSqlColumn} from '../components/widgets/sql/table/well_known_columns';
44import {TraceImpl} from '../core/trace_impl';
45import {PivotTableManager} from '../core/pivot_table_manager';
46import {extensions} from '../components/extensions';
47import {MenuItem, PopupMenu2} from '../widgets/menu';
48import {Button} from '../widgets/button';
49import {popupMenuIcon} from '../widgets/table';
50
51interface PathItem {
52  tree: PivotTree;
53  nextKey: ColumnType;
54}
55
56interface PivotTableAttrs {
57  trace: TraceImpl;
58  selectionArea: AreaSelection;
59}
60
61interface DrillFilter {
62  column: TableColumn;
63  value: ColumnType;
64}
65
66function drillFilterColumnName(column: TableColumn): SqlColumn {
67  switch (column.kind) {
68    case 'argument':
69      return argSqlColumn('arg_set_id', column.argument);
70    case 'regular':
71      return `${column.column}`;
72  }
73}
74
75// Convert DrillFilter to SQL condition to be used in WHERE clause.
76function renderDrillFilter(filter: DrillFilter): Filter {
77  const column = drillFilterColumnName(filter.column);
78  const value = filter.value;
79  if (value === null) {
80    return {op: (cols) => `${cols[0]} IS NULL`, columns: [column]};
81  } else if (typeof value === 'number' || typeof value === 'bigint') {
82    return {op: (cols) => `${cols[0]} = ${filter.value}`, columns: [column]};
83  } else if (value instanceof Uint8Array) {
84    throw new Error(`BLOB as DrillFilter not implemented`);
85  }
86  return {
87    op: (cols) => `${cols[0]} = ${sqliteString(value)}`,
88    columns: [column],
89  };
90}
91
92function readableColumnName(column: TableColumn) {
93  switch (column.kind) {
94    case 'argument':
95      return `Argument ${column.argument}`;
96    case 'regular':
97      return `${column.column}`;
98  }
99}
100
101export function markFirst(index: number) {
102  if (index === 0) {
103    return '.first';
104  }
105  return '';
106}
107
108export class PivotTable implements m.ClassComponent<PivotTableAttrs> {
109  private pivotMgr: PivotTableManager;
110
111  constructor({attrs}: m.CVnode<PivotTableAttrs>) {
112    this.pivotMgr = attrs.trace.pivotTable;
113    this.attributeModalHolder = new AttributeModalHolder((arg) =>
114      this.pivotMgr.setPivotSelected({
115        column: {kind: 'argument', argument: arg},
116        selected: true,
117      }),
118    );
119  }
120
121  get pivotState() {
122    return this.pivotMgr.state;
123  }
124
125  renderDrillDownCell(attrs: PivotTableAttrs, filters: DrillFilter[]) {
126    return m(
127      'td',
128      m(
129        'button',
130        {
131          title: 'All corresponding slices',
132          onclick: () => {
133            const queryFilters = filters.map(renderDrillFilter);
134            if (this.pivotState.constrainToArea) {
135              queryFilters.push(...areaFilters(attrs.selectionArea));
136            }
137            extensions.addSqlTableTab(attrs.trace, {
138              table: assertExists(getSqlTableDescription('slice')),
139              // TODO(altimin): this should properly reference the required columns, but it works for now (until the pivot table is going to be rewritten to be more flexible).
140              filters: queryFilters,
141            });
142          },
143        },
144        m('i.material-icons', 'arrow_right'),
145      ),
146    );
147  }
148
149  renderSectionRow(
150    attrs: PivotTableAttrs,
151    path: PathItem[],
152    tree: PivotTree,
153    result: PivotTableResult,
154  ): m.Vnode {
155    const renderedCells = [];
156    for (let j = 0; j + 1 < path.length; j++) {
157      renderedCells.push(m('td', m('span.indent', ' '), `${path[j].nextKey}`));
158    }
159
160    const treeDepth = result.metadata.pivotColumns.length;
161    const colspan = treeDepth - path.length + 1;
162    const button = m(
163      'button',
164      {
165        onclick: () => {
166          tree.isCollapsed = !tree.isCollapsed;
167          raf.scheduleFullRedraw();
168        },
169      },
170      m('i.material-icons', tree.isCollapsed ? 'expand_more' : 'expand_less'),
171    );
172
173    renderedCells.push(
174      m('td', {colspan}, button, `${path[path.length - 1].nextKey}`),
175    );
176
177    for (let i = 0; i < result.metadata.aggregationColumns.length; i++) {
178      const renderedValue = this.renderCell(
179        result.metadata.aggregationColumns[i].column,
180        tree.aggregates[i],
181      );
182      renderedCells.push(m('td' + markFirst(i), renderedValue));
183    }
184
185    const drillFilters: DrillFilter[] = [];
186    for (let i = 0; i < path.length; i++) {
187      drillFilters.push({
188        value: `${path[i].nextKey}`,
189        column: result.metadata.pivotColumns[i],
190      });
191    }
192
193    renderedCells.push(this.renderDrillDownCell(attrs, drillFilters));
194    return m('tr', renderedCells);
195  }
196
197  renderCell(column: TableColumn, value: ColumnType): m.Children {
198    if (
199      column.kind === 'regular' &&
200      (column.column === 'dur' || column.column === 'thread_dur')
201    ) {
202      if (typeof value === 'bigint') {
203        return m(DurationWidget, {dur: value});
204      } else if (typeof value === 'number') {
205        return m(DurationWidget, {dur: BigInt(Math.round(value))});
206      }
207    }
208    return `${value}`;
209  }
210
211  renderTree(
212    attrs: PivotTableAttrs,
213    path: PathItem[],
214    tree: PivotTree,
215    result: PivotTableResult,
216    sink: m.Vnode[],
217  ) {
218    if (tree.isCollapsed) {
219      sink.push(this.renderSectionRow(attrs, path, tree, result));
220      return;
221    }
222    if (tree.children.size > 0) {
223      // Avoid rendering the intermediate results row for the root of tree
224      // and in case there's only one child subtree.
225      if (!tree.isCollapsed && path.length > 0 && tree.children.size !== 1) {
226        sink.push(this.renderSectionRow(attrs, path, tree, result));
227      }
228      for (const [key, childTree] of tree.children.entries()) {
229        path.push({tree: childTree, nextKey: key});
230        this.renderTree(attrs, path, childTree, result, sink);
231        path.pop();
232      }
233      return;
234    }
235
236    // Avoid rendering the intermediate results row if it has only one leaf
237    // row.
238    if (!tree.isCollapsed && path.length > 0 && tree.rows.length > 1) {
239      sink.push(this.renderSectionRow(attrs, path, tree, result));
240    }
241    for (const row of tree.rows) {
242      const renderedCells = [];
243      const drillFilters: DrillFilter[] = [];
244      const treeDepth = result.metadata.pivotColumns.length;
245      for (let j = 0; j < treeDepth; j++) {
246        const value = this.renderCell(result.metadata.pivotColumns[j], row[j]);
247        if (j < path.length) {
248          renderedCells.push(m('td', m('span.indent', ' '), value));
249        } else {
250          renderedCells.push(m(`td`, value));
251        }
252        drillFilters.push({
253          column: result.metadata.pivotColumns[j],
254          value: row[j],
255        });
256      }
257      for (let j = 0; j < result.metadata.aggregationColumns.length; j++) {
258        const value = row[aggregationIndex(treeDepth, j)];
259        const renderedValue = this.renderCell(
260          result.metadata.aggregationColumns[j].column,
261          value,
262        );
263        renderedCells.push(m('td.aggregation' + markFirst(j), renderedValue));
264      }
265
266      renderedCells.push(this.renderDrillDownCell(attrs, drillFilters));
267      sink.push(m('tr', renderedCells));
268    }
269  }
270
271  renderTotalsRow(queryResult: PivotTableResult) {
272    const overallValuesRow = [
273      m(
274        'td.total-values',
275        {colspan: queryResult.metadata.pivotColumns.length},
276        m('strong', 'Total values:'),
277      ),
278    ];
279    for (let i = 0; i < queryResult.metadata.aggregationColumns.length; i++) {
280      overallValuesRow.push(
281        m(
282          'td' + markFirst(i),
283          this.renderCell(
284            queryResult.metadata.aggregationColumns[i].column,
285            queryResult.tree.aggregates[i],
286          ),
287        ),
288      );
289    }
290    overallValuesRow.push(m('td'));
291    return m('tr', overallValuesRow);
292  }
293
294  sortingItem(aggregationIndex: number, order: SortDirection): m.Child {
295    const pivotMgr = this.pivotMgr;
296    return m(MenuItem, {
297      label: order === 'DESC' ? 'Highest first' : 'Lowest first',
298      onclick: () => {
299        pivotMgr.setSortColumn(aggregationIndex, order);
300      },
301    });
302  }
303
304  readableAggregationName(aggregation: Aggregation) {
305    if (aggregation.aggregationFunction === 'COUNT') {
306      return 'Count';
307    }
308    return `${aggregation.aggregationFunction}(${readableColumnName(
309      aggregation.column,
310    )})`;
311  }
312
313  aggregationPopupItem(
314    aggregation: Aggregation,
315    index: number,
316    nameOverride?: string,
317  ): m.Child {
318    return m(MenuItem, {
319      label: nameOverride ?? readableColumnName(aggregation.column),
320      onclick: () => {
321        this.pivotMgr.addAggregation(aggregation, index);
322      },
323    });
324  }
325
326  aggregationPopupTableGroup(
327    table: string,
328    columns: string[],
329    index: number,
330  ): m.Child | undefined {
331    const items: m.Child[] = [];
332    for (const column of columns) {
333      const tableColumn: TableColumn = {kind: 'regular', table, column};
334      items.push(
335        this.aggregationPopupItem(
336          {aggregationFunction: 'SUM', column: tableColumn},
337          index,
338        ),
339      );
340    }
341
342    if (items.length === 0) {
343      return undefined;
344    }
345
346    return m(MenuItem, {label: `Add ${table} aggregation`}, items);
347  }
348
349  renderAggregationHeaderCell(
350    aggregation: Aggregation,
351    index: number,
352    removeItem: boolean,
353  ): ReorderableCell {
354    const popupItems: m.Child[] = [];
355    if (aggregation.sortDirection === undefined) {
356      popupItems.push(
357        this.sortingItem(index, 'DESC'),
358        this.sortingItem(index, 'ASC'),
359      );
360    } else {
361      // Table is already sorted by the same column, return one item with
362      // opposite direction.
363      popupItems.push(
364        this.sortingItem(
365          index,
366          aggregation.sortDirection === 'DESC' ? 'ASC' : 'DESC',
367        ),
368      );
369    }
370    const otherAggs: AggregationFunction[] = ['SUM', 'MAX', 'MIN', 'AVG'];
371    if (aggregation.aggregationFunction !== 'COUNT') {
372      for (const otherAgg of otherAggs) {
373        if (aggregation.aggregationFunction === otherAgg) {
374          continue;
375        }
376        const pivotMgr = this.pivotMgr;
377        popupItems.push(
378          m(MenuItem, {
379            label: otherAgg,
380            onclick: () => {
381              pivotMgr.setAggregationFunction(index, otherAgg);
382            },
383          }),
384        );
385      }
386    }
387
388    if (removeItem) {
389      popupItems.push(
390        m(MenuItem, {
391          label: 'Remove',
392          onclick: () => {
393            this.pivotMgr.removeAggregation(index);
394          },
395        }),
396      );
397    }
398
399    let hasCount = false;
400    for (const agg of this.pivotState.selectedAggregations.values()) {
401      if (agg.aggregationFunction === 'COUNT') {
402        hasCount = true;
403      }
404    }
405
406    if (!hasCount) {
407      popupItems.push(
408        this.aggregationPopupItem(
409          COUNT_AGGREGATION,
410          index,
411          'Add count aggregation',
412        ),
413      );
414    }
415
416    const sliceAggregationsItem = this.aggregationPopupTableGroup(
417      assertExists(getSqlTableDescription('slice')).name,
418      sliceAggregationColumns,
419      index,
420    );
421    if (sliceAggregationsItem !== undefined) {
422      popupItems.push(sliceAggregationsItem);
423    }
424
425    return {
426      extraClass: '.aggregation' + markFirst(index),
427      content: [
428        this.readableAggregationName(aggregation),
429        m(
430          PopupMenu2,
431          {
432            trigger: m(Button, {
433              icon: popupMenuIcon(aggregation.sortDirection),
434            }),
435          },
436          popupItems,
437        ),
438      ],
439    };
440  }
441
442  attributeModalHolder: AttributeModalHolder;
443
444  renderPivotColumnHeader(
445    queryResult: PivotTableResult,
446    pivot: TableColumn,
447    selectedPivots: Set<string>,
448  ): ReorderableCell {
449    const pivotMgr = this.pivotMgr;
450    const items: m.Child[] = [
451      m(MenuItem, {
452        label: 'Add argument pivot',
453        onclick: () => {
454          this.attributeModalHolder.start();
455        },
456      }),
457    ];
458    if (queryResult.metadata.pivotColumns.length > 1) {
459      items.push(
460        m(MenuItem, {
461          label: 'Remove',
462          onclick: () => {
463            pivotMgr.setPivotSelected({column: pivot, selected: false});
464          },
465        }),
466      );
467    }
468
469    for (const table of tables) {
470      const group: m.Child[] = [];
471      for (const columnName of table.columns) {
472        const column: TableColumn = {
473          kind: 'regular',
474          table: table.name,
475          column: columnName,
476        };
477        if (selectedPivots.has(columnKey(column))) {
478          continue;
479        }
480        group.push(
481          m(MenuItem, {
482            label: columnName,
483            onclick: () => {
484              pivotMgr.setPivotSelected({column, selected: true});
485            },
486          }),
487        );
488      }
489      items.push(
490        m(
491          MenuItem,
492          {
493            label: `Add ${table.displayName} pivot`,
494          },
495          group,
496        ),
497      );
498    }
499
500    return {
501      content: [
502        readableColumnName(pivot),
503        m(PopupMenu2, {trigger: m(Button, {icon: 'more_horiz'})}, items),
504      ],
505    };
506  }
507
508  renderResultsTable(attrs: PivotTableAttrs) {
509    const state = this.pivotState;
510    const queryResult = state.queryResult;
511    if (queryResult === undefined) {
512      return m('div', 'Loading...');
513    }
514
515    const renderedRows: m.Vnode[] = [];
516
517    // We should not even be showing the tab if there's no results.
518    const tree = queryResult.tree;
519    assertFalse(tree.children.size === 0 && tree.rows.length === 0);
520
521    this.renderTree(attrs, [], tree, queryResult, renderedRows);
522
523    const selectedPivots = new Set(
524      this.pivotState.selectedPivots.map(columnKey),
525    );
526    const pivotTableHeaders = state.selectedPivots.map((pivot) =>
527      this.renderPivotColumnHeader(queryResult, pivot, selectedPivots),
528    );
529
530    const removeItem = queryResult.metadata.aggregationColumns.length > 1;
531    const aggregationTableHeaders = queryResult.metadata.aggregationColumns.map(
532      (aggregation, index) =>
533        this.renderAggregationHeaderCell(aggregation, index, removeItem),
534    );
535
536    return m(
537      'table.pivot-table',
538      m(
539        'thead',
540        // First row of the table, containing names of pivot and aggregation
541        // columns, as well as popup menus to modify the columns. Last cell
542        // is empty because of an extra column with "drill down" button for
543        // each pivot table row.
544        m(
545          'tr.header',
546          m(ReorderableCellGroup, {
547            cells: pivotTableHeaders,
548            onReorder: (from: number, to: number, direction: DropDirection) => {
549              this.pivotMgr.setOrder(from, to, direction);
550            },
551          }),
552          m(ReorderableCellGroup, {
553            cells: aggregationTableHeaders,
554            onReorder: (from: number, to: number, direction: DropDirection) => {
555              this.pivotMgr.setAggregationOrder(from, to, direction);
556            },
557          }),
558          m(
559            'td.menu',
560            m(
561              PopupMenu2,
562              {
563                trigger: m(Button, {icon: 'menu'}),
564              },
565              m(MenuItem, {
566                label: state.constrainToArea
567                  ? 'Query data for the whole timeline'
568                  : 'Constrain to selected area',
569                onclick: () => {
570                  this.pivotMgr.setConstrainedToArea(!state.constrainToArea);
571                },
572              }),
573            ),
574          ),
575        ),
576      ),
577      m('tbody', this.renderTotalsRow(queryResult), renderedRows),
578    );
579  }
580
581  view({attrs}: m.Vnode<PivotTableAttrs>): m.Children {
582    return m('.pivot-table', this.renderResultsTable(attrs));
583  }
584}
585