• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {assertTrue, assertUnreachable} from '../base/logging';
16import {
17  Selection,
18  Area,
19  SelectionOpts,
20  SelectionManager,
21  TrackEventSelection,
22  AreaSelectionTab,
23} from '../public/selection';
24import {TimeSpan} from '../base/time';
25import {raf} from './raf_scheduler';
26import {exists, getOrCreate} from '../base/utils';
27import {TrackManagerImpl} from './track_manager';
28import {Engine} from '../trace_processor/engine';
29import {ScrollHelper} from './scroll_helper';
30import {NoteManagerImpl} from './note_manager';
31import {SearchResult} from '../public/search';
32import {AsyncLimiter} from '../base/async_limiter';
33import m from 'mithril';
34import {SerializedSelection} from './state_serialization_schema';
35import {showModal} from '../widgets/modal';
36import {NUM, SqlValue, UNKNOWN} from '../trace_processor/query_result';
37import {SourceDataset, UnionDataset} from '../trace_processor/dataset';
38import {Track} from '../public/track';
39
40interface SelectionDetailsPanel {
41  isLoading: boolean;
42  render(): m.Children;
43  serializatonState(): unknown;
44}
45
46// There are two selection-related states in this class.
47// 1. _selection: This is the "input" / locator of the selection, what other
48//    parts of the codebase specify (e.g., a tuple of trackUri + eventId) to say
49//    "please select this object if it exists".
50// 2. _selected{Slice,ThreadState}: This is the resolved selection, that is, the
51//    rich details about the object that has been selected. If the input
52//    `_selection` is valid, this is filled in the near future. Doing so
53//    requires querying the SQL engine, which is an async operation.
54export class SelectionManagerImpl implements SelectionManager {
55  private readonly detailsPanelLimiter = new AsyncLimiter();
56  private _selection: Selection = {kind: 'empty'};
57  private readonly detailsPanels = new WeakMap<
58    Selection,
59    SelectionDetailsPanel
60  >();
61  public readonly areaSelectionTabs: AreaSelectionTab[] = [];
62
63  constructor(
64    private readonly engine: Engine,
65    private trackManager: TrackManagerImpl,
66    private noteManager: NoteManagerImpl,
67    private scrollHelper: ScrollHelper,
68    private onSelectionChange: (s: Selection, opts: SelectionOpts) => void,
69  ) {}
70
71  clear(): void {
72    this.setSelection({kind: 'empty'});
73  }
74
75  async selectTrackEvent(
76    trackUri: string,
77    eventId: number,
78    opts?: SelectionOpts,
79  ) {
80    this.selectTrackEventInternal(trackUri, eventId, opts);
81  }
82
83  selectTrack(trackUri: string, opts?: SelectionOpts) {
84    this.setSelection({kind: 'track', trackUri}, opts);
85  }
86
87  selectNote(args: {id: string}, opts?: SelectionOpts) {
88    this.setSelection(
89      {
90        kind: 'note',
91        id: args.id,
92      },
93      opts,
94    );
95  }
96
97  selectArea(area: Area, opts?: SelectionOpts): void {
98    const {start, end} = area;
99    assertTrue(start <= end);
100
101    // In the case of area selection, the caller provides a list of trackUris.
102    // However, all the consumers want to access the resolved Tracks. Rather
103    // than delegating this to the various consumers, we resolve them now once
104    // and for all and place them in the selection object.
105    const tracks = [];
106    for (const uri of area.trackUris) {
107      const trackDescr = this.trackManager.getTrack(uri);
108      if (trackDescr === undefined) continue;
109      tracks.push(trackDescr);
110    }
111
112    this.setSelection(
113      {
114        ...area,
115        kind: 'area',
116        tracks,
117      },
118      opts,
119    );
120  }
121
122  deserialize(serialized: SerializedSelection | undefined) {
123    if (serialized === undefined) {
124      return;
125    }
126    this.deserializeInternal(serialized);
127  }
128
129  private async deserializeInternal(serialized: SerializedSelection) {
130    try {
131      switch (serialized.kind) {
132        case 'TRACK_EVENT':
133          await this.selectTrackEventInternal(
134            serialized.trackKey,
135            parseInt(serialized.eventId),
136            undefined,
137            serialized.detailsPanel,
138          );
139          break;
140        case 'AREA':
141          this.selectArea({
142            start: serialized.start,
143            end: serialized.end,
144            trackUris: serialized.trackUris,
145          });
146      }
147    } catch (ex) {
148      showModal({
149        title: 'Failed to restore the selected event',
150        content: m(
151          'div',
152          m(
153            'p',
154            `Due to a version skew between the version of the UI the trace was
155             shared with and the version of the UI you are using, we were
156             unable to restore the selected event.`,
157          ),
158          m(
159            'p',
160            `These backwards incompatible changes are very rare but is in some
161             cases unavoidable. We apologise for the inconvenience.`,
162          ),
163        ),
164        buttons: [
165          {
166            text: 'Continue',
167            primary: true,
168          },
169        ],
170      });
171    }
172  }
173
174  toggleTrackAreaSelection(trackUri: string) {
175    const curSelection = this._selection;
176    if (curSelection.kind !== 'area') return;
177
178    let trackUris = curSelection.trackUris.slice();
179    if (!trackUris.includes(trackUri)) {
180      trackUris.push(trackUri);
181    } else {
182      trackUris = trackUris.filter((t) => t !== trackUri);
183    }
184    this.selectArea({
185      ...curSelection,
186      trackUris,
187    });
188  }
189
190  toggleGroupAreaSelection(trackUris: string[]) {
191    const curSelection = this._selection;
192    if (curSelection.kind !== 'area') return;
193
194    const allTracksSelected = trackUris.every((t) =>
195      curSelection.trackUris.includes(t),
196    );
197
198    let newTrackUris: string[];
199    if (allTracksSelected) {
200      // Deselect all tracks in the list
201      newTrackUris = curSelection.trackUris.filter(
202        (t) => !trackUris.includes(t),
203      );
204    } else {
205      newTrackUris = curSelection.trackUris.slice();
206      trackUris.forEach((t) => {
207        if (!newTrackUris.includes(t)) {
208          newTrackUris.push(t);
209        }
210      });
211    }
212    this.selectArea({
213      ...curSelection,
214      trackUris: newTrackUris,
215    });
216  }
217
218  get selection(): Selection {
219    return this._selection;
220  }
221
222  getDetailsPanelForSelection(): SelectionDetailsPanel | undefined {
223    return this.detailsPanels.get(this._selection);
224  }
225
226  async resolveSqlEvent(
227    sqlTableName: string,
228    id: number,
229  ): Promise<{eventId: number; trackUri: string} | undefined> {
230    // This function:
231    // 1. Find the list of tracks whose rootTableName is the same as the one we
232    //    are looking for
233    // 2. Groups them by their filter column - i.e. utid, cpu, or track_id.
234    // 3. Builds a map of which of these column values match which track.
235    // 4. Run one query per group, reading out the filter column value, and
236    //    looking up the originating track in the map.
237    // One flaw of this approach is that.
238    const groups = new Map<string, [SourceDataset, Track][]>();
239    const tracksWithNoFilter: [SourceDataset, Track][] = [];
240
241    this.trackManager
242      .getAllTracks()
243      .filter((track) => track.track.rootTableName === sqlTableName)
244      .map((track) => {
245        const dataset = track.track.getDataset?.();
246        if (!dataset) return undefined;
247        return [dataset, track] as const;
248      })
249      .filter(exists)
250      .filter(([dataset]) => dataset.implements({id: NUM}))
251      .forEach(([dataset, track]) => {
252        const col = dataset.filter?.col;
253        if (col) {
254          const existingGroup = getOrCreate(groups, col, () => []);
255          existingGroup.push([dataset, track]);
256        } else {
257          tracksWithNoFilter.push([dataset, track]);
258        }
259      });
260
261    // Run one query per no-filter track. This is the only way we can reliably
262    // keep track of which track the event belonged to.
263    for (const [dataset, track] of tracksWithNoFilter) {
264      const query = `select id from (${dataset.query()}) where id = ${id}`;
265      const result = await this.engine.query(query);
266      if (result.numRows() > 0) {
267        return {eventId: id, trackUri: track.uri};
268      }
269    }
270
271    for (const [colName, values] of groups) {
272      // Build a map of the values -> track uri
273      const map = new Map<SqlValue, string>();
274      values.forEach(([dataset, track]) => {
275        const filter = dataset.filter;
276        if (filter) {
277          if ('eq' in filter) map.set(filter.eq, track.uri);
278          if ('in' in filter) filter.in.forEach((v) => map.set(v, track.uri));
279        }
280      });
281
282      const datasets = values.map(([dataset]) => dataset);
283      const union = new UnionDataset(datasets).optimize();
284
285      // Make sure to include the filter value in the schema.
286      const schema = {...union.schema, [colName]: UNKNOWN};
287      const query = `select * from (${union.query(schema)}) where id = ${id}`;
288      const result = await this.engine.query(query);
289
290      const row = result.iter(union.schema);
291      const value = row.get(colName);
292
293      let trackUri = map.get(value);
294
295      // If that didn't work, try converting the value to a number if it's a
296      // bigint. Unless specified as a NUM type, any integers on the wire will
297      // be parsed as a bigint to avoid losing precision.
298      if (trackUri === undefined && typeof value === 'bigint') {
299        trackUri = map.get(Number(value));
300      }
301
302      if (trackUri) {
303        return {eventId: id, trackUri};
304      }
305    }
306
307    return undefined;
308  }
309
310  selectSqlEvent(sqlTableName: string, id: number, opts?: SelectionOpts): void {
311    this.resolveSqlEvent(sqlTableName, id).then((selection) => {
312      selection &&
313        this.selectTrackEvent(selection.trackUri, selection.eventId, opts);
314    });
315  }
316
317  private setSelection(selection: Selection, opts?: SelectionOpts) {
318    this._selection = selection;
319    this.onSelectionChange(selection, opts ?? {});
320
321    if (opts?.scrollToSelection) {
322      this.scrollToCurrentSelection();
323    }
324  }
325
326  selectSearchResult(searchResult: SearchResult) {
327    const {source, eventId, trackUri} = searchResult;
328    if (eventId === undefined) {
329      return;
330    }
331    switch (source) {
332      case 'track':
333        this.selectTrack(trackUri, {
334          clearSearch: false,
335          scrollToSelection: true,
336        });
337        break;
338      case 'cpu':
339        this.selectSqlEvent('sched_slice', eventId, {
340          clearSearch: false,
341          scrollToSelection: true,
342          switchToCurrentSelectionTab: true,
343        });
344        break;
345      case 'log':
346        this.selectSqlEvent('android_logs', eventId, {
347          clearSearch: false,
348          scrollToSelection: true,
349          switchToCurrentSelectionTab: true,
350        });
351        break;
352      case 'slice':
353        // Search results only include slices from the slice table for now.
354        // When we include annotations we need to pass the correct table.
355        this.selectSqlEvent('slice', eventId, {
356          clearSearch: false,
357          scrollToSelection: true,
358          switchToCurrentSelectionTab: true,
359        });
360        break;
361      case 'event':
362        this.selectTrackEvent(trackUri, eventId, {
363          clearSearch: false,
364          scrollToSelection: true,
365          switchToCurrentSelectionTab: true,
366        });
367        break;
368      default:
369        assertUnreachable(source);
370    }
371  }
372
373  scrollToCurrentSelection() {
374    const uri = (() => {
375      switch (this.selection.kind) {
376        case 'track_event':
377        case 'track':
378          return this.selection.trackUri;
379        // TODO(stevegolton): Handle scrolling to area and note selections.
380        default:
381          return undefined;
382      }
383    })();
384    const range = this.findTimeRangeOfSelection();
385    this.scrollHelper.scrollTo({
386      time: range ? {...range} : undefined,
387      track: uri ? {uri: uri, expandGroup: true} : undefined,
388    });
389  }
390
391  private async selectTrackEventInternal(
392    trackUri: string,
393    eventId: number,
394    opts?: SelectionOpts,
395    serializedDetailsPanel?: unknown,
396  ) {
397    const details = await this.trackManager
398      .getTrack(trackUri)
399      ?.track.getSelectionDetails?.(eventId);
400
401    if (!exists(details)) {
402      throw new Error('Unable to resolve selection details');
403    }
404
405    const selection: TrackEventSelection = {
406      ...details,
407      kind: 'track_event',
408      trackUri,
409      eventId,
410    };
411    this.createTrackEventDetailsPanel(selection, serializedDetailsPanel);
412    this.setSelection(selection, opts);
413  }
414
415  private createTrackEventDetailsPanel(
416    selection: TrackEventSelection,
417    serializedState: unknown,
418  ) {
419    const td = this.trackManager.getTrack(selection.trackUri);
420    if (!td) {
421      return;
422    }
423    const panel = td.track.detailsPanel?.(selection);
424    if (!panel) {
425      return;
426    }
427
428    if (panel.serialization && serializedState !== undefined) {
429      const res = panel.serialization.schema.safeParse(serializedState);
430      if (res.success) {
431        panel.serialization.state = res.data;
432      }
433    }
434
435    const detailsPanel: SelectionDetailsPanel = {
436      render: () => panel.render(),
437      serializatonState: () => panel.serialization?.state,
438      isLoading: true,
439    };
440    // Associate this details panel with this selection object
441    this.detailsPanels.set(selection, detailsPanel);
442
443    this.detailsPanelLimiter.schedule(async () => {
444      await panel?.load?.(selection);
445      detailsPanel.isLoading = false;
446      raf.scheduleFullRedraw();
447    });
448  }
449
450  findTimeRangeOfSelection(): TimeSpan | undefined {
451    const sel = this.selection;
452    if (sel.kind === 'area') {
453      return new TimeSpan(sel.start, sel.end);
454    } else if (sel.kind === 'note') {
455      const selectedNote = this.noteManager.getNote(sel.id);
456      if (selectedNote !== undefined) {
457        const kind = selectedNote.noteType;
458        switch (kind) {
459          case 'SPAN':
460            return new TimeSpan(selectedNote.start, selectedNote.end);
461          case 'DEFAULT':
462            // A TimeSpan where start === end is treated as an instant event.
463            return new TimeSpan(selectedNote.timestamp, selectedNote.timestamp);
464          default:
465            assertUnreachable(kind);
466        }
467      }
468    } else if (sel.kind === 'track_event') {
469      switch (sel.dur) {
470        case undefined:
471        case -1n:
472          // Events without a duration or with duration -1 (DNF) slices are just
473          // treated as if they were instant events.
474          return TimeSpan.fromTimeAndDuration(sel.ts, 0n);
475        default:
476          return TimeSpan.fromTimeAndDuration(sel.ts, sel.dur);
477      }
478    }
479
480    return undefined;
481  }
482
483  registerAreaSelectionTab(tab: AreaSelectionTab): void {
484    this.areaSelectionTabs.push(tab);
485  }
486}
487