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 {BigintMath} from '../../base/bigint_math'; 17import {copyToClipboard} from '../../base/clipboard'; 18import {isString} from '../../base/object_utils'; 19import {Time} from '../../base/time'; 20import {QueryResponse} from './queries'; 21import {Row} from '../../trace_processor/query_result'; 22import {Anchor} from '../../widgets/anchor'; 23import {Button} from '../../widgets/button'; 24import {Callout} from '../../widgets/callout'; 25import {DetailsShell} from '../../widgets/details_shell'; 26import {downloadData} from '../../base/download_utils'; 27import {Router} from '../../core/router'; 28import {scrollTo} from '../../public/scroll_helper'; 29import {AppImpl} from '../../core/app_impl'; 30import {Trace} from '../../public/trace'; 31 32interface QueryTableRowAttrs { 33 trace: Trace; 34 row: Row; 35 columns: string[]; 36} 37 38type Numeric = bigint | number; 39 40function isIntegral(x: Row[string]): x is Numeric { 41 return ( 42 typeof x === 'bigint' || (typeof x === 'number' && Number.isInteger(x)) 43 ); 44} 45 46function hasTs(row: Row): row is Row & {ts: Numeric} { 47 return 'ts' in row && isIntegral(row.ts); 48} 49 50function hasDur(row: Row): row is Row & {dur: Numeric} { 51 return 'dur' in row && isIntegral(row.dur); 52} 53 54function hasTrackId(row: Row): row is Row & {track_id: Numeric} { 55 return 'track_id' in row && isIntegral(row.track_id); 56} 57 58function hasType(row: Row): row is Row & {type: string} { 59 return 'type' in row && isString(row.type); 60} 61 62function hasId(row: Row): row is Row & {id: Numeric} { 63 return 'id' in row && isIntegral(row.id); 64} 65 66function hasSliceId(row: Row): row is Row & {slice_id: Numeric} { 67 return 'slice_id' in row && isIntegral(row.slice_id); 68} 69 70// These are properties that a row should have in order to be "slice-like", 71// insofar as it represents a time range and a track id which can be revealed 72// or zoomed-into on the timeline. 73type Sliceish = { 74 ts: Numeric; 75 dur: Numeric; 76 track_id: Numeric; 77}; 78 79export function isSliceish(row: Row): row is Row & Sliceish { 80 return hasTs(row) && hasDur(row) && hasTrackId(row); 81} 82 83// Attempts to extract a slice ID from a row, or undefined if none can be found 84export function getSliceId(row: Row): number | undefined { 85 if (hasType(row) && row.type.includes('slice')) { 86 if (hasId(row)) { 87 return Number(row.id); 88 } 89 } else { 90 if (hasSliceId(row)) { 91 return Number(row.slice_id); 92 } 93 } 94 return undefined; 95} 96 97class QueryTableRow implements m.ClassComponent<QueryTableRowAttrs> { 98 private readonly trace: Trace; 99 100 constructor({attrs}: m.Vnode<QueryTableRowAttrs>) { 101 this.trace = attrs.trace; 102 } 103 104 view(vnode: m.Vnode<QueryTableRowAttrs>) { 105 const {row, columns} = vnode.attrs; 106 const cells = columns.map((col) => this.renderCell(col, row[col])); 107 108 // TODO(dproy): Make click handler work from analyze page. 109 if ( 110 Router.parseUrl(window.location.href).page === '/viewer' && 111 isSliceish(row) 112 ) { 113 return m( 114 'tr', 115 { 116 onclick: () => this.selectAndRevealSlice(row, false), 117 // TODO(altimin): Consider improving the logic here (e.g. delay?) to 118 // account for cases when dblclick fires late. 119 ondblclick: () => this.selectAndRevealSlice(row, true), 120 clickable: true, 121 title: 'Go to slice', 122 }, 123 cells, 124 ); 125 } else { 126 return m('tr', cells); 127 } 128 } 129 130 private renderCell(name: string, value: Row[string]) { 131 if (value instanceof Uint8Array) { 132 return m('td', this.renderBlob(name, value)); 133 } else { 134 return m('td', `${value}`); 135 } 136 } 137 138 private renderBlob(name: string, value: Uint8Array) { 139 return m( 140 Anchor, 141 { 142 onclick: () => downloadData(`${name}.blob`, value), 143 }, 144 `Blob (${value.length} bytes)`, 145 ); 146 } 147 148 private selectAndRevealSlice( 149 row: Row & Sliceish, 150 switchToCurrentSelectionTab: boolean, 151 ) { 152 const trackId = Number(row.track_id); 153 const sliceStart = Time.fromRaw(BigInt(row.ts)); 154 // row.dur can be negative. Clamp to 1ns. 155 const sliceDur = BigintMath.max(BigInt(row.dur), 1n); 156 const trackUri = this.trace.tracks.findTrack((td) => 157 td.tags?.trackIds?.includes(trackId), 158 )?.uri; 159 if (trackUri !== undefined) { 160 scrollTo({ 161 track: {uri: trackUri, expandGroup: true}, 162 time: {start: sliceStart, end: Time.add(sliceStart, sliceDur)}, 163 }); 164 const sliceId = getSliceId(row); 165 if (sliceId !== undefined) { 166 this.selectSlice(sliceId, switchToCurrentSelectionTab); 167 } 168 } 169 } 170 171 private selectSlice(sliceId: number, switchToCurrentSelectionTab: boolean) { 172 this.trace.selection.selectSqlEvent('slice', sliceId, { 173 switchToCurrentSelectionTab, 174 scrollToSelection: true, 175 }); 176 } 177} 178 179interface QueryTableContentAttrs { 180 trace: Trace; 181 resp: QueryResponse; 182} 183 184class QueryTableContent implements m.ClassComponent<QueryTableContentAttrs> { 185 private previousResponse?: QueryResponse; 186 187 onbeforeupdate(vnode: m.CVnode<QueryTableContentAttrs>) { 188 return vnode.attrs.resp !== this.previousResponse; 189 } 190 191 view(vnode: m.CVnode<QueryTableContentAttrs>) { 192 const resp = vnode.attrs.resp; 193 this.previousResponse = resp; 194 const cols = []; 195 for (const col of resp.columns) { 196 cols.push(m('td', col)); 197 } 198 const tableHeader = m('tr', cols); 199 200 const rows = resp.rows.map((row) => 201 m(QueryTableRow, {trace: vnode.attrs.trace, row, columns: resp.columns}), 202 ); 203 204 if (resp.error) { 205 return m('.query-error', `SQL error: ${resp.error}`); 206 } else { 207 return m( 208 'table.pf-query-table', 209 m('thead', tableHeader), 210 m('tbody', rows), 211 ); 212 } 213 } 214} 215 216interface QueryTableAttrs { 217 trace: Trace; 218 query: string; 219 resp?: QueryResponse; 220 contextButtons?: m.Child[]; 221 fillParent: boolean; 222} 223 224export class QueryTable implements m.ClassComponent<QueryTableAttrs> { 225 private readonly trace: Trace; 226 227 constructor({attrs}: m.CVnode<QueryTableAttrs>) { 228 this.trace = attrs.trace; 229 } 230 231 view({attrs}: m.CVnode<QueryTableAttrs>) { 232 const {resp, query, contextButtons = [], fillParent} = attrs; 233 234 return m( 235 DetailsShell, 236 { 237 title: this.renderTitle(resp), 238 description: query, 239 buttons: this.renderButtons(query, contextButtons, resp), 240 fillParent, 241 }, 242 resp && this.renderTableContent(resp), 243 ); 244 } 245 246 renderTitle(resp?: QueryResponse) { 247 if (!resp) { 248 return 'Query - running'; 249 } 250 const result = resp.error ? 'error' : `${resp.rows.length} rows`; 251 if (AppImpl.instance.testingMode) { 252 // Omit the duration in tests, they cause screenshot diff failures. 253 return `Query result (${result})`; 254 } 255 return `Query result (${result}) - ${resp.durationMs.toLocaleString()}ms`; 256 } 257 258 renderButtons( 259 query: string, 260 contextButtons: m.Child[], 261 resp?: QueryResponse, 262 ) { 263 return [ 264 contextButtons, 265 m(Button, { 266 label: 'Copy query', 267 onclick: () => { 268 copyToClipboard(query); 269 }, 270 }), 271 resp && 272 resp.error === undefined && 273 m(Button, { 274 label: 'Copy result (.tsv)', 275 onclick: () => { 276 queryResponseToClipboard(resp); 277 }, 278 }), 279 ]; 280 } 281 282 renderTableContent(resp: QueryResponse) { 283 return m( 284 '.pf-query-panel', 285 resp.statementWithOutputCount > 1 && 286 m( 287 '.pf-query-warning', 288 m( 289 Callout, 290 {icon: 'warning'}, 291 `${resp.statementWithOutputCount} out of ${resp.statementCount} `, 292 'statements returned a result. ', 293 'Only the results for the last statement are displayed.', 294 ), 295 ), 296 m(QueryTableContent, {trace: this.trace, resp}), 297 ); 298 } 299} 300 301async function queryResponseToClipboard(resp: QueryResponse): Promise<void> { 302 const lines: string[][] = []; 303 lines.push(resp.columns); 304 for (const row of resp.rows) { 305 const line = []; 306 for (const col of resp.columns) { 307 const value = row[col]; 308 line.push(value === null ? 'NULL' : `${value}`); 309 } 310 lines.push(line); 311 } 312 copyToClipboard(lines.map((line) => line.join('\t')).join('\n')); 313} 314