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