blob: f6a6e00136a335d551c2ca0849008e508bf75311 [file] [log] [blame]
// 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} 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 {
Dataset,
DatasetSchema,
SourceDataset,
UnionDataset,
} from '../trace_processor/dataset';
import {Engine} from '../trace_processor/engine';
import {EmptyState} from '../widgets/empty_state';
import {Spinner} from '../widgets/spinner';
import {AggregationPanel} from './aggregation_panel';
import {DataGridDataSource} from './widgets/data_grid/common';
import {SQLDataSource} from './widgets/data_grid/sql_data_source';
import {BarChartData, ColumnDef, Sorting} from './aggregation';
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 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;
getTabName(): string;
getDefaultSorting(): Sorting;
getColumnDefinitions(): ColumnDef[];
/**
* Optionally override which component is used to render the data in the
* details panel. This can be used to define customize how the data is
* rendered.
*/
readonly PanelComponent?: PanelComponent;
}
export interface AggregationPanelAttrs {
readonly dataSource: DataGridDataSource;
readonly sorting: Sorting;
readonly columns: ReadonlyArray<ColumnDef>;
readonly barChartData?: ReadonlyArray<BarChartData>;
}
// Define a type for the expected props of the panel components so that a
// generic AggregationPanel can be specificed as an argument to
// createBaseAggregationToTabAdaptor()
export type PanelComponent = m.ComponentTypes<AggregationPanelAttrs>;
export interface Aggregator {
readonly id: string;
/**
* If set, this component will be used instead of the default AggregationPanel
* for displaying the aggregation. Use this to customize the look and feel of
* the rendered table.
*/
readonly Panel?: PanelComponent;
/**
* 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;
getTabName(): string;
getDefaultSorting(): Sorting;
getColumnDefinitions(): ColumnDef[];
}
export function selectTracksAndGetDataset<T extends DatasetSchema>(
tracks: ReadonlyArray<Track>,
spec: T,
kind?: string,
): Dataset<T> | undefined {
const datasets = tracks
.filter((t) => kind === undefined || t.tags?.kind === kind)
.map((t) => t.renderer.getDataset?.())
.filter(exists)
.filter((d) => d.implements(spec));
if (datasets.length > 0) {
// TODO(stevegolton): Avoid typecast in UnionDataset.
return new UnionDataset(datasets) as unknown as Dataset<T>;
} else {
return undefined;
}
}
export async function ii<T extends {ts: bigint; dur: bigint; id: number}>(
engine: Engine,
id: string,
dataset: Dataset<T>,
area: AreaSelection,
): Promise<Dataset<T>> {
const duration = Time.durationBetween(area.start, area.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 new SourceDataset({
src: `
SELECT * FROM (${dataset.query()})
LIMIT 0
`,
schema: dataset.schema,
});
}
// Materialize the source into a perfetto table first.
// Note: the `ORDER BY id` is absolutely crucial. Removing this
// significantly worsens aggregation results compared to no
// materialization at all.
const tableName = `__ii_${id}`;
await engine.query(`
CREATE OR REPLACE PERFETTO TABLE ${tableName} AS
${dataset.query()}
ORDER BY id
`);
// Pass the interval intersect to the aggregator.
await engine.query('INCLUDE PERFETTO MODULE viz.aggregation');
const iiDataset = new SourceDataset({
src: `
SELECT
ii_dur AS dur,
*
FROM _intersect_slices!(
${area.start},
${duration},
${tableName}
)
`,
schema: dataset.schema,
});
return iiDataset;
}
/**
* 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 barChartData: ReadonlyArray<BarChartData> | undefined;
let dataSource: DataGridDataSource | undefined;
return {
id: aggregator.id,
name: aggregator.getTabName(),
priority,
render(selection: AreaSelection) {
if (
currentSelection === undefined ||
!areaSelectionsEqual(selection, currentSelection)
) {
currentSelection = selection;
aggregation = aggregator.probe(selection);
// Kick off a new load of the data
limiter.schedule(async () => {
if (aggregation) {
const data = await aggregation?.prepareData(trace.engine);
dataSource = new SQLDataSource(trace.engine, data.tableName);
barChartData = data.barChartData;
}
});
}
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 PanelComponent = aggregator.Panel ?? AggregationPanel;
return {
isLoading: false,
content: m(PanelComponent, {
key: aggregator.id,
dataSource,
columns: aggregator.getColumnDefinitions(),
sorting: aggregator.getDefaultSorting(),
barChartData,
}),
};
},
};
}