blob: de504920beeb5c75907f2791cc987cf00067c0ac [file]
// 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,
});
}
}