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