blob: 6a034a0b593ef366320f9152d94111aed7ed4930 [file] [log] [blame]
/*
* Copyright (C) 2022 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 * as m from 'mithril';
import {sqliteString} from '../base/string_utils';
import {Actions, DeferredAction} from '../common/actions';
import {ColumnType} from '../common/query_result';
import {
Area,
PivotTableReduxAreaState,
PivotTableReduxQuery,
PivotTableReduxResult,
SortDirection
} from '../common/state';
import {fromNs, timeToCode} from '../common/time';
import {
PivotTableReduxController,
PivotTree
} from '../controller/pivot_table_redux_controller';
import {globals} from './globals';
import {Panel} from './panel';
import {
aggregationIndex,
areaFilter,
extractArgumentExpression,
generateQuery,
QueryGeneratorError,
sliceAggregationColumns,
Table,
TableColumn,
tableColumnEquals,
tables,
threadSliceAggregationColumns
} from './pivot_table_redux_query_generator';
import {PopupMenuButton, PopupMenuItem} from './popup_menu';
interface PathItem {
tree: PivotTree;
nextKey: ColumnType;
}
// Used to convert TableColumn to a string in order to store it in a Map, as
// ES6 does not support compound Set/Map keys. This function should only be used
// for interning keys, and does not have any requirements beyond different
// TableColumn objects mapping to different strings.
export function columnKey(tableColumn: TableColumn): string {
switch (tableColumn.kind) {
case 'count': {
return 'count';
}
case 'argument': {
return `argument:${tableColumn.argument}`;
}
case 'regular': {
return `${tableColumn.table}.${tableColumn.column}`;
}
default: {
throw new Error(`malformed table column ${tableColumn}`);
}
}
}
// Arguments to an action to toggle a table column in a particular part of
// application's state.
interface ColumnSetArgs {
column: TableColumn;
selected: boolean;
}
interface ColumnSetCheckboxAttrs {
set: (args: ColumnSetArgs) => DeferredAction<ColumnSetArgs>;
get: Map<string, TableColumn>;
setKey: TableColumn;
}
// Helper component that controls whether a particular key is present in a
// ColumnSet.
class ColumnSetCheckbox implements m.ClassComponent<ColumnSetCheckboxAttrs> {
view({attrs}: m.Vnode<ColumnSetCheckboxAttrs>) {
return m('input[type=checkbox]', {
onclick: (e: InputEvent) => {
const target = e.target as HTMLInputElement;
globals.dispatch(
attrs.set({column: attrs.setKey, selected: target.checked}));
globals.rafScheduler.scheduleFullRedraw();
},
checked: attrs.get.has(columnKey(attrs.setKey))
});
}
}
interface PivotTableReduxAttrs {
selectionArea: PivotTableReduxAreaState;
}
interface DrillFilter {
column: TableColumn;
value: ColumnType;
}
function drillFilterExpression(column: TableColumn) {
switch (column.kind) {
case 'count': {
throw new Error('pivot cannot be count!');
}
case 'regular': {
// TODO(b/231429468): This would not work for non-slice column.
return column.column;
}
case 'argument': {
return extractArgumentExpression(column.argument);
}
default: {
throw new Error(`malformed table column ${column}`);
}
}
}
// Convert DrillFilter to SQL condition to be used in WHERE clause.
function renderDrillFilter(filter: DrillFilter): string {
const column = drillFilterExpression(filter.column);
if (filter.value === null) {
return `${column} IS NULL`;
} else if (typeof filter.value === 'number') {
return `${column} = ${filter.value}`;
}
return `${column} = ${sqliteString(filter.value)}`;
}
function readableColumnName(column: TableColumn) {
switch (column.kind) {
case 'count': {
return 'Count';
}
case 'argument': {
return `Argument ${column.argument}`;
}
case 'regular': {
return `${column.table}.${column.column}`;
}
default: {
throw new Error(`malformed table column ${column}`);
}
}
}
export class PivotTableRedux extends Panel<PivotTableReduxAttrs> {
get selectedPivotsMap() {
return globals.state.nonSerializableState.pivotTableRedux.selectedPivotsMap;
}
get selectedAggregations() {
return globals.state.nonSerializableState.pivotTableRedux
.selectedAggregations;
}
get constrainToArea() {
return globals.state.nonSerializableState.pivotTableRedux.constrainToArea;
}
renderCanvas(): void {}
generateQuery(attrs: PivotTableReduxAttrs): PivotTableReduxQuery {
return generateQuery(
this.selectedPivotsMap,
this.selectedAggregations,
globals.state.areas[attrs.selectionArea.areaId],
this.constrainToArea);
}
renderTablePivotColumns(t: Table) {
return m(
'li',
t.name,
m('ul',
t.columns.map(
col =>
m('li',
m(ColumnSetCheckbox, {
get: this.selectedPivotsMap,
set: Actions.setPivotTablePivotSelected,
setKey: {kind: 'regular', table: t.name, column: col},
}),
col))));
}
renderResultsView(attrs: PivotTableReduxAttrs) {
return m(
'.pivot-table-redux',
m('button.mode-button',
{
onclick: () => {
globals.dispatch(Actions.setPivotTableEditMode({editMode: true}));
globals.rafScheduler.scheduleFullRedraw();
}
},
'Edit'),
this.renderResultsTable(attrs));
}
renderDrillDownCell(
area: Area, result: PivotTableReduxResult, filters: DrillFilter[]) {
return m(
'td',
m('button',
{
title: 'All corresponding slices',
onclick: () => {
const queryFilters = filters.map(renderDrillFilter);
if (this.constrainToArea) {
queryFilters.push(areaFilter(area));
}
const query = `
select * from ${result.metadata.tableName}
where ${queryFilters.join(' and \n')}
`;
// TODO(ddrone): the UI of running query as if it was a canned or
// custom query is a temporary one, replace with a proper UI.
globals.dispatch(Actions.executeQuery({
queryId: `pivot_table_details_${
PivotTableReduxController.detailsCount++}`,
query,
}));
}
},
m('i.material-icons', 'arrow_right')));
}
renderSectionRow(
area: Area, path: PathItem[], tree: PivotTree,
result: PivotTableReduxResult): m.Vnode {
const renderedCells = [];
for (let j = 0; j + 1 < path.length; j++) {
renderedCells.push(m('td', m('span.indent', ' '), `${path[j].nextKey}`));
}
const treeDepth = result.metadata.pivotColumns.length;
const colspan = treeDepth - path.length + 1;
const button =
m('button',
{
onclick: () => {
tree.isCollapsed = !tree.isCollapsed;
globals.rafScheduler.scheduleFullRedraw();
}
},
m('i.material-icons',
tree.isCollapsed ? 'expand_more' : 'expand_less'));
renderedCells.push(
m('td', {colspan}, button, `${path[path.length - 1].nextKey}`));
for (let i = 0; i < tree.aggregates.length; i++) {
const renderedValue = this.renderCell(
result.metadata.aggregationColumns[i], tree.aggregates[i]);
renderedCells.push(m('td', renderedValue));
}
const drillFilters: DrillFilter[] = [];
for (let i = 0; i < path.length; i++) {
drillFilters.push({
value: `${path[i].nextKey}`,
column: result.metadata.pivotColumns[i]
});
}
renderedCells.push(this.renderDrillDownCell(area, result, drillFilters));
return m('tr', renderedCells);
}
renderCell(column: TableColumn, value: ColumnType): string {
if (column.kind === 'regular' &&
(column.column === 'dur' || column.column === 'thread_dur')) {
if (typeof value === 'number') {
return timeToCode(fromNs(value));
}
}
return `${value}`;
}
renderTree(
area: Area, path: PathItem[], tree: PivotTree,
result: PivotTableReduxResult, sink: m.Vnode[]) {
if (tree.isCollapsed) {
sink.push(this.renderSectionRow(area, path, tree, result));
return;
}
if (tree.children.size > 0) {
// Avoid rendering the intermediate results row for the root of tree
// and in case there's only one child subtree.
if (!tree.isCollapsed && path.length > 0 && tree.children.size !== 1) {
sink.push(this.renderSectionRow(area, path, tree, result));
}
for (const [key, childTree] of tree.children.entries()) {
path.push({tree: childTree, nextKey: key});
this.renderTree(area, path, childTree, result, sink);
path.pop();
}
return;
}
// Avoid rendering the intermediate results row if it has only one leaf
// row.
if (!tree.isCollapsed && path.length > 0 && tree.rows.length > 1) {
sink.push(this.renderSectionRow(area, path, tree, result));
}
for (const row of tree.rows) {
const renderedCells = [];
const drillFilters: DrillFilter[] = [];
const treeDepth = result.metadata.pivotColumns.length;
for (let j = 0; j < treeDepth; j++) {
const value = this.renderCell(result.metadata.pivotColumns[j], row[j]);
if (j < path.length) {
renderedCells.push(m('td', m('span.indent', ' '), value));
} else {
renderedCells.push(m(`td`, value));
}
drillFilters.push(
{column: result.metadata.pivotColumns[j], value: row[j]});
}
for (let j = 0; j < result.metadata.aggregationColumns.length; j++) {
const value = row[aggregationIndex(treeDepth, j, treeDepth)];
const renderedValue =
this.renderCell(result.metadata.aggregationColumns[j], value);
renderedCells.push(m('td', renderedValue));
}
renderedCells.push(this.renderDrillDownCell(area, result, drillFilters));
sink.push(m('tr', renderedCells));
}
}
renderTotalsRow(queryResult: PivotTableReduxResult) {
const overallValuesRow =
[m('td.total-values',
{'colspan': queryResult.metadata.pivotColumns.length},
m('strong', 'Total values:'))];
for (let i = 0; i < queryResult.tree.aggregates.length; i++) {
overallValuesRow.push(
m('td',
this.renderCell(
queryResult.metadata.aggregationColumns[i],
queryResult.tree.aggregates[i])));
}
overallValuesRow.push(m('td'));
return m('tr', overallValuesRow);
}
sortingItem(column: TableColumn, order: SortDirection): PopupMenuItem {
// Arrow contains unicode character for up or down arrow, according to the
// direction of sorting.
const arrow = order === 'DESC' ? '\u25BC' : '\u25B2';
return {
text: `Sort ${arrow}`,
callback() {
globals.dispatch(Actions.setPivotTableSortColumn({column, order}));
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}));
}
};
}
readableAggregationName(column: TableColumn) {
switch (column.kind) {
case 'count': {
return 'Count';
}
case 'argument': {
return `SUM(Argument ${column.argument})`;
}
case 'regular': {
return `SUM(${column.column})`;
}
default: {
throw new Error(`malformed table column ${column}`);
}
}
}
renderAggregationHeaderCell(aggregation: TableColumn): m.Child {
const popupItems: PopupMenuItem[] = [];
const state = globals.state.nonSerializableState.pivotTableRedux;
if (state.sortCriteria === undefined ||
!tableColumnEquals(aggregation, state.sortCriteria.column)) {
popupItems.push(
this.sortingItem(aggregation, 'DESC'),
this.sortingItem(aggregation, 'ASC'));
} else {
// Table is already sorted by the same column, return one item with
// opposite direction.
popupItems.push(this.sortingItem(
aggregation, state.sortCriteria.order === 'DESC' ? 'ASC' : 'DESC'));
}
return m(
'td', this.readableAggregationName(aggregation), m(PopupMenuButton, {
icon: 'arrow_drop_down',
items: popupItems,
}));
}
renderResultsTable(attrs: PivotTableReduxAttrs) {
const state = globals.state.nonSerializableState.pivotTableRedux;
if (state.queryResult === null) {
return m('div', 'Loading...');
}
const renderedRows: m.Vnode[] = [];
const tree = state.queryResult.tree;
if (tree.children.size === 0 && tree.rows.length === 0) {
// Empty result, render a special message
return m('.empty-result', 'No slices in the current selection.');
}
this.renderTree(
globals.state.areas[attrs.selectionArea.areaId],
[],
tree,
state.queryResult,
renderedRows);
const pivotTableHeaders = [];
for (const pivot of state.queryResult.metadata.pivotColumns) {
const items = [{
text: 'Add argument pivot',
callback: () => {
// TODO(ddrone): Replace this with modal using argument name
// completion after the modal mithrilization CL is landed.
const argument = prompt('Enter argument name');
if (argument !== null) {
globals.dispatch(Actions.setPivotTablePivotSelected(
{column: {kind: 'argument', argument}, selected: true}));
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}));
}
}
}];
if (state.queryResult.metadata.pivotColumns.length > 1) {
items.push({
text: 'Remove',
callback() {
globals.dispatch(Actions.setPivotTablePivotSelected(
{column: pivot, selected: false}));
globals.dispatch(
Actions.setPivotTableQueryRequested({queryRequested: true}));
}
});
}
pivotTableHeaders.push(
m('td',
readableColumnName(pivot),
m(PopupMenuButton, {icon: 'arrow_drop_down', items})));
}
const aggregationTableHeaders =
state.queryResult.metadata.aggregationColumns.map(
aggregation => this.renderAggregationHeaderCell(aggregation));
return m(
'table.query-table.pivot-table',
m('thead',
// First row of the table, containing names of pivot and aggregation
// columns, as well as popup menus to modify the columns. Last cell
// is empty because of an extra column with "drill down" button for
// each pivot table row.
m('tr', pivotTableHeaders, aggregationTableHeaders, m('td'))),
m('tbody', this.renderTotalsRow(state.queryResult), renderedRows));
}
renderQuery(attrs: PivotTableReduxAttrs): m.Vnode {
// Prepare a button to switch to results mode.
let innerElement = m(
'button.mode-button',
{
onclick: () => {
globals.dispatch(Actions.setPivotTableEditMode({editMode: false}));
globals.rafScheduler.scheduleFullRedraw();
}
},
'Execute');
try {
this.generateQuery(attrs);
} catch (e) {
if (e instanceof QueryGeneratorError) {
// If query generation fails, show an error message instead of a button.
innerElement = m('div.query-error', e.message);
} else {
throw e;
}
}
return m(
'div',
m('div',
m('input', {
type: 'checkbox',
id: 'constrain-to-selection',
checked: this.constrainToArea,
onclick: (e: InputEvent) => {
const checkbox = e.target as HTMLInputElement;
globals.dispatch(Actions.setPivotTableReduxConstrainToArea(
{constrain: checkbox.checked}));
}
}),
m('label',
{
'for': 'constrain-to-selection',
},
'Constrain to current time range')),
innerElement);
}
view({attrs}: m.Vnode<PivotTableReduxAttrs>): m.Children {
return globals.state.nonSerializableState.pivotTableRedux.editMode ?
this.renderEditView(attrs) :
this.renderResultsView(attrs);
}
renderEditView(attrs: PivotTableReduxAttrs) {
return m(
'.pivot-table-redux.edit',
m('div',
m('h2', 'Pivots'),
m('ul',
tables.map(
t => this.renderTablePivotColumns(t),
))),
m('div',
m('h2', 'Aggregations'),
m('ul',
m('li',
m(ColumnSetCheckbox, {
get: this.selectedAggregations,
set: Actions.setPivotTableAggregationSelected,
setKey: {kind: 'count'}
}),
'count'),
...sliceAggregationColumns.map(
t =>
m('li',
m(ColumnSetCheckbox, {
get: this.selectedAggregations,
set: Actions.setPivotTableAggregationSelected,
setKey: {kind: 'regular', table: 'slice', column: t},
}),
t)),
...threadSliceAggregationColumns.map(
t =>
m('li',
m(ColumnSetCheckbox, {
get: this.selectedAggregations,
set: Actions.setPivotTableAggregationSelected,
setKey:
{kind: 'regular', table: 'thread_slice', column: t},
}),
`thread_slice.${t}`)))),
this.renderQuery(attrs));
}
}