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