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