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 * as m from 'mithril'; 18 19import {GenericSet} from '../base/generic_set'; 20import {sqliteString} from '../base/string_utils'; 21import {Actions} from '../common/actions'; 22import {ColumnType} from '../common/query_result'; 23import { 24 Area, 25 PivotTableReduxQuery, 26 PivotTableReduxResult 27} from '../common/state'; 28import {PivotTree} from '../controller/pivot_table_redux_controller'; 29 30import {globals} from './globals'; 31import {Panel} from './panel'; 32import { 33 aggregationIndex, 34 areaFilter, 35 createColumnSet, 36 generateQuery, 37 QueryGeneratorError, 38 sliceAggregationColumns, 39 Table, 40 TableColumn, 41 tables, 42 threadSliceAggregationColumns 43} from './pivot_table_redux_query_generator'; 44 45interface ColumnSetCheckboxAttrs { 46 set: GenericSet<TableColumn>; 47 setKey: TableColumn; 48} 49 50interface PathItem { 51 tree: PivotTree; 52 nextKey: ColumnType; 53} 54 55// Helper component that controls whether a particular key is present in a 56// ColumnSet. 57class ColumnSetCheckbox implements m.ClassComponent<ColumnSetCheckboxAttrs> { 58 view({attrs}: m.Vnode<ColumnSetCheckboxAttrs>) { 59 return m('input[type=checkbox]', { 60 onclick: (e: InputEvent) => { 61 const target = e.target as HTMLInputElement; 62 if (target.checked) { 63 attrs.set.add(attrs.setKey); 64 } else { 65 attrs.set.delete(attrs.setKey); 66 } 67 globals.rafScheduler.scheduleFullRedraw(); 68 }, 69 checked: attrs.set.has(attrs.setKey) 70 }); 71 } 72} 73 74interface PivotTableReduxAttrs { 75 selectionArea: Area; 76} 77 78interface DrillFilter { 79 column: string; 80 value: ColumnType; 81} 82 83// Convert DrillFilter to SQL condition to be used in WHERE clause. 84function renderDrillFilter(filter: DrillFilter): string { 85 if (filter.value === null) { 86 return `${filter.column} IS NULL`; 87 } else if (typeof filter.value === 'number') { 88 return `${filter.column} = ${filter.value}`; 89 } 90 return `${filter.column} = ${sqliteString(filter.value)}`; 91} 92 93export class PivotTableRedux extends Panel<PivotTableReduxAttrs> { 94 selectedPivotsMap = createColumnSet(); 95 selectedAggregations = createColumnSet(); 96 constrainToArea = true; 97 editMode = true; 98 99 renderCanvas(): void {} 100 101 generateQuery(attrs: PivotTableReduxAttrs): PivotTableReduxQuery { 102 return generateQuery( 103 this.selectedPivotsMap, 104 this.selectedAggregations, 105 attrs.selectionArea, 106 this.constrainToArea); 107 } 108 109 runQuery(attrs: PivotTableReduxAttrs) { 110 try { 111 const query = this.generateQuery(attrs); 112 const lastPivotTableState = globals.state.pivotTableRedux; 113 globals.dispatch(Actions.setPivotStateReduxState({ 114 pivotTableState: { 115 query, 116 queryId: lastPivotTableState.queryId + 1, 117 selectionArea: lastPivotTableState.selectionArea, 118 queryResult: null 119 } 120 })); 121 } catch (e) { 122 console.log(e); 123 } 124 } 125 126 renderTablePivotColumns(t: Table) { 127 return m( 128 'li', 129 t.name, 130 m('ul', 131 t.columns.map( 132 col => 133 m('li', 134 m(ColumnSetCheckbox, { 135 set: this.selectedPivotsMap, 136 setKey: [t.name, col], 137 }), 138 col)))); 139 } 140 141 renderResultsView(attrs: PivotTableReduxAttrs) { 142 return m( 143 '.pivot-table-redux', 144 m('button.mode-button', 145 { 146 onclick: () => { 147 this.editMode = true; 148 globals.rafScheduler.scheduleFullRedraw(); 149 } 150 }, 151 'Edit'), 152 this.renderResultsTable(attrs)); 153 } 154 155 renderDrillDownCell( 156 area: Area, result: PivotTableReduxResult, filters: DrillFilter[]) { 157 return m( 158 'td', 159 m('button', 160 { 161 title: 'All corresponding slices', 162 onclick: () => { 163 const queryFilters = filters.map(renderDrillFilter); 164 if (this.constrainToArea) { 165 queryFilters.push(areaFilter(area)); 166 } 167 const query = ` 168 select * from ${result.metadata.tableName} 169 where ${queryFilters.join(' and \n')} 170 `; 171 // TODO(ddrone): the UI of running query as if it was a canned or 172 // custom query is a temporary one, replace with a proper UI. 173 globals.dispatch(Actions.executeQuery({ 174 engineId: '0', 175 queryId: 'command', 176 query, 177 })); 178 } 179 }, 180 m('i.material-icons', 'arrow_right'))); 181 } 182 183 renderSectionRow( 184 area: Area, path: PathItem[], tree: PivotTree, 185 result: PivotTableReduxResult): m.Vnode { 186 const renderedCells = []; 187 for (let j = 0; j + 1 < path.length; j++) { 188 renderedCells.push(m('td', m('span.indent', ' '), `${path[j].nextKey}`)); 189 } 190 191 const treeDepth = result.metadata.pivotColumns.length; 192 const colspan = treeDepth - path.length + 1; 193 const button = 194 m('button', 195 { 196 onclick: () => { 197 tree.isCollapsed = !tree.isCollapsed; 198 globals.rafScheduler.scheduleFullRedraw(); 199 } 200 }, 201 m('i.material-icons', 202 tree.isCollapsed ? 'expand_more' : 'expand_less')); 203 204 renderedCells.push( 205 m('td', {colspan}, button, `${path[path.length - 1].nextKey}`)); 206 207 for (const value of tree.aggregates) { 208 renderedCells.push(m('td', `${value}`)); 209 } 210 211 const drillFilters: DrillFilter[] = []; 212 for (let i = 0; i < path.length; i++) { 213 drillFilters.push({ 214 value: `${path[i].nextKey}`, 215 column: result.metadata.pivotColumns[i] 216 }); 217 } 218 219 renderedCells.push(this.renderDrillDownCell(area, result, drillFilters)); 220 return m('tr', renderedCells); 221 } 222 223 renderTree( 224 area: Area, path: PathItem[], tree: PivotTree, 225 result: PivotTableReduxResult, sink: m.Vnode[]) { 226 if (tree.isCollapsed) { 227 sink.push(this.renderSectionRow(area, path, tree, result)); 228 return; 229 } 230 if (tree.children.size > 0) { 231 // Avoid rendering the intermediate results row for the root of tree 232 // and in case there's only one child subtree. 233 if (!tree.isCollapsed && path.length > 0 && tree.children.size !== 1) { 234 sink.push(this.renderSectionRow(area, path, tree, result)); 235 } 236 for (const [key, childTree] of tree.children.entries()) { 237 path.push({tree: childTree, nextKey: key}); 238 this.renderTree(area, path, childTree, result, sink); 239 path.pop(); 240 } 241 return; 242 } 243 244 // Avoid rendering the intermediate results row if it has only one leaf 245 // row. 246 if (!tree.isCollapsed && path.length > 0 && tree.rows.length > 1) { 247 sink.push(this.renderSectionRow(area, path, tree, result)); 248 } 249 for (const row of tree.rows) { 250 const renderedCells = []; 251 const drillFilters: DrillFilter[] = []; 252 const treeDepth = result.metadata.pivotColumns.length; 253 for (let j = 0; j < treeDepth; j++) { 254 if (j < path.length) { 255 renderedCells.push(m('td', m('span.indent', ' '), `${row[j]}`)); 256 } else { 257 renderedCells.push(m(`td`, `${row[j]}`)); 258 } 259 drillFilters.push( 260 {column: result.metadata.pivotColumns[j], value: row[j]}); 261 } 262 for (let j = 0; j < result.metadata.aggregationColumns.length; j++) { 263 const value = row[aggregationIndex(treeDepth, j, treeDepth)]; 264 renderedCells.push(m('td', `${value}`)); 265 } 266 267 renderedCells.push(this.renderDrillDownCell(area, result, drillFilters)); 268 sink.push(m('tr', renderedCells)); 269 } 270 } 271 272 renderTotalsRow(queryResult: PivotTableReduxResult) { 273 const overallValuesRow = 274 [m('td.total-values', 275 {'colspan': queryResult.metadata.pivotColumns.length}, 276 m('strong', 'Total values:'))]; 277 for (const aggValue of queryResult.tree.aggregates) { 278 overallValuesRow.push(m('td', `${aggValue}`)); 279 } 280 overallValuesRow.push(m('td')); 281 return m('tr', overallValuesRow); 282 } 283 284 renderResultsTable(attrs: PivotTableReduxAttrs) { 285 const state = globals.state.pivotTableRedux; 286 if (state.query !== null || state.queryResult === null) { 287 return m('div', 'Loading...'); 288 } 289 290 const renderedRows: m.Vnode[] = []; 291 const tree = state.queryResult.tree; 292 293 if (tree.children.size === 0 && tree.rows.length === 0) { 294 // Empty result, render a special message 295 return m('.empty-result', 'No slices in the current selection.'); 296 } 297 298 this.renderTree( 299 attrs.selectionArea, [], tree, state.queryResult, renderedRows); 300 301 const allColumns = state.queryResult.metadata.pivotColumns.concat( 302 state.queryResult.metadata.aggregationColumns); 303 return m( 304 'table.query-table.pivot-table', 305 m('thead', m('tr', allColumns.map(column => m('td', column)), m('td'))), 306 m('tbody', this.renderTotalsRow(state.queryResult), renderedRows)); 307 } 308 309 renderQuery(attrs: PivotTableReduxAttrs): m.Vnode { 310 // Prepare a button to switch to results mode. 311 let innerElement = 312 m('button.mode-button', 313 { 314 onclick: () => { 315 this.editMode = false; 316 this.runQuery(attrs); 317 globals.rafScheduler.scheduleFullRedraw(); 318 } 319 }, 320 'Execute'); 321 try { 322 this.generateQuery(attrs); 323 } catch (e) { 324 if (e instanceof QueryGeneratorError) { 325 // If query generation fails, show an error message instead of a button. 326 innerElement = m('div.query-error', e.message); 327 } else { 328 throw e; 329 } 330 } 331 332 return m( 333 'div', 334 m('div', 335 m('input', { 336 type: 'checkbox', 337 id: 'constrain-to-selection', 338 checked: this.constrainToArea, 339 onclick: (e: InputEvent) => { 340 const checkbox = e.target as HTMLInputElement; 341 this.constrainToArea = checkbox.checked; 342 } 343 }), 344 m('label', 345 { 346 'for': 'constrain-to-selection', 347 }, 348 'Constrain to current time range')), 349 innerElement); 350 } 351 352 view({attrs}: m.Vnode<PivotTableReduxAttrs>) { 353 return this.editMode ? this.renderEditView(attrs) : 354 this.renderResultsView(attrs); 355 } 356 357 renderEditView(attrs: PivotTableReduxAttrs) { 358 return m( 359 '.pivot-table-redux.edit', 360 m('div', 361 m('h2', 'Pivots'), 362 m('ul', 363 tables.map( 364 t => this.renderTablePivotColumns(t), 365 ))), 366 m('div', 367 m('h2', 'Aggregations'), 368 m('ul', 369 ...sliceAggregationColumns.map( 370 t => 371 m('li', 372 m(ColumnSetCheckbox, { 373 set: this.selectedAggregations, 374 setKey: ['slice', t], 375 }), 376 t)), 377 ...threadSliceAggregationColumns.map( 378 t => 379 m('li', 380 m(ColumnSetCheckbox, { 381 set: this.selectedAggregations, 382 setKey: ['thread_slice', t], 383 }), 384 `thread_slice.${t}`)))), 385 this.renderQuery(attrs)); 386 } 387}