| // Copyright (C) 2020 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import m from 'mithril'; |
| import {copyToClipboard} from '../../base/clipboard'; |
| import { |
| formatAsDelimited, |
| formatAsMarkdownTable, |
| QueryResponse, |
| } from './queries'; |
| import {Row} from '../../trace_processor/query_result'; |
| import {Button} from '../../widgets/button'; |
| import {Callout} from '../../widgets/callout'; |
| import {DetailsShell} from '../../widgets/details_shell'; |
| import {Router} from '../../core/router'; |
| import {Trace} from '../../public/trace'; |
| import {MenuItem, PopupMenu} from '../../widgets/menu'; |
| import {Icons} from '../../base/semantic_icons'; |
| import {DataGrid, renderCell} from '../widgets/data_grid/data_grid'; |
| import {DataGridDataSource} from '../widgets/data_grid/common'; |
| import {InMemoryDataSource} from '../widgets/data_grid/in_memory_data_source'; |
| import {Anchor} from '../../widgets/anchor'; |
| import {Box} from '../../widgets/box'; |
| |
| type Numeric = bigint | number; |
| |
| function isIntegral(x: Row[string]): x is Numeric { |
| return ( |
| typeof x === 'bigint' || (typeof x === 'number' && Number.isInteger(x)) |
| ); |
| } |
| |
| function hasTs(row: Row): row is Row & {ts: Numeric} { |
| return 'ts' in row && isIntegral(row.ts); |
| } |
| |
| function hasDur(row: Row): row is Row & {dur: Numeric} { |
| return 'dur' in row && isIntegral(row.dur); |
| } |
| |
| function hasTrackId(row: Row): row is Row & {track_id: Numeric} { |
| return 'track_id' in row && isIntegral(row.track_id); |
| } |
| |
| function hasSliceId(row: Row): row is Row & {slice_id: Numeric} { |
| return 'slice_id' in row && isIntegral(row.slice_id); |
| } |
| |
| // These are properties that a row should have in order to be "slice-like", |
| // insofar as it represents a time range and a track id which can be revealed |
| // or zoomed-into on the timeline. |
| type Sliceish = { |
| ts: Numeric; |
| dur: Numeric; |
| track_id: Numeric; |
| }; |
| |
| export function isSliceish(row: Row): row is Row & Sliceish { |
| return hasTs(row) && hasDur(row) && hasTrackId(row); |
| } |
| |
| // Attempts to extract a slice ID from a row, or undefined if none can be found |
| export function getSliceId(row: Row): number | undefined { |
| if (hasSliceId(row)) { |
| return Number(row.slice_id); |
| } |
| return undefined; |
| } |
| |
| interface QueryTableAttrs { |
| readonly trace: Trace; |
| readonly query: string; |
| readonly resp?: QueryResponse; |
| readonly contextButtons?: m.Child[]; |
| readonly fillHeight: boolean; |
| } |
| |
| export class QueryTable implements m.ClassComponent<QueryTableAttrs> { |
| private readonly trace: Trace; |
| private dataSource?: DataGridDataSource; |
| |
| constructor({attrs}: m.CVnode<QueryTableAttrs>) { |
| this.trace = attrs.trace; |
| if (attrs.resp) { |
| this.dataSource = new InMemoryDataSource(attrs.resp.rows); |
| } |
| } |
| |
| onbeforeupdate( |
| vnode: m.Vnode<QueryTableAttrs, this>, |
| old: m.VnodeDOM<QueryTableAttrs, this>, |
| ): boolean | void { |
| if (vnode.attrs.resp !== old.attrs.resp) { |
| if (vnode.attrs.resp) { |
| this.dataSource = new InMemoryDataSource(vnode.attrs.resp.rows); |
| } else { |
| this.dataSource = undefined; |
| } |
| } |
| } |
| |
| view({attrs}: m.CVnode<QueryTableAttrs>) { |
| const {resp, query, contextButtons = [], fillHeight} = attrs; |
| |
| return m( |
| DetailsShell, |
| { |
| className: 'pf-query-table', |
| title: this.renderTitle(resp), |
| description: query, |
| buttons: this.renderButtons(query, contextButtons, resp), |
| fillHeight, |
| }, |
| resp && this.dataSource && this.renderTableContent(resp, this.dataSource), |
| ); |
| } |
| |
| private renderTitle(resp?: QueryResponse) { |
| if (!resp) { |
| return 'Query - running'; |
| } |
| const result = resp.error ? 'error' : `${resp.rows.length} rows`; |
| return `Query result (${result}) - ${resp.durationMs.toLocaleString()}ms`; |
| } |
| |
| private renderButtons( |
| query: string, |
| contextButtons: m.Child[], |
| resp?: QueryResponse, |
| ) { |
| return [ |
| contextButtons, |
| m( |
| PopupMenu, |
| { |
| trigger: m(Button, { |
| label: 'Copy', |
| rightIcon: Icons.ContextMenu, |
| }), |
| }, |
| m(MenuItem, { |
| label: 'Query', |
| onclick: () => copyToClipboard(query), |
| }), |
| resp && |
| resp.error === undefined && [ |
| m(MenuItem, { |
| label: 'Result (.tsv)', |
| onclick: async () => { |
| const tsv = formatAsDelimited(resp); |
| await copyToClipboard(tsv); |
| }, |
| }), |
| m(MenuItem, { |
| label: 'Result (.md)', |
| onclick: async () => { |
| const markdown = formatAsMarkdownTable(resp); |
| await copyToClipboard(markdown); |
| }, |
| }), |
| ], |
| ), |
| ]; |
| } |
| |
| private renderTableContent( |
| resp: QueryResponse, |
| dataSource: DataGridDataSource, |
| ) { |
| return m( |
| '.pf-query-panel', |
| resp.statementWithOutputCount > 1 && |
| m(Box, [ |
| m(Callout, {icon: 'warning'}, [ |
| `${resp.statementWithOutputCount} out of ${resp.statementCount} `, |
| 'statements returned a result. ', |
| 'Only the results for the last statement are displayed.', |
| ]), |
| ]), |
| this.renderContent(resp, dataSource), |
| ); |
| } |
| |
| private renderContent(resp: QueryResponse, dataSource: DataGridDataSource) { |
| if (resp.error) { |
| return m('.pf-query-panel__query-error', `SQL error: ${resp.error}`); |
| } |
| |
| const onTimelinePage = |
| Router.parseUrl(window.location.href).page === '/viewer'; |
| |
| return m(DataGrid, { |
| // If filters are defined by no onFilterChanged handler, the grid operates |
| // in filter read only mode. |
| fillHeight: true, |
| filters: [], |
| columns: resp.columns.map((c) => ({name: c})), |
| data: dataSource, |
| cellRenderer: (value, name, row) => { |
| const sliceId = getSliceId(row); |
| const cell = renderCell(value, name); |
| if ( |
| name === 'id' && |
| sliceId !== undefined && |
| onTimelinePage && |
| isSliceish(row) |
| ) { |
| return m( |
| Anchor, |
| { |
| title: 'Go to slice', |
| icon: Icons.UpdateSelection, |
| onclick: () => this.goToSlice(sliceId, false), |
| ondblclick: () => this.goToSlice(sliceId, true), |
| }, |
| cell, |
| ); |
| } else { |
| return cell; |
| } |
| }, |
| }); |
| } |
| |
| private goToSlice( |
| sliceId: number, |
| switchToCurrentSelectionTab: boolean, |
| ): void { |
| this.trace.selection.selectSqlEvent('slice', sliceId, { |
| switchToCurrentSelectionTab, |
| scrollToSelection: true, |
| }); |
| } |
| } |