blob: 86443019492fd77a125f1a7e109a5561e91ee495 [file] [log] [blame]
// Copyright (C) 2024 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 {assertTrue, assertUnreachable} from '../base/logging';
import {
Selection,
Area,
SelectionOpts,
SelectionManager,
AreaSelectionAggregator,
SqlSelectionResolver,
TrackEventSelection,
} from '../public/selection';
import {TimeSpan} from '../base/time';
import {raf} from './raf_scheduler';
import {exists} from '../base/utils';
import {TrackManagerImpl} from './track_manager';
import {Engine} from '../trace_processor/engine';
import {ScrollHelper} from './scroll_helper';
import {NoteManagerImpl} from './note_manager';
import {SearchResult} from '../public/search';
import {SelectionAggregationManager} from './selection_aggregation_manager';
import {AsyncLimiter} from '../base/async_limiter';
import m from 'mithril';
import {SerializedSelection} from './state_serialization_schema';
const INSTANT_FOCUS_DURATION = 1n;
const INCOMPLETE_SLICE_DURATION = 30_000n;
interface SelectionDetailsPanel {
isLoading: boolean;
render(): m.Children;
serializatonState(): unknown;
}
// There are two selection-related states in this class.
// 1. _selection: This is the "input" / locator of the selection, what other
// parts of the codebase specify (e.g., a tuple of trackUri + eventId) to say
// "please select this object if it exists".
// 2. _selected{Slice,ThreadState}: This is the resolved selection, that is, the
// rich details about the object that has been selected. If the input
// `_selection` is valid, this is filled in the near future. Doing so
// requires querying the SQL engine, which is an async operation.
export class SelectionManagerImpl implements SelectionManager {
private readonly detailsPanelLimiter = new AsyncLimiter();
private _selection: Selection = {kind: 'empty'};
private _aggregationManager: SelectionAggregationManager;
// Incremented every time _selection changes.
private readonly selectionResolvers = new Array<SqlSelectionResolver>();
private readonly detailsPanels = new WeakMap<
Selection,
SelectionDetailsPanel
>();
constructor(
engine: Engine,
private trackManager: TrackManagerImpl,
private noteManager: NoteManagerImpl,
private scrollHelper: ScrollHelper,
private onSelectionChange: (s: Selection, opts: SelectionOpts) => void,
) {
this._aggregationManager = new SelectionAggregationManager(
engine.getProxy('SelectionAggregationManager'),
);
}
registerAreaSelectionAggreagtor(aggr: AreaSelectionAggregator): void {
this._aggregationManager.registerAggregator(aggr);
}
clear(): void {
this.setSelection({kind: 'empty'});
}
async selectTrackEvent(
trackUri: string,
eventId: number,
opts?: SelectionOpts,
) {
this.selectTrackEventInternal(trackUri, eventId, opts);
}
selectTrack(trackUri: string, opts?: SelectionOpts) {
this.setSelection({kind: 'track', trackUri}, opts);
}
selectNote(args: {id: string}, opts?: SelectionOpts) {
this.setSelection(
{
kind: 'note',
id: args.id,
},
opts,
);
}
selectArea(area: Area, opts?: SelectionOpts): void {
const {start, end} = area;
assertTrue(start <= end);
// In the case of area selection, the caller provides a list of trackUris.
// However, all the consumer want to access the resolved TrackDescriptor.
// Rather than delegating this to the various consumers, we resolve them
// now once and for all and place them in the selection object.
const tracks = [];
for (const uri of area.trackUris) {
const trackDescr = this.trackManager.getTrack(uri);
if (trackDescr === undefined) continue;
tracks.push(trackDescr);
}
this.setSelection(
{
...area,
kind: 'area',
tracks,
},
opts,
);
}
deserialize(serialized: SerializedSelection | undefined) {
if (serialized === undefined) {
return;
}
switch (serialized.kind) {
case 'TRACK_EVENT':
this.selectTrackEventInternal(
serialized.trackKey,
parseInt(serialized.eventId),
undefined,
serialized.detailsPanel,
);
break;
case 'AREA':
this.selectArea({
start: serialized.start,
end: serialized.end,
trackUris: serialized.trackUris,
});
}
}
toggleTrackAreaSelection(trackUri: string) {
const curSelection = this._selection;
if (curSelection.kind !== 'area') return;
let trackUris = curSelection.trackUris.slice();
if (!trackUris.includes(trackUri)) {
trackUris.push(trackUri);
} else {
trackUris = trackUris.filter((t) => t !== trackUri);
}
this.selectArea({
...curSelection,
trackUris,
});
}
toggleGroupAreaSelection(trackUris: string[]) {
const curSelection = this._selection;
if (curSelection.kind !== 'area') return;
const allTracksSelected = trackUris.every((t) =>
curSelection.trackUris.includes(t),
);
let newTrackUris: string[];
if (allTracksSelected) {
// Deselect all tracks in the list
newTrackUris = curSelection.trackUris.filter(
(t) => !trackUris.includes(t),
);
} else {
newTrackUris = curSelection.trackUris.slice();
trackUris.forEach((t) => {
if (!newTrackUris.includes(t)) {
newTrackUris.push(t);
}
});
}
this.selectArea({
...curSelection,
trackUris: newTrackUris,
});
}
get selection(): Selection {
return this._selection;
}
getDetailsPanelForSelection(): SelectionDetailsPanel | undefined {
return this.detailsPanels.get(this._selection);
}
registerSqlSelectionResolver(resolver: SqlSelectionResolver): void {
this.selectionResolvers.push(resolver);
}
async resolveSqlEvent(
sqlTableName: string,
id: number,
): Promise<{eventId: number; trackUri: string} | undefined> {
const matchingResolvers = this.selectionResolvers.filter(
(r) => r.sqlTableName === sqlTableName,
);
for (const resolver of matchingResolvers) {
const result = await resolver.callback(id, sqlTableName);
if (result) {
// If we have multiple resolvers for the same table, just return the first one.
return result;
}
}
return undefined;
}
selectSqlEvent(sqlTableName: string, id: number, opts?: SelectionOpts): void {
this.resolveSqlEvent(sqlTableName, id).then((selection) => {
selection &&
this.selectTrackEvent(selection.trackUri, selection.eventId, opts);
});
}
private setSelection(selection: Selection, opts?: SelectionOpts) {
this._selection = selection;
this.onSelectionChange(selection, opts ?? {});
raf.scheduleFullRedraw();
if (opts?.scrollToSelection) {
this.scrollToCurrentSelection();
}
if (this._selection.kind === 'area') {
this._aggregationManager.aggregateArea(this._selection);
} else {
this._aggregationManager.clear();
}
}
selectSearchResult(searchResult: SearchResult) {
const {source, eventId, trackUri} = searchResult;
if (eventId === undefined) {
return;
}
switch (source) {
case 'track':
this.selectTrack(trackUri, {
clearSearch: false,
scrollToSelection: true,
});
break;
case 'cpu':
this.selectSqlEvent('sched_slice', eventId, {
clearSearch: false,
scrollToSelection: true,
switchToCurrentSelectionTab: true,
});
break;
case 'log':
// TODO(stevegolton): Get log selection working.
break;
case 'slice':
// Search results only include slices from the slice table for now.
// When we include annotations we need to pass the correct table.
this.selectSqlEvent('slice', eventId, {
clearSearch: false,
scrollToSelection: true,
switchToCurrentSelectionTab: true,
});
break;
default:
assertUnreachable(source);
}
}
scrollToCurrentSelection() {
const uri = (() => {
switch (this.selection.kind) {
case 'track_event':
case 'track':
return this.selection.trackUri;
// TODO(stevegolton): Handle scrolling to area and note selections.
default:
return undefined;
}
})();
const range = this.findFocusRangeOfSelection();
this.scrollHelper.scrollTo({
time: range ? {...range} : undefined,
track: uri ? {uri: uri, expandGroup: true} : undefined,
});
}
// Finds the time range range that we should actually focus on - using dummy
// values for instant and incomplete slices, so we don't end up super zoomed
// in.
private findFocusRangeOfSelection(): TimeSpan | undefined {
const sel = this.selection;
if (sel.kind === 'track_event') {
// The focus range of slices is different to that of the actual span
if (sel.dur === -1n) {
return TimeSpan.fromTimeAndDuration(sel.ts, INCOMPLETE_SLICE_DURATION);
} else if (sel.dur === 0n) {
return TimeSpan.fromTimeAndDuration(sel.ts, INSTANT_FOCUS_DURATION);
} else {
return TimeSpan.fromTimeAndDuration(sel.ts, sel.dur);
}
} else {
return this.findTimeRangeOfSelection();
}
}
private async selectTrackEventInternal(
trackUri: string,
eventId: number,
opts?: SelectionOpts,
serializedDetailsPanel?: unknown,
) {
const details = await this.trackManager
.getTrack(trackUri)
?.track.getSelectionDetails?.(eventId);
if (!exists(details)) {
throw new Error('Unable to resolve selection details');
}
const selection: TrackEventSelection = {
...details,
kind: 'track_event',
trackUri,
eventId,
};
this.createTrackEventDetailsPanel(selection, serializedDetailsPanel);
this.setSelection(selection, opts);
}
private createTrackEventDetailsPanel(
selection: TrackEventSelection,
serializedState: unknown,
) {
const td = this.trackManager.getTrack(selection.trackUri);
if (!td) {
return;
}
const panel = td.track.detailsPanel?.(selection);
if (!panel) {
return;
}
if (panel.serialization && serializedState !== undefined) {
const res = panel.serialization.schema.safeParse(serializedState);
if (res.success) {
panel.serialization.state = res.data;
}
}
const detailsPanel: SelectionDetailsPanel = {
render: () => panel.render(),
serializatonState: () => panel.serialization?.state,
isLoading: true,
};
// Associate this details panel with this selection object
this.detailsPanels.set(selection, detailsPanel);
this.detailsPanelLimiter.schedule(async () => {
await panel?.load?.(selection);
detailsPanel.isLoading = false;
raf.scheduleFullRedraw();
});
}
findTimeRangeOfSelection(): TimeSpan | undefined {
const sel = this.selection;
if (sel.kind === 'area') {
return new TimeSpan(sel.start, sel.end);
} else if (sel.kind === 'note') {
const selectedNote = this.noteManager.getNote(sel.id);
if (selectedNote !== undefined) {
const kind = selectedNote.noteType;
switch (kind) {
case 'SPAN':
return new TimeSpan(selectedNote.start, selectedNote.end);
case 'DEFAULT':
return TimeSpan.fromTimeAndDuration(
selectedNote.timestamp,
INSTANT_FOCUS_DURATION,
);
default:
assertUnreachable(kind);
}
}
} else if (sel.kind === 'track_event') {
return TimeSpan.fromTimeAndDuration(sel.ts, sel.dur);
}
return undefined;
}
get aggregation() {
return this._aggregationManager;
}
}