1// Copyright (C) 2020 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 {copyToClipboard} from '../../base/clipboard'; 17import {QueryResponse} from './queries'; 18import {Row} from '../../trace_processor/query_result'; 19import {Anchor} from '../../widgets/anchor'; 20import {Button} from '../../widgets/button'; 21import {Callout} from '../../widgets/callout'; 22import {DetailsShell} from '../../widgets/details_shell'; 23import {downloadData} from '../../base/download_utils'; 24import {Router} from '../../core/router'; 25import {AppImpl} from '../../core/app_impl'; 26import {Trace} from '../../public/trace'; 27import {MenuItem, PopupMenu} from '../../widgets/menu'; 28import {Icons} from '../../base/semantic_icons'; 29 30// Controls how many rows we see per page when showing paginated results. 31const ROWS_PER_PAGE = 50; 32 33interface QueryTableRowAttrs { 34 readonly trace: Trace; 35 readonly row: Row; 36 readonly columns: ReadonlyArray<string>; 37} 38 39type Numeric = bigint | number; 40 41function isIntegral(x: Row[string]): x is Numeric { 42 return ( 43 typeof x === 'bigint' || (typeof x === 'number' && Number.isInteger(x)) 44 ); 45} 46 47function hasTs(row: Row): row is Row & {ts: Numeric} { 48 return 'ts' in row && isIntegral(row.ts); 49} 50 51function hasDur(row: Row): row is Row & {dur: Numeric} { 52 return 'dur' in row && isIntegral(row.dur); 53} 54 55function hasTrackId(row: Row): row is Row & {track_id: Numeric} { 56 return 'track_id' in row && isIntegral(row.track_id); 57} 58 59function hasSliceId(row: Row): row is Row & {slice_id: Numeric} { 60 return 'slice_id' in row && isIntegral(row.slice_id); 61} 62 63// These are properties that a row should have in order to be "slice-like", 64// insofar as it represents a time range and a track id which can be revealed 65// or zoomed-into on the timeline. 66type Sliceish = { 67 ts: Numeric; 68 dur: Numeric; 69 track_id: Numeric; 70}; 71 72export function isSliceish(row: Row): row is Row & Sliceish { 73 return hasTs(row) && hasDur(row) && hasTrackId(row); 74} 75 76// Attempts to extract a slice ID from a row, or undefined if none can be found 77export function getSliceId(row: Row): number | undefined { 78 if (hasSliceId(row)) { 79 return Number(row.slice_id); 80 } 81 return undefined; 82} 83 84class QueryTableRow implements m.ClassComponent<QueryTableRowAttrs> { 85 private readonly trace: Trace; 86 87 constructor({attrs}: m.Vnode<QueryTableRowAttrs>) { 88 this.trace = attrs.trace; 89 } 90 91 view(vnode: m.Vnode<QueryTableRowAttrs>) { 92 const {row, columns} = vnode.attrs; 93 const cells = columns.map((col) => this.renderCell(col, row[col])); 94 95 // TODO(dproy): Make click handler work from analyze page. 96 if ( 97 Router.parseUrl(window.location.href).page === '/viewer' && 98 isSliceish(row) 99 ) { 100 return m( 101 'tr', 102 { 103 onclick: () => this.selectAndRevealSlice(row, false), 104 // TODO(altimin): Consider improving the logic here (e.g. delay?) to 105 // account for cases when dblclick fires late. 106 ondblclick: () => this.selectAndRevealSlice(row, true), 107 clickable: true, 108 title: 'Go to slice', 109 }, 110 cells, 111 ); 112 } else { 113 return m('tr', cells); 114 } 115 } 116 117 private renderCell(name: string, value: Row[string]) { 118 if (value instanceof Uint8Array) { 119 return m('td', this.renderBlob(name, value)); 120 } else { 121 return m('td', `${value}`); 122 } 123 } 124 125 private renderBlob(name: string, value: Uint8Array) { 126 return m( 127 Anchor, 128 { 129 onclick: () => downloadData(`${name}.blob`, value), 130 }, 131 `Blob (${value.length} bytes)`, 132 ); 133 } 134 135 private selectAndRevealSlice( 136 row: Row & Sliceish, 137 switchToCurrentSelectionTab: boolean, 138 ) { 139 const sliceId = getSliceId(row); 140 if (sliceId === undefined) { 141 return; 142 } 143 this.trace.selection.selectSqlEvent('slice', sliceId, { 144 switchToCurrentSelectionTab, 145 scrollToSelection: true, 146 }); 147 } 148} 149 150interface QueryTableContentAttrs { 151 readonly trace: Trace; 152 readonly columns: ReadonlyArray<string>; 153 readonly rows: ReadonlyArray<Row>; 154} 155 156class QueryTableContent implements m.ClassComponent<QueryTableContentAttrs> { 157 view({attrs}: m.CVnode<QueryTableContentAttrs>) { 158 const cols = []; 159 for (const col of attrs.columns) { 160 cols.push(m('td', col)); 161 } 162 const tableHeader = m('tr', cols); 163 164 const rows = attrs.rows.map((row) => { 165 return m(QueryTableRow, { 166 trace: attrs.trace, 167 row, 168 columns: attrs.columns, 169 }); 170 }); 171 172 return m('table.pf-query-table', m('thead', tableHeader), m('tbody', rows)); 173 } 174} 175 176interface QueryTableAttrs { 177 trace: Trace; 178 query: string; 179 resp?: QueryResponse; 180 contextButtons?: m.Child[]; 181 fillParent: boolean; 182} 183 184export class QueryTable implements m.ClassComponent<QueryTableAttrs> { 185 private readonly trace: Trace; 186 private pageNumber = 0; 187 188 constructor({attrs}: m.CVnode<QueryTableAttrs>) { 189 this.trace = attrs.trace; 190 } 191 192 view({attrs}: m.CVnode<QueryTableAttrs>) { 193 const {resp, query, contextButtons = [], fillParent} = attrs; 194 195 // Clamp the page number to ensure the page count doesn't exceed the number 196 // of rows in the results. 197 if (resp) { 198 const pageCount = this.getPageCount(resp.rows.length); 199 if (this.pageNumber >= pageCount) { 200 this.pageNumber = Math.max(0, pageCount - 1); 201 } 202 } else { 203 this.pageNumber = 0; 204 } 205 206 return m( 207 DetailsShell, 208 { 209 title: this.renderTitle(resp), 210 description: query, 211 buttons: this.renderButtons(query, contextButtons, resp), 212 fillParent, 213 }, 214 resp && this.renderTableContent(resp), 215 ); 216 } 217 218 private getPageCount(rowCount: number) { 219 return Math.floor((rowCount - 1) / ROWS_PER_PAGE) + 1; 220 } 221 222 private getFirstRowInPage() { 223 return this.pageNumber * ROWS_PER_PAGE; 224 } 225 226 private getCountOfRowsInPage(totalRows: number) { 227 const firstRow = this.getFirstRowInPage(); 228 const endStop = Math.min(firstRow + ROWS_PER_PAGE, totalRows); 229 return endStop - firstRow; 230 } 231 232 private renderTitle(resp?: QueryResponse) { 233 if (!resp) { 234 return 'Query - running'; 235 } 236 const result = resp.error ? 'error' : `${resp.rows.length} rows`; 237 if (AppImpl.instance.testingMode) { 238 // Omit the duration in tests, they cause screenshot diff failures. 239 return `Query result (${result})`; 240 } 241 return `Query result (${result}) - ${resp.durationMs.toLocaleString()}ms`; 242 } 243 244 private renderButtons( 245 query: string, 246 contextButtons: m.Child[], 247 resp?: QueryResponse, 248 ) { 249 return [ 250 resp && this.renderPrevNextButtons(resp), 251 contextButtons, 252 m( 253 PopupMenu, 254 { 255 trigger: m(Button, { 256 label: 'Copy', 257 rightIcon: Icons.ContextMenu, 258 }), 259 }, 260 m(MenuItem, { 261 label: 'Query', 262 onclick: () => copyToClipboard(query), 263 }), 264 resp && 265 resp.error === undefined && [ 266 m(MenuItem, { 267 label: 'Result (.tsv)', 268 onclick: () => queryResponseAsTsvToClipboard(resp), 269 }), 270 m(MenuItem, { 271 label: 'Result (.md)', 272 onclick: () => queryResponseAsMarkdownToClipboard(resp), 273 }), 274 ], 275 ), 276 ]; 277 } 278 279 private renderPrevNextButtons(resp: QueryResponse) { 280 const from = this.getFirstRowInPage(); 281 const to = Math.min(from + this.getCountOfRowsInPage(resp.rows.length)) - 1; 282 const pageCount = this.getPageCount(resp.rows.length); 283 284 return [ 285 `Showing rows ${from + 1} to ${to + 1} of ${resp.rows.length}`, 286 m(Button, { 287 label: 'Prev', 288 icon: 'skip_previous', 289 title: 'Go to previous page of results', 290 disabled: this.pageNumber === 0, 291 onclick: () => { 292 this.pageNumber = Math.max(0, this.pageNumber - 1); 293 }, 294 }), 295 m(Button, { 296 label: 'Next', 297 icon: 'skip_next', 298 title: 'Go to next page of results', 299 disabled: this.pageNumber >= pageCount - 1, 300 onclick: () => { 301 this.pageNumber = Math.min(pageCount - 1, this.pageNumber + 1); 302 }, 303 }), 304 ]; 305 } 306 307 private renderTableContent(resp: QueryResponse) { 308 return m( 309 '.pf-query-panel', 310 resp.statementWithOutputCount > 1 && 311 m( 312 '.pf-query-warning', 313 m( 314 Callout, 315 {icon: 'warning'}, 316 `${resp.statementWithOutputCount} out of ${resp.statementCount} `, 317 'statements returned a result. ', 318 'Only the results for the last statement are displayed.', 319 ), 320 ), 321 this.renderContent(resp), 322 ); 323 } 324 325 private renderContent(resp: QueryResponse) { 326 if (resp.error) { 327 return m('.query-error', `SQL error: ${resp.error}`); 328 } 329 330 // Pick out only the rows in this page. 331 const rowOffset = this.getFirstRowInPage(); 332 const totalRows = this.getCountOfRowsInPage(resp.rows.length); 333 const rowsInPage: Row[] = []; 334 for ( 335 let rowIndex = rowOffset; 336 rowIndex < rowOffset + totalRows; 337 ++rowIndex 338 ) { 339 rowsInPage.push(resp.rows[rowIndex]); 340 } 341 342 return m(QueryTableContent, { 343 trace: this.trace, 344 columns: resp.columns, 345 rows: rowsInPage, 346 }); 347 } 348} 349 350async function queryResponseAsTsvToClipboard( 351 resp: QueryResponse, 352): Promise<void> { 353 const lines: string[][] = []; 354 lines.push(resp.columns); 355 for (const row of resp.rows) { 356 const line = []; 357 for (const col of resp.columns) { 358 const value = row[col]; 359 line.push(value === null ? 'NULL' : `${value}`); 360 } 361 lines.push(line); 362 } 363 await copyToClipboard(lines.map((line) => line.join('\t')).join('\n')); 364} 365 366async function queryResponseAsMarkdownToClipboard( 367 resp: QueryResponse, 368): Promise<void> { 369 // Convert all values to strings. 370 // rows = [header, separators, ...body] 371 const rows: string[][] = []; 372 rows.push(resp.columns); 373 rows.push(resp.columns.map((_) => '---')); 374 for (const responseRow of resp.rows) { 375 rows.push( 376 resp.columns.map((responseCol) => { 377 const value = responseRow[responseCol]; 378 return value === null ? 'NULL' : `${value}`; 379 }), 380 ); 381 } 382 383 // Find the maximum width of each column. 384 const maxWidths: number[] = Array(resp.columns.length).fill(0); 385 for (const row of rows) { 386 for (let i = 0; i < resp.columns.length; i++) { 387 if (row[i].length > maxWidths[i]) { 388 maxWidths[i] = row[i].length; 389 } 390 } 391 } 392 393 const text = rows 394 .map((row, rowIndex) => { 395 // Pad each column to the maximum width with hyphens (separator row) or 396 // spaces (all other rows). 397 const expansionChar = rowIndex === 1 ? '-' : ' '; 398 const line: string[] = row.map( 399 (str, colIndex) => 400 str + expansionChar.repeat(maxWidths[colIndex] - str.length), 401 ); 402 return `| ${line.join(' | ')} |`; 403 }) 404 .join('\n'); 405 406 await copyToClipboard(text); 407} 408