| // 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 {QueryResponse} from './queries'; |
| import {Row} from '../../trace_processor/query_result'; |
| import {Callout} from '../../widgets/callout'; |
| import {DetailsShell} from '../../widgets/details_shell'; |
| import {Trace} from '../../public/trace'; |
| import {Icons} from '../../base/semantic_icons'; |
| import {DataGrid, renderCell, DataGridApi} from '../widgets/datagrid/datagrid'; |
| import { |
| CellRenderer, |
| ColumnSchema, |
| SchemaRegistry, |
| } from '../widgets/datagrid/datagrid_schema'; |
| import {InMemoryDataSource} from '../widgets/datagrid/in_memory_data_source'; |
| import {Anchor} from '../../widgets/anchor'; |
| import {Box} from '../../widgets/box'; |
| import {DataGridExportButton} from '../widgets/datagrid/export_button'; |
| import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button'; |
| import {DataSource} from '../widgets/datagrid/data_source'; |
| import {AddDebugTrackMenu} from '../tracks/add_debug_track_menu'; |
| import {Button} from '../../widgets/button'; |
| import {PopupMenu} from '../../widgets/menu'; |
| import {PopupPosition} from '../../widgets/popup'; |
| import {exists} from '../../base/utils'; |
| import {EmptyState} from '../../widgets/empty_state'; |
| |
| 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 QueryResultsTableAttrs { |
| // The trace that the query was executed against - used for adding debug |
| // tracks, navigating to slices, etc. |
| readonly trace: Trace; |
| |
| // If true, a loading indicator is shown. |
| readonly isLoading?: boolean; |
| |
| // The query response to display in the table. |
| readonly resp?: QueryResponse; |
| |
| // If true, the table will expand to fill the height of its container. |
| readonly fillHeight: boolean; |
| |
| // What to render in the body when there is no response. If undefined, a |
| // default "No results" empty state is shown. |
| readonly emptyState?: m.Children; |
| } |
| |
| /** |
| * A component that displays the results of a query in a DataGrid with a nicely |
| * styled toolbar and additional buttons for copying the query, exporting the |
| * data and creating a debug track. |
| */ |
| export class QueryResultsTable |
| implements m.ClassComponent<QueryResultsTableAttrs> |
| { |
| private dataSource?: DataSource; |
| private dataGridApi?: DataGridApi; |
| |
| constructor({attrs}: m.CVnode<QueryResultsTableAttrs>) { |
| if (attrs.resp) { |
| this.dataSource = new InMemoryDataSource(attrs.resp.rows); |
| } |
| } |
| |
| onbeforeupdate( |
| vnode: m.Vnode<QueryResultsTableAttrs, this>, |
| old: m.VnodeDOM<QueryResultsTableAttrs, 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<QueryResultsTableAttrs>) { |
| const {resp, fillHeight, trace, isLoading, emptyState} = attrs; |
| |
| return m( |
| DetailsShell, |
| { |
| className: 'pf-query-table', |
| title: this.renderTitle(isLoading, resp), |
| description: resp?.query, |
| buttons: this.renderButtons(trace, resp?.query, resp), |
| fillHeight, |
| }, |
| this.renderBody(trace, resp, isLoading, emptyState), |
| ); |
| } |
| |
| private renderBody( |
| trace: Trace, |
| resp: QueryResponse | undefined, |
| isLoading?: boolean, |
| emptyState?: m.Children, |
| ) { |
| if (isLoading) { |
| return m(EmptyState, { |
| fillHeight: true, |
| title: 'Query running...', |
| icon: 'pending', |
| }); |
| } |
| |
| if (!resp) { |
| if (emptyState !== undefined) { |
| return emptyState; |
| } |
| return m(EmptyState, { |
| fillHeight: true, |
| title: 'No results', |
| }); |
| } |
| |
| if (!this.dataSource) { |
| return null; |
| } |
| |
| return this.renderTableContent(trace, resp, this.dataSource); |
| } |
| |
| private renderTitle(isLoading?: boolean, resp?: QueryResponse) { |
| if (isLoading) { |
| return 'Query Results - running...'; |
| } |
| |
| if (resp === undefined) { |
| return 'Query Results - empty'; |
| } |
| |
| const result = resp.error ? 'error' : `${resp.rows.length} rows`; |
| return `Query Results (${result}) - ${resp.durationMs.toLocaleString()}ms`; |
| } |
| |
| private renderButtons( |
| trace: Trace, |
| query: string | undefined, |
| resp: QueryResponse | undefined, |
| ) { |
| return [ |
| this.renderAddDebugTrackButton(trace, resp), |
| query && |
| m(CopyToClipboardButton, { |
| textToCopy: query, |
| title: 'Copy executed query to clipboard', |
| label: 'Copy Query', |
| }), |
| this.dataGridApi && |
| m(DataGridExportButton, {onExportData: this.dataGridApi.exportData}), |
| ]; |
| } |
| |
| private renderAddDebugTrackButton( |
| trace: Trace, |
| resp: QueryResponse | undefined, |
| ) { |
| if (!resp || resp.error || !exists(resp.lastStatementSql)) { |
| return null; |
| } |
| |
| return m( |
| PopupMenu, |
| { |
| trigger: m(Button, {label: 'Add debug track'}), |
| position: PopupPosition.Top, |
| }, |
| m(AddDebugTrackMenu, { |
| trace, |
| query: resp.lastStatementSql, |
| availableColumns: resp.columns, |
| }), |
| ); |
| } |
| |
| private renderTableContent( |
| trace: Trace, |
| resp: QueryResponse, |
| dataSource: DataSource, |
| ) { |
| 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(trace, resp, dataSource), |
| ); |
| } |
| |
| private renderContent( |
| trace: Trace, |
| resp: QueryResponse, |
| dataSource: DataSource, |
| ) { |
| if (resp.error) { |
| return m('.pf-query-panel__query-error', `SQL error: ${resp.error}`); |
| } |
| |
| // Build schema directly |
| const columnSchema: ColumnSchema = {}; |
| for (const column of resp.columns) { |
| const cellRenderer: CellRenderer | undefined = |
| column === 'id' |
| ? (value, row) => { |
| const sliceId = getSliceId(row); |
| const cell = renderCell(value, column); |
| if (sliceId !== undefined && isSliceish(row)) { |
| return m( |
| Anchor, |
| { |
| title: 'Go to slice', |
| icon: Icons.UpdateSelection, |
| onclick: () => this.goToSlice(trace, sliceId, false), |
| ondblclick: () => this.goToSlice(trace, sliceId, true), |
| }, |
| cell, |
| ); |
| } else { |
| return renderCell(value, column); |
| } |
| } |
| : undefined; |
| |
| columnSchema[column] = {cellRenderer}; |
| } |
| |
| const schema: SchemaRegistry = {data: columnSchema}; |
| |
| return m(DataGrid, { |
| schema, |
| rootSchema: 'data', |
| // Fixed columns - ensures that any changes to the query response are |
| // reflected in the table immediately. |
| // TODO(stevegolton): Support column manipulation but sync with results on |
| // the query page. |
| columns: resp.columns.map((col) => ({id: col, field: col})), |
| enablePivotControls: false, // In-memory datasource doesn't support pivot |
| fillHeight: true, |
| data: dataSource, |
| onReady: (api) => { |
| this.dataGridApi = api; |
| }, |
| }); |
| } |
| |
| private goToSlice( |
| trace: Trace, |
| sliceId: number, |
| switchToCurrentSelectionTab: boolean, |
| ): void { |
| // Navigate to the timeline page |
| trace.navigate('#!/viewer'); |
| trace.selection.selectSqlEvent('slice', sliceId, { |
| switchToCurrentSelectionTab, |
| scrollToSelection: true, |
| }); |
| } |
| } |