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 AreaSelectionAggregator, 22 SqlSelectionResolver, 23 TrackEventSelection, 24} from '../public/selection'; 25import {TimeSpan} from '../base/time'; 26import {raf} from './raf_scheduler'; 27import {exists} from '../base/utils'; 28import {TrackManagerImpl} from './track_manager'; 29import {Engine} from '../trace_processor/engine'; 30import {ScrollHelper} from './scroll_helper'; 31import {NoteManagerImpl} from './note_manager'; 32import {SearchResult} from '../public/search'; 33import {SelectionAggregationManager} from './selection_aggregation_manager'; 34import {AsyncLimiter} from '../base/async_limiter'; 35import m from 'mithril'; 36import {SerializedSelection} from './state_serialization_schema'; 37 38const INSTANT_FOCUS_DURATION = 1n; 39const INCOMPLETE_SLICE_DURATION = 30_000n; 40 41interface SelectionDetailsPanel { 42 isLoading: boolean; 43 render(): m.Children; 44 serializatonState(): unknown; 45} 46 47// There are two selection-related states in this class. 48// 1. _selection: This is the "input" / locator of the selection, what other 49// parts of the codebase specify (e.g., a tuple of trackUri + eventId) to say 50// "please select this object if it exists". 51// 2. _selected{Slice,ThreadState}: This is the resolved selection, that is, the 52// rich details about the object that has been selected. If the input 53// `_selection` is valid, this is filled in the near future. Doing so 54// requires querying the SQL engine, which is an async operation. 55export class SelectionManagerImpl implements SelectionManager { 56 private readonly detailsPanelLimiter = new AsyncLimiter(); 57 private _selection: Selection = {kind: 'empty'}; 58 private _aggregationManager: SelectionAggregationManager; 59 // Incremented every time _selection changes. 60 private readonly selectionResolvers = new Array<SqlSelectionResolver>(); 61 private readonly detailsPanels = new WeakMap< 62 Selection, 63 SelectionDetailsPanel 64 >(); 65 66 constructor( 67 engine: Engine, 68 private trackManager: TrackManagerImpl, 69 private noteManager: NoteManagerImpl, 70 private scrollHelper: ScrollHelper, 71 private onSelectionChange: (s: Selection, opts: SelectionOpts) => void, 72 ) { 73 this._aggregationManager = new SelectionAggregationManager( 74 engine.getProxy('SelectionAggregationManager'), 75 ); 76 } 77 78 registerAreaSelectionAggregator(aggr: AreaSelectionAggregator): void { 79 this._aggregationManager.registerAggregator(aggr); 80 } 81 82 clear(): void { 83 this.setSelection({kind: 'empty'}); 84 } 85 86 async selectTrackEvent( 87 trackUri: string, 88 eventId: number, 89 opts?: SelectionOpts, 90 ) { 91 this.selectTrackEventInternal(trackUri, eventId, opts); 92 } 93 94 selectTrack(trackUri: string, opts?: SelectionOpts) { 95 this.setSelection({kind: 'track', trackUri}, opts); 96 } 97 98 selectNote(args: {id: string}, opts?: SelectionOpts) { 99 this.setSelection( 100 { 101 kind: 'note', 102 id: args.id, 103 }, 104 opts, 105 ); 106 } 107 108 selectArea(area: Area, opts?: SelectionOpts): void { 109 const {start, end} = area; 110 assertTrue(start <= end); 111 112 // In the case of area selection, the caller provides a list of trackUris. 113 // However, all the consumer want to access the resolved TrackDescriptor. 114 // Rather than delegating this to the various consumers, we resolve them 115 // now once and for all and place them in the selection object. 116 const tracks = []; 117 for (const uri of area.trackUris) { 118 const trackDescr = this.trackManager.getTrack(uri); 119 if (trackDescr === undefined) continue; 120 tracks.push(trackDescr); 121 } 122 123 this.setSelection( 124 { 125 ...area, 126 kind: 'area', 127 tracks, 128 }, 129 opts, 130 ); 131 } 132 133 deserialize(serialized: SerializedSelection | undefined) { 134 if (serialized === undefined) { 135 return; 136 } 137 switch (serialized.kind) { 138 case 'TRACK_EVENT': 139 this.selectTrackEventInternal( 140 serialized.trackKey, 141 parseInt(serialized.eventId), 142 undefined, 143 serialized.detailsPanel, 144 ); 145 break; 146 case 'AREA': 147 this.selectArea({ 148 start: serialized.start, 149 end: serialized.end, 150 trackUris: serialized.trackUris, 151 }); 152 } 153 } 154 155 toggleTrackAreaSelection(trackUri: string) { 156 const curSelection = this._selection; 157 if (curSelection.kind !== 'area') return; 158 159 let trackUris = curSelection.trackUris.slice(); 160 if (!trackUris.includes(trackUri)) { 161 trackUris.push(trackUri); 162 } else { 163 trackUris = trackUris.filter((t) => t !== trackUri); 164 } 165 this.selectArea({ 166 ...curSelection, 167 trackUris, 168 }); 169 } 170 171 toggleGroupAreaSelection(trackUris: string[]) { 172 const curSelection = this._selection; 173 if (curSelection.kind !== 'area') return; 174 175 const allTracksSelected = trackUris.every((t) => 176 curSelection.trackUris.includes(t), 177 ); 178 179 let newTrackUris: string[]; 180 if (allTracksSelected) { 181 // Deselect all tracks in the list 182 newTrackUris = curSelection.trackUris.filter( 183 (t) => !trackUris.includes(t), 184 ); 185 } else { 186 newTrackUris = curSelection.trackUris.slice(); 187 trackUris.forEach((t) => { 188 if (!newTrackUris.includes(t)) { 189 newTrackUris.push(t); 190 } 191 }); 192 } 193 this.selectArea({ 194 ...curSelection, 195 trackUris: newTrackUris, 196 }); 197 } 198 199 get selection(): Selection { 200 return this._selection; 201 } 202 203 getDetailsPanelForSelection(): SelectionDetailsPanel | undefined { 204 return this.detailsPanels.get(this._selection); 205 } 206 207 registerSqlSelectionResolver(resolver: SqlSelectionResolver): void { 208 this.selectionResolvers.push(resolver); 209 } 210 211 async resolveSqlEvent( 212 sqlTableName: string, 213 id: number, 214 ): Promise<{eventId: number; trackUri: string} | undefined> { 215 const matchingResolvers = this.selectionResolvers.filter( 216 (r) => r.sqlTableName === sqlTableName, 217 ); 218 219 for (const resolver of matchingResolvers) { 220 const result = await resolver.callback(id, sqlTableName); 221 if (result) { 222 // If we have multiple resolvers for the same table, just return the first one. 223 return result; 224 } 225 } 226 227 return undefined; 228 } 229 230 selectSqlEvent(sqlTableName: string, id: number, opts?: SelectionOpts): void { 231 this.resolveSqlEvent(sqlTableName, id).then((selection) => { 232 selection && 233 this.selectTrackEvent(selection.trackUri, selection.eventId, opts); 234 }); 235 } 236 237 private setSelection(selection: Selection, opts?: SelectionOpts) { 238 this._selection = selection; 239 this.onSelectionChange(selection, opts ?? {}); 240 raf.scheduleFullRedraw(); 241 242 if (opts?.scrollToSelection) { 243 this.scrollToCurrentSelection(); 244 } 245 246 if (this._selection.kind === 'area') { 247 this._aggregationManager.aggregateArea(this._selection); 248 } else { 249 this._aggregationManager.clear(); 250 } 251 } 252 253 selectSearchResult(searchResult: SearchResult) { 254 const {source, eventId, trackUri} = searchResult; 255 if (eventId === undefined) { 256 return; 257 } 258 switch (source) { 259 case 'track': 260 this.selectTrack(trackUri, { 261 clearSearch: false, 262 scrollToSelection: true, 263 }); 264 break; 265 case 'cpu': 266 this.selectSqlEvent('sched_slice', eventId, { 267 clearSearch: false, 268 scrollToSelection: true, 269 switchToCurrentSelectionTab: true, 270 }); 271 break; 272 case 'log': 273 // TODO(stevegolton): Get log selection working. 274 break; 275 case 'slice': 276 // Search results only include slices from the slice table for now. 277 // When we include annotations we need to pass the correct table. 278 this.selectSqlEvent('slice', eventId, { 279 clearSearch: false, 280 scrollToSelection: true, 281 switchToCurrentSelectionTab: true, 282 }); 283 break; 284 default: 285 assertUnreachable(source); 286 } 287 } 288 289 scrollToCurrentSelection() { 290 const uri = (() => { 291 switch (this.selection.kind) { 292 case 'track_event': 293 case 'track': 294 return this.selection.trackUri; 295 // TODO(stevegolton): Handle scrolling to area and note selections. 296 default: 297 return undefined; 298 } 299 })(); 300 const range = this.findFocusRangeOfSelection(); 301 this.scrollHelper.scrollTo({ 302 time: range ? {...range} : undefined, 303 track: uri ? {uri: uri, expandGroup: true} : undefined, 304 }); 305 } 306 307 // Finds the time range range that we should actually focus on - using dummy 308 // values for instant and incomplete slices, so we don't end up super zoomed 309 // in. 310 private findFocusRangeOfSelection(): TimeSpan | undefined { 311 const sel = this.selection; 312 if (sel.kind === 'track_event') { 313 // The focus range of slices is different to that of the actual span 314 if (sel.dur === -1n) { 315 return TimeSpan.fromTimeAndDuration(sel.ts, INCOMPLETE_SLICE_DURATION); 316 } else if (sel.dur === 0n) { 317 return TimeSpan.fromTimeAndDuration(sel.ts, INSTANT_FOCUS_DURATION); 318 } else { 319 return TimeSpan.fromTimeAndDuration(sel.ts, sel.dur); 320 } 321 } else { 322 return this.findTimeRangeOfSelection(); 323 } 324 } 325 326 private async selectTrackEventInternal( 327 trackUri: string, 328 eventId: number, 329 opts?: SelectionOpts, 330 serializedDetailsPanel?: unknown, 331 ) { 332 const details = await this.trackManager 333 .getTrack(trackUri) 334 ?.track.getSelectionDetails?.(eventId); 335 336 if (!exists(details)) { 337 throw new Error('Unable to resolve selection details'); 338 } 339 340 const selection: TrackEventSelection = { 341 ...details, 342 kind: 'track_event', 343 trackUri, 344 eventId, 345 }; 346 this.createTrackEventDetailsPanel(selection, serializedDetailsPanel); 347 this.setSelection(selection, opts); 348 } 349 350 private createTrackEventDetailsPanel( 351 selection: TrackEventSelection, 352 serializedState: unknown, 353 ) { 354 const td = this.trackManager.getTrack(selection.trackUri); 355 if (!td) { 356 return; 357 } 358 const panel = td.track.detailsPanel?.(selection); 359 if (!panel) { 360 return; 361 } 362 363 if (panel.serialization && serializedState !== undefined) { 364 const res = panel.serialization.schema.safeParse(serializedState); 365 if (res.success) { 366 panel.serialization.state = res.data; 367 } 368 } 369 370 const detailsPanel: SelectionDetailsPanel = { 371 render: () => panel.render(), 372 serializatonState: () => panel.serialization?.state, 373 isLoading: true, 374 }; 375 // Associate this details panel with this selection object 376 this.detailsPanels.set(selection, detailsPanel); 377 378 this.detailsPanelLimiter.schedule(async () => { 379 await panel?.load?.(selection); 380 detailsPanel.isLoading = false; 381 raf.scheduleFullRedraw(); 382 }); 383 } 384 385 findTimeRangeOfSelection(): TimeSpan | undefined { 386 const sel = this.selection; 387 if (sel.kind === 'area') { 388 return new TimeSpan(sel.start, sel.end); 389 } else if (sel.kind === 'note') { 390 const selectedNote = this.noteManager.getNote(sel.id); 391 if (selectedNote !== undefined) { 392 const kind = selectedNote.noteType; 393 switch (kind) { 394 case 'SPAN': 395 return new TimeSpan(selectedNote.start, selectedNote.end); 396 case 'DEFAULT': 397 return TimeSpan.fromTimeAndDuration( 398 selectedNote.timestamp, 399 INSTANT_FOCUS_DURATION, 400 ); 401 default: 402 assertUnreachable(kind); 403 } 404 } 405 } else if (sel.kind === 'track_event') { 406 return TimeSpan.fromTimeAndDuration(sel.ts, sel.dur); 407 } 408 409 return undefined; 410 } 411 412 get aggregation() { 413 return this._aggregationManager; 414 } 415} 416