blob: e8d1b3b74a0e55a4db053fdc1d68cd42935439b9 [file] [log] [blame] [edit]
// Copyright (C) 2025 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 {AsyncLimiter} from '../base/async_limiter';
import {time, Time} from '../base/time';
import {exists} from '../base/utils';
import {
AreaSelection,
areaSelectionsEqual,
AreaSelectionTab,
} from '../public/selection';
import {Trace} from '../public/trace';
import {Track} from '../public/track';
import {UnionDataset, Dataset, DatasetSchema} from '../trace_processor/dataset';
import {Engine} from '../trace_processor/engine';
import {EmptyState} from '../widgets/empty_state';
import {Spinner} from '../widgets/spinner';
import {shortUuid} from '../base/uuid';
import {AggregationPanel} from './aggregation_panel';
import {Column, Filter, Pivot} from './widgets/datagrid/model';
import {SQLDataSource} from './widgets/datagrid/sql_data_source';
import {createSimpleSchema} from './widgets/datagrid/sql_schema';
import {BarChartData, ColumnDef} from './aggregation';
import {
createPerfettoTable,
DisposableSqlEntity,
} from '../trace_processor/sql_utils';
import {DataGridApi} from './widgets/datagrid/datagrid';
import {DataGridExportButton} from './widgets/datagrid/export_button';
export interface AggregationData {
readonly tableName: string;
readonly barChartData?: ReadonlyArray<BarChartData>;
}
export interface Aggregation {
/**
* Creates a view for the aggregated data corresponding to the selected area.
*
* The dataset provided will be filtered based on the `trackKind` and `schema`
* if these properties are defined.
*
* @param engine - The query engine used to execute queries.
*/
prepareData(engine: Engine): Promise<AggregationData>;
}
export type AggregatePivotModel = Pivot & {
readonly columns: ReadonlyArray<ColumnDef>;
};
/**
* State that can be controlled externally for a DataGrid.
* All properties are optional - only those provided will be used in controlled
* mode, others will use uncontrolled (internal) state.
*/
export interface DataGridState {
readonly columns?: readonly Column[];
readonly filters?: readonly Filter[];
readonly pivot?: Pivot;
readonly onColumnsChanged?: (columns: readonly Column[]) => void;
readonly onFiltersChanged?: (filters: readonly Filter[]) => void;
readonly onPivotChanged?: (pivot: Pivot | undefined) => void;
}
export interface Aggregator {
readonly id: string;
// This function is called every time the area selection changes. The purpose
// of this function is to test whether this aggregator applies to the given
// area selection. If it does, it returns an aggregation object which gives
// further instructions on how to prepare the aggregation data.
//
// Aggregators are arranged this way because often the computation required to
// work out whether this aggregation applies is the same as the computation
// required to actually do the aggregation, so doing it like this means the
// prepareData() function returned can capture intermediate state avoiding
// having to do it again or awkwardly cache it somewhere in the aggregators
// local state.
probe(area: AreaSelection): Aggregation | undefined;
// Returns the name of this aggregation tag. Called every render cycle.
getTabName(): string;
// Return the column definitions for this aggregation panel. Called every
// render cycle.
getColumnDefinitions(): ColumnDef[] | AggregatePivotModel;
// Optional controls to render in the top bar of the aggregation panel.
renderTopbarControls?(): m.Children;
}
export function selectTracksAndGetDataset<T extends DatasetSchema>(
tracks: ReadonlyArray<Track>,
spec: T,
kind?: string,
) {
const datasets = tracks
.filter((t) => kind === undefined || t.tags?.kinds?.includes(kind))
.map((t) => t.renderer.getDataset?.())
.filter(exists)
.filter((d) => d.implements(spec));
if (datasets.length > 0) {
return UnionDataset.create(datasets);
} else {
return undefined;
}
}
/**
* For a given slice-like dataset (ts, dur and id cols), creates a new table
* that contains the slices intersected with a given interval.
*
* @param engine The engine to use to run queries.
* @param dataset The source dataset.
* @param start The start of the interval to intersect with.
* @param end The end of the interval to intersect with.
* @returns A disposable SQL entity representing the new table.
*/
export async function createIITable<
T extends {ts: bigint; dur: bigint; id: number},
>(
engine: Engine,
dataset: Dataset<T>,
start: time,
end: time,
): Promise<DisposableSqlEntity> {
const duration = Time.durationBetween(start, end);
if (duration <= 0n) {
// Return an empty dataset if the area selection's length is zero or less.
// II can't handle 0 or negative durations.
return createPerfettoTable({
engine,
as: `
SELECT *
FROM (${dataset.query()})
LIMIT 0
`,
});
}
// Materialize the source into a perfetto table first, dropping all incomplete
// slices.
//
// Note: the `ORDER BY id` is absolutely crucial. Removing this significantly
// worsens aggregation results compared to no materialization at all.
await using tempTable = await createPerfettoTable({
engine,
as: `
WITH slices AS (${dataset.query()})
SELECT * FROM slices
WHERE dur >= 0
ORDER BY id
`,
});
// Include all columns from the dataset except for `dur` and `ts`, which
// are replaced with the `dur` and `ts` from the interval intersection.
const otherCols = Object.keys(dataset.schema).filter(
(col) => col !== 'dur' && col !== 'ts',
);
await engine.query(`INCLUDE PERFETTO MODULE intervals.intersect`);
return await createPerfettoTable({
engine,
as: `
SELECT
${otherCols.map((c) => `slices.${c}`).join()},
ii.dur AS dur,
ii.ts AS ts
FROM _interval_intersect_single!(
${start},
${duration},
${tempTable.name}
) AS ii
JOIN ${tempTable.name} AS slices USING (id)
`,
});
}
interface DataGridModel {
readonly columns?: readonly Column[];
readonly pivot?: Pivot;
readonly filters: readonly Filter[];
}
/**
* Creates an adapter that adapts an old style aggregation to a new area
* selection sub-tab.
*/
export function createAggregationTab(
trace: Trace,
aggregator: Aggregator,
priority: number = 0,
): AreaSelectionTab {
const limiter = new AsyncLimiter();
let currentSelection: AreaSelection | undefined;
let aggregation: Aggregation | undefined;
let data: AggregationData | undefined;
let dataSource: SQLDataSource | undefined;
let dataGridApi: DataGridApi | undefined;
function createInitialState(): DataGridModel {
const initialModel = aggregator.getColumnDefinitions();
if ('groupBy' in initialModel) {
return {
pivot: {
groupBy: initialModel.groupBy,
aggregates: initialModel.aggregates,
},
filters: [],
};
} else {
// Generate initial columns for flat mode
const columns: readonly Column[] = initialModel.map((c) => ({
id: shortUuid(),
field: c.columnId,
aggregate: c.sum ? 'SUM' : undefined,
sort: c.sort,
}));
return {
columns,
filters: [],
};
}
}
// DataGrid state managed by the adapter
const initialDataModel: DataGridModel = createInitialState();
let dataModel: DataGridModel = initialDataModel;
return {
id: aggregator.id,
name: aggregator.getTabName(),
priority,
render(selection: AreaSelection) {
if (
currentSelection === undefined ||
!areaSelectionsEqual(selection, currentSelection)
) {
// Every time the selection changes, probe the aggregator to see if it
// supports this selection.
currentSelection = selection;
aggregation = aggregator.probe(selection);
// Kick off a new load of the data
limiter.schedule(async () => {
// Clear previous data to prevent queries against a stale or partially
// updated table/view while `prepareData` is running.
dataSource = undefined;
data = undefined;
if (aggregation) {
data = await aggregation?.prepareData(trace.engine);
dataSource = new SQLDataSource({
engine: trace.engine,
sqlSchema: createSimpleSchema(data.tableName),
rootSchemaName: 'query',
});
}
});
}
if (!aggregation) {
// Hides the tab
return undefined;
}
if (!dataSource) {
return {
isLoading: true,
content: m(
EmptyState,
{
icon: 'mediation',
title: 'Computing aggregation ...',
className: 'pf-aggregation-loading',
},
m(Spinner, {easing: true}),
),
};
}
const dataGridState: DataGridState = {
columns: dataModel.columns,
pivot: dataModel.pivot,
filters: dataModel.filters,
onColumnsChanged: (c) => {
dataModel = {...dataModel, columns: c};
},
onPivotChanged: (p) => {
dataModel = {...dataModel, pivot: p};
},
onFiltersChanged: (f) => {
dataModel = {...dataModel, filters: f};
},
};
return {
isLoading: false,
content: m(AggregationPanel, {
controls: aggregator.renderTopbarControls?.(),
key: aggregator.id,
dataSource,
columns: aggregator.getColumnDefinitions(),
barChartData: data?.barChartData,
onReady: (api: DataGridApi) => {
dataGridApi = api;
},
dataGridState,
onClearGridState: () => {
// Just wipe out the local data model to reset to initial state
dataModel = initialDataModel;
},
}),
buttons:
dataGridApi &&
m(DataGridExportButton, {onExportData: dataGridApi.exportData}),
};
},
};
}