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 {SortDirection} from '../base/comparison_utils'; 20import {sqliteString} from '../base/string_utils'; 21import {Actions} from '../common/actions'; 22import {DropDirection} from '../common/dragndrop_logic'; 23import {COUNT_AGGREGATION} from '../common/empty_state'; 24import {Area, PivotTableResult} from '../common/state'; 25import {raf} from '../core/raf_scheduler'; 26import {ColumnType} from '../trace_processor/query_result'; 27 28import {globals} from './globals'; 29import { 30 aggregationIndex, 31 areaFilters, 32 extractArgumentExpression, 33 sliceAggregationColumns, 34 tables, 35} from './pivot_table_query_generator'; 36import { 37 Aggregation, 38 AggregationFunction, 39 columnKey, 40 PivotTree, 41 TableColumn, 42} from './pivot_table_types'; 43import {PopupMenuButton, popupMenuIcon, PopupMenuItem} from './popup_menu'; 44import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells'; 45import {addSqlTableTab} from './sql_table/tab'; 46import {SqlTables} from './sql_table/well_known_tables'; 47import {AttributeModalHolder} from './tables/attribute_modal_holder'; 48import {DurationWidget} from './widgets/duration'; 49 50interface PathItem { 51 tree: PivotTree; 52 nextKey: ColumnType; 53} 54 55interface PivotTableAttrs { 56 selectionArea: Area; 57} 58 59interface DrillFilter { 60 column: TableColumn; 61 value: ColumnType; 62} 63 64function drillFilterColumnName(column: TableColumn): string { 65 switch (column.kind) { 66 case 'argument': 67 return extractArgumentExpression(column.argument, SqlTables.slice.name); 68 case 'regular': 69 return `${column.column}`; 70 } 71} 72 73// Convert DrillFilter to SQL condition to be used in WHERE clause. 74function renderDrillFilter(filter: DrillFilter): string { 75 const column = drillFilterColumnName(filter.column); 76 if (filter.value === null) { 77 return `${column} IS NULL`; 78 } else if (typeof filter.value === 'number') { 79 return `${column} = ${filter.value}`; 80 } else if (filter.value instanceof Uint8Array) { 81 throw new Error(`BLOB as DrillFilter not implemented`); 82 } else if (typeof filter.value === 'bigint') { 83 return `${column} = ${filter.value}`; 84 } 85 return `${column} = ${sqliteString(filter.value)}`; 86} 87 88function readableColumnName(column: TableColumn) { 89 switch (column.kind) { 90 case 'argument': 91 return `Argument ${column.argument}`; 92 case 'regular': 93 return `${column.column}`; 94 } 95} 96 97export function markFirst(index: number) { 98 if (index === 0) { 99 return '.first'; 100 } 101 return ''; 102} 103 104export class PivotTable implements m.ClassComponent<PivotTableAttrs> { 105 constructor() { 106 this.attributeModalHolder = new AttributeModalHolder((arg) => { 107 globals.dispatch( 108 Actions.setPivotTablePivotSelected({ 109 column: {kind: 'argument', argument: arg}, 110 selected: true, 111 }), 112 ); 113 globals.dispatch( 114 Actions.setPivotTableQueryRequested({queryRequested: true}), 115 ); 116 }); 117 } 118 119 get pivotState() { 120 return globals.state.nonSerializableState.pivotTable; 121 } 122 get constrainToArea() { 123 return globals.state.nonSerializableState.pivotTable.constrainToArea; 124 } 125 126 renderDrillDownCell(area: Area, filters: DrillFilter[]) { 127 return m( 128 'td', 129 m( 130 'button', 131 { 132 title: 'All corresponding slices', 133 onclick: () => { 134 const queryFilters = filters.map(renderDrillFilter); 135 if (this.constrainToArea) { 136 queryFilters.push(...areaFilters(area)); 137 } 138 addSqlTableTab({ 139 table: SqlTables.slice, 140 filters: queryFilters, 141 }); 142 }, 143 }, 144 m('i.material-icons', 'arrow_right'), 145 ), 146 ); 147 } 148 149 renderSectionRow( 150 area: Area, 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(area, 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 } 205 } 206 return `${value}`; 207 } 208 209 renderTree( 210 area: Area, 211 path: PathItem[], 212 tree: PivotTree, 213 result: PivotTableResult, 214 sink: m.Vnode[], 215 ) { 216 if (tree.isCollapsed) { 217 sink.push(this.renderSectionRow(area, path, tree, result)); 218 return; 219 } 220 if (tree.children.size > 0) { 221 // Avoid rendering the intermediate results row for the root of tree 222 // and in case there's only one child subtree. 223 if (!tree.isCollapsed && path.length > 0 && tree.children.size !== 1) { 224 sink.push(this.renderSectionRow(area, path, tree, result)); 225 } 226 for (const [key, childTree] of tree.children.entries()) { 227 path.push({tree: childTree, nextKey: key}); 228 this.renderTree(area, path, childTree, result, sink); 229 path.pop(); 230 } 231 return; 232 } 233 234 // Avoid rendering the intermediate results row if it has only one leaf 235 // row. 236 if (!tree.isCollapsed && path.length > 0 && tree.rows.length > 1) { 237 sink.push(this.renderSectionRow(area, path, tree, result)); 238 } 239 for (const row of tree.rows) { 240 const renderedCells = []; 241 const drillFilters: DrillFilter[] = []; 242 const treeDepth = result.metadata.pivotColumns.length; 243 for (let j = 0; j < treeDepth; j++) { 244 const value = this.renderCell(result.metadata.pivotColumns[j], row[j]); 245 if (j < path.length) { 246 renderedCells.push(m('td', m('span.indent', ' '), value)); 247 } else { 248 renderedCells.push(m(`td`, value)); 249 } 250 drillFilters.push({ 251 column: result.metadata.pivotColumns[j], 252 value: row[j], 253 }); 254 } 255 for (let j = 0; j < result.metadata.aggregationColumns.length; j++) { 256 const value = row[aggregationIndex(treeDepth, j)]; 257 const renderedValue = this.renderCell( 258 result.metadata.aggregationColumns[j].column, 259 value, 260 ); 261 renderedCells.push(m('td.aggregation' + markFirst(j), renderedValue)); 262 } 263 264 renderedCells.push(this.renderDrillDownCell(area, drillFilters)); 265 sink.push(m('tr', renderedCells)); 266 } 267 } 268 269 renderTotalsRow(queryResult: PivotTableResult) { 270 const overallValuesRow = [ 271 m( 272 'td.total-values', 273 {colspan: queryResult.metadata.pivotColumns.length}, 274 m('strong', 'Total values:'), 275 ), 276 ]; 277 for (let i = 0; i < queryResult.metadata.aggregationColumns.length; i++) { 278 overallValuesRow.push( 279 m( 280 'td' + markFirst(i), 281 this.renderCell( 282 queryResult.metadata.aggregationColumns[i].column, 283 queryResult.tree.aggregates[i], 284 ), 285 ), 286 ); 287 } 288 overallValuesRow.push(m('td')); 289 return m('tr', overallValuesRow); 290 } 291 292 sortingItem(aggregationIndex: number, order: SortDirection): PopupMenuItem { 293 return { 294 itemType: 'regular', 295 text: order === 'DESC' ? 'Highest first' : 'Lowest first', 296 callback() { 297 globals.dispatch( 298 Actions.setPivotTableSortColumn({aggregationIndex, order}), 299 ); 300 globals.dispatch( 301 Actions.setPivotTableQueryRequested({queryRequested: true}), 302 ); 303 }, 304 }; 305 } 306 307 readableAggregationName(aggregation: Aggregation) { 308 if (aggregation.aggregationFunction === 'COUNT') { 309 return 'Count'; 310 } 311 return `${aggregation.aggregationFunction}(${readableColumnName( 312 aggregation.column, 313 )})`; 314 } 315 316 aggregationPopupItem( 317 aggregation: Aggregation, 318 index: number, 319 nameOverride?: string, 320 ): PopupMenuItem { 321 return { 322 itemType: 'regular', 323 text: nameOverride ?? readableColumnName(aggregation.column), 324 callback: () => { 325 globals.dispatch( 326 Actions.addPivotTableAggregation({aggregation, after: index}), 327 ); 328 globals.dispatch( 329 Actions.setPivotTableQueryRequested({queryRequested: true}), 330 ); 331 }, 332 }; 333 } 334 335 aggregationPopupTableGroup( 336 table: string, 337 columns: string[], 338 index: number, 339 ): PopupMenuItem | undefined { 340 const items = []; 341 for (const column of columns) { 342 const tableColumn: TableColumn = {kind: 'regular', table, column}; 343 items.push( 344 this.aggregationPopupItem( 345 {aggregationFunction: 'SUM', column: tableColumn}, 346 index, 347 ), 348 ); 349 } 350 351 if (items.length === 0) { 352 return undefined; 353 } 354 355 return { 356 itemType: 'group', 357 itemId: `aggregations-${table}`, 358 text: `Add ${table} aggregation`, 359 children: items, 360 }; 361 } 362 363 renderAggregationHeaderCell( 364 aggregation: Aggregation, 365 index: number, 366 removeItem: boolean, 367 ): ReorderableCell { 368 const popupItems: PopupMenuItem[] = []; 369 const state = globals.state.nonSerializableState.pivotTable; 370 if (aggregation.sortDirection === undefined) { 371 popupItems.push( 372 this.sortingItem(index, 'DESC'), 373 this.sortingItem(index, 'ASC'), 374 ); 375 } else { 376 // Table is already sorted by the same column, return one item with 377 // opposite direction. 378 popupItems.push( 379 this.sortingItem( 380 index, 381 aggregation.sortDirection === 'DESC' ? 'ASC' : 'DESC', 382 ), 383 ); 384 } 385 const otherAggs: AggregationFunction[] = ['SUM', 'MAX', 'MIN', 'AVG']; 386 if (aggregation.aggregationFunction !== 'COUNT') { 387 for (const otherAgg of otherAggs) { 388 if (aggregation.aggregationFunction === otherAgg) { 389 continue; 390 } 391 392 popupItems.push({ 393 itemType: 'regular', 394 text: otherAgg, 395 callback() { 396 globals.dispatch( 397 Actions.setPivotTableAggregationFunction({ 398 index, 399 function: otherAgg, 400 }), 401 ); 402 globals.dispatch( 403 Actions.setPivotTableQueryRequested({queryRequested: true}), 404 ); 405 }, 406 }); 407 } 408 } 409 410 if (removeItem) { 411 popupItems.push({ 412 itemType: 'regular', 413 text: 'Remove', 414 callback: () => { 415 globals.dispatch(Actions.removePivotTableAggregation({index})); 416 globals.dispatch( 417 Actions.setPivotTableQueryRequested({queryRequested: true}), 418 ); 419 }, 420 }); 421 } 422 423 let hasCount = false; 424 for (const agg of state.selectedAggregations.values()) { 425 if (agg.aggregationFunction === 'COUNT') { 426 hasCount = true; 427 } 428 } 429 430 if (!hasCount) { 431 popupItems.push( 432 this.aggregationPopupItem( 433 COUNT_AGGREGATION, 434 index, 435 'Add count aggregation', 436 ), 437 ); 438 } 439 440 const sliceAggregationsItem = this.aggregationPopupTableGroup( 441 SqlTables.slice.name, 442 sliceAggregationColumns, 443 index, 444 ); 445 if (sliceAggregationsItem !== undefined) { 446 popupItems.push(sliceAggregationsItem); 447 } 448 449 return { 450 extraClass: '.aggregation' + markFirst(index), 451 content: [ 452 this.readableAggregationName(aggregation), 453 m(PopupMenuButton, { 454 icon: popupMenuIcon(aggregation.sortDirection), 455 items: popupItems, 456 }), 457 ], 458 }; 459 } 460 461 attributeModalHolder: AttributeModalHolder; 462 463 renderPivotColumnHeader( 464 queryResult: PivotTableResult, 465 pivot: TableColumn, 466 selectedPivots: Set<string>, 467 ): ReorderableCell { 468 const items: PopupMenuItem[] = [ 469 { 470 itemType: 'regular', 471 text: 'Add argument pivot', 472 callback: () => { 473 this.attributeModalHolder.start(); 474 }, 475 }, 476 ]; 477 if (queryResult.metadata.pivotColumns.length > 1) { 478 items.push({ 479 itemType: 'regular', 480 text: 'Remove', 481 callback() { 482 globals.dispatch( 483 Actions.setPivotTablePivotSelected({ 484 column: pivot, 485 selected: false, 486 }), 487 ); 488 globals.dispatch( 489 Actions.setPivotTableQueryRequested({queryRequested: true}), 490 ); 491 }, 492 }); 493 } 494 495 for (const table of tables) { 496 const group: PopupMenuItem[] = []; 497 for (const columnName of table.columns) { 498 const column: TableColumn = { 499 kind: 'regular', 500 table: table.name, 501 column: columnName, 502 }; 503 if (selectedPivots.has(columnKey(column))) { 504 continue; 505 } 506 507 group.push({ 508 itemType: 'regular', 509 text: columnName, 510 callback() { 511 globals.dispatch( 512 Actions.setPivotTablePivotSelected({column, selected: true}), 513 ); 514 globals.dispatch( 515 Actions.setPivotTableQueryRequested({queryRequested: true}), 516 ); 517 }, 518 }); 519 } 520 items.push({ 521 itemType: 'group', 522 itemId: `pivot-${table.name}`, 523 text: `Add ${table.displayName} pivot`, 524 children: group, 525 }); 526 } 527 528 return { 529 content: [ 530 readableColumnName(pivot), 531 m(PopupMenuButton, {icon: 'more_horiz', items}), 532 ], 533 }; 534 } 535 536 renderResultsTable(attrs: PivotTableAttrs) { 537 const state = globals.state.nonSerializableState.pivotTable; 538 if (state.queryResult === null) { 539 return m('div', 'Loading...'); 540 } 541 const queryResult: PivotTableResult = state.queryResult; 542 543 const renderedRows: m.Vnode[] = []; 544 const tree = state.queryResult.tree; 545 546 if (tree.children.size === 0 && tree.rows.length === 0) { 547 // Empty result, render a special message 548 return m('.empty-result', 'No slices in the current selection.'); 549 } 550 551 this.renderTree( 552 attrs.selectionArea, 553 [], 554 tree, 555 state.queryResult, 556 renderedRows, 557 ); 558 559 const selectedPivots = new Set( 560 this.pivotState.selectedPivots.map(columnKey), 561 ); 562 const pivotTableHeaders = state.selectedPivots.map((pivot) => 563 this.renderPivotColumnHeader(queryResult, pivot, selectedPivots), 564 ); 565 566 const removeItem = state.queryResult.metadata.aggregationColumns.length > 1; 567 const aggregationTableHeaders = 568 state.queryResult.metadata.aggregationColumns.map((aggregation, index) => 569 this.renderAggregationHeaderCell(aggregation, index, removeItem), 570 ); 571 572 return m( 573 'table.pivot-table', 574 m( 575 'thead', 576 // First row of the table, containing names of pivot and aggregation 577 // columns, as well as popup menus to modify the columns. Last cell 578 // is empty because of an extra column with "drill down" button for 579 // each pivot table row. 580 m( 581 'tr.header', 582 m(ReorderableCellGroup, { 583 cells: pivotTableHeaders, 584 onReorder: (from: number, to: number, direction: DropDirection) => { 585 globals.dispatch( 586 Actions.changePivotTablePivotOrder({from, to, direction}), 587 ); 588 globals.dispatch( 589 Actions.setPivotTableQueryRequested({queryRequested: true}), 590 ); 591 }, 592 }), 593 m(ReorderableCellGroup, { 594 cells: aggregationTableHeaders, 595 onReorder: (from: number, to: number, direction: DropDirection) => { 596 globals.dispatch( 597 Actions.changePivotTableAggregationOrder({from, to, direction}), 598 ); 599 globals.dispatch( 600 Actions.setPivotTableQueryRequested({queryRequested: true}), 601 ); 602 }, 603 }), 604 m( 605 'td.menu', 606 m(PopupMenuButton, { 607 icon: 'menu', 608 items: [ 609 { 610 itemType: 'regular', 611 text: state.constrainToArea 612 ? 'Query data for the whole timeline' 613 : 'Constrain to selected area', 614 callback: () => { 615 globals.dispatch( 616 Actions.setPivotTableConstrainToArea({ 617 constrain: !state.constrainToArea, 618 }), 619 ); 620 globals.dispatch( 621 Actions.setPivotTableQueryRequested({ 622 queryRequested: true, 623 }), 624 ); 625 }, 626 }, 627 ], 628 }), 629 ), 630 ), 631 ), 632 m('tbody', this.renderTotalsRow(state.queryResult), renderedRows), 633 ); 634 } 635 636 view({attrs}: m.Vnode<PivotTableAttrs>): m.Children { 637 return m('.pivot-table', this.renderResultsTable(attrs)); 638 } 639} 640