diff --git a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/filter.spec.ts b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/filter.spec.ts new file mode 100644 index 00000000000..3a1ef41ab3b --- /dev/null +++ b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/filter.spec.ts @@ -0,0 +1,194 @@ +import { createFilter, parseFilter } from './filter'; + +describe('filtering', () => { + describe('parsing', () => { + it('should parse filters', () => { + const result = parseFilter(''); + expect(result).toEqual([]); + }); + + it('should return empty filter if the query is invalid', () => { + const result = parseFilter('foobar'); + expect(result.length).toBe(0); + }); + + it('should return proper result with duration', () => { + const result = parseFilter('duration : =2'); + expect(result.length).toBe(1); + expect(result[0][0]).toBe('duration'); + expect(result[0][1]).toEqual(['=', 2]); + }); + + it('should return proper result with source', () => { + const result = parseFilter('source : foobar'); + expect(result.length).toBe(1); + expect(result[0][0]).toBe('source'); + expect(result[0][1]).toEqual('foobar'); + }); + + it('should return proper result with composite filters', () => { + const result = parseFilter('source : foobar baz duration: >= 4200'); + expect(result.length).toBe(2); + expect(result[0][0]).toBe('source'); + expect(result[0][1]).toEqual('foobar baz'); + + expect(result[1][0]).toBe('duration'); + expect(result[1][1]).toEqual(['>=', 4200]); + }); + + it('should ignore invalid operators', () => { + const result1 = parseFilter('sorce : foobar baz duration: >= 4200'); + expect(result1.length).toBe(1); + expect(result1[0][0]).toBe('duration'); + expect(result1[0][1]).toEqual(['>=', 4200]); + + const result2 = parseFilter('sorce : foobar baz dration: >= 4200'); + expect(result2.length).toBe(0); + }); + + it('should consider decimal values in durations queries with a ms suffix', () => { + const result = parseFilter('source: foobar duration: >=42.42ms'); + expect(result.length).toBe(2); + expect(result[0][0]).toBe('source'); + expect(result[0][1]).toEqual('foobar'); + + expect(result[1][0]).toBe('duration'); + expect(result[1][1]).toEqual(['>=', 42.42]); + }); + }); + + describe('filtering', () => { + it('should filter results with a source query', () => { + const filter = createFilter('source:click'); + expect( + filter({ + frame: { + directives: [], + duration: 10, + source: 'click', + }, + style: {}, + toolTip: '', + }) + ).toBeTrue(); + + expect( + filter({ + frame: { + directives: [], + duration: 10, + source: 'mouseenter', + }, + style: {}, + toolTip: '', + }) + ).toBeFalse(); + }); + + it('should filter results with a duration query', () => { + const filter1 = createFilter('duration:>10ms'); + expect( + filter1({ + frame: { + directives: [], + duration: 10, + source: 'click', + }, + style: {}, + toolTip: '', + }) + ).toBeFalse(); + + expect( + filter1({ + frame: { + directives: [], + duration: 15, + source: 'mouseenter', + }, + style: {}, + toolTip: '', + }) + ).toBeTrue(); + + const filter2 = createFilter('duration:=10ms'); + expect( + filter2({ + frame: { + directives: [], + duration: 11, + source: 'mouseenter', + }, + style: {}, + toolTip: '', + }) + ).toBeFalse(); + expect( + filter2({ + frame: { + directives: [], + duration: 10, + source: 'mouseenter', + }, + style: {}, + toolTip: '', + }) + ).toBeTrue(); + }); + + it('should work with composite selectors', () => { + const filter = createFilter('duration:>10ms source: click'); + expect( + filter({ + frame: { + directives: [], + duration: 10, + source: 'click', + }, + style: {}, + toolTip: '', + }) + ).toBeFalse(); + + expect( + filter({ + frame: { + directives: [], + duration: 15, + source: 'mouseenter', + }, + style: {}, + toolTip: '', + }) + ).toBeFalse(); + + expect( + filter({ + frame: { + directives: [], + duration: 15, + source: 'click', + }, + style: {}, + toolTip: '', + }) + ).toBeTrue(); + }); + + it('should work with invalid arguments', () => { + const filter = createFilter('duration:>ms'); + + expect( + filter({ + frame: { + directives: [], + duration: 15, + source: 'click', + }, + style: {}, + toolTip: '', + }) + ).toBeTrue(); + }); + }); +}); diff --git a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/filter.ts b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/filter.ts new file mode 100644 index 00000000000..c9e0dbe93b0 --- /dev/null +++ b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/filter.ts @@ -0,0 +1,123 @@ +import { ProfilerFrame } from 'protocol'; +import { GraphNode } from './record-formatter/record-formatter'; + +export type Filter = (nodes: GraphNode) => boolean; + +export const noopFilter = (_: GraphNode) => true; + +interface Query { + readonly name: QueryType; + parseArguments(args: string[]): Arguments | undefined; + apply(node: ProfilerFrame, args: Arguments): boolean; +} + +type Operator = '>' | '<' | '=' | '<=' | '>='; + +const ops: { [operator in Operator]: (a: number, b: number) => boolean } = { + '>'(a: number, b: number): boolean { + return a > b; + }, + '<'(a: number, b: number): boolean { + return a < b; + }, + '='(a: number, b: number): boolean { + return a === b; + }, + '<='(a: number, b: number): boolean { + return a <= b; + }, + '>='(a: number, b: number): boolean { + return a >= b; + }, +}; + +type DurationArgument = [Operator, number]; +type SourceArgument = string; + +const enum QueryType { + Duration = 'duration', + Source = 'source', +} + +type QueryArguments = DurationArgument | SourceArgument; + +const operatorRe = /^(>=|<=|=|<|>|)/; +class DurationQuery implements Query { + readonly name = QueryType.Duration; + parseArguments([arg]: string[]): DurationArgument | undefined { + arg.trim(); + const operator = (arg.match(operatorRe) ?? [null])[0]; + if (!operator) { + return undefined; + } + const num = parseFloat(arg.replace(operatorRe, '').trim()); + if (isNaN(num)) { + return undefined; + } + return [operator as Operator, num] as DurationArgument; + } + + apply(node: ProfilerFrame, args: DurationArgument): boolean { + return ops[args[0]](node.duration, args[1]); + } +} + +class SourceQuery implements Query { + readonly name = QueryType.Source; + parseArguments([arg]: string[]): SourceArgument { + return arg; + } + + apply(node: ProfilerFrame, args: SourceArgument): boolean { + return node.source.indexOf(args) >= 0; + } +} + +const queryMap: { [query in QueryType]: Query } = [new DurationQuery(), new SourceQuery()].reduce((map, query) => { + map[query.name] = query; + return map; +}, {} as { [query in QueryType]: Query }); + +const queryRe = new RegExp(`(${QueryType.Duration}|${QueryType.Source})$`, 'g'); + +export const parseFilter = (query: string): [QueryType, QueryArguments][] => { + const parts = query.split(':').map((part) => part.trim()); + if (parts.length <= 1) { + return []; + } + const result: [QueryType, QueryArguments][] = []; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!queryRe.test(part)) { + continue; + } + const match = part.match(/(\w+)$/); + if (!match) { + continue; + } + const operator = queryMap[match[0] as QueryType]; + if (!operator) { + continue; + } + const operandString = parts[i + 1].replace(queryRe, '').trim(); + const operand = operator.parseArguments([operandString]) as QueryArguments; + if (!operand) { + continue; + } + result.push([operator.name, operand]); + } + return result; +}; + +export const createFilter = (query: string) => { + const queries = parseFilter(query); + return (frame: GraphNode) => { + return queries.every(([queryName, args]) => { + const currentQuery = queryMap[queryName]; + if (!currentQuery) { + return true; + } + return currentQuery.apply(frame.frame, args); + }); + }; +}; diff --git a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/record-formatter/record-formatter.ts b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/record-formatter/record-formatter.ts index 600c5f8f063..6b59f349643 100644 --- a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/record-formatter/record-formatter.ts +++ b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/record-formatter/record-formatter.ts @@ -13,6 +13,7 @@ export interface AppEntry { export interface GraphNode { toolTip: string; style: any; + frame: ProfilerFrame; } export abstract class RecordFormatter { diff --git a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline-controls/timeline-controls.component.html b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline-controls/timeline-controls.component.html index 19070be4404..9008c8f0f8c 100644 --- a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline-controls/timeline-controls.component.html +++ b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline-controls/timeline-controls.component.html @@ -1,17 +1,29 @@
- - - - Flame graph - - - Tree map - - - Bar chart - - - +
+ + Filter + + + + + + + Flame graph + + + Tree map + + + Bar chart + + + +
(); @Output() exportProfile = new EventEmitter(); @Output() toggleChangeDetection = new EventEmitter(); + @Output() filter = new EventEmitter(); flameGraphMode = VisualizationMode.FlameGraph; treeMapMode = VisualizationMode.TreeMap; diff --git a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.component.html b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.component.html index 6550777b681..9249ecdb95b 100644 --- a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.component.html +++ b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.component.html @@ -2,7 +2,7 @@

There's no information to show.

-
+
{ @@ -39,19 +41,26 @@ export class TimelineComponent implements OnDestroy { changeDetection = false; frame: ProfilerFrame | null = null; + private _filter: Filter = noopFilter; private _maxDuration = -Infinity; private _subscription: Subscription; private _allRecords: ProfilerFrame[] = []; + private _filtered: GraphNode[] = []; private _graphDataSubject = new BehaviorSubject([]); visualizing = false; - graphData$ = this._graphDataSubject.asObservable().pipe(share()); + graphData$ = this._graphDataSubject.pipe( + share(), + map((nodes) => { + return (this._filtered = nodes.filter((node) => this._filter(node))); + }) + ); selectFrames({ indexes }: { indexes: number[] }): void { - this.frame = mergeFrames(indexes.map((index) => this._allRecords[index])); + this.frame = mergeFrames(indexes.map((index) => this._filtered[index].frame)); } get hasFrames(): boolean { - return this._allRecords.length > 0; + return this._graphDataSubject.value.length > 0; } estimateFrameRate(timeSpent: number): number { @@ -59,6 +68,11 @@ export class TimelineComponent implements OnDestroy { return Math.floor(60 / 2 ** multiplier); } + setFilter(filter: string): void { + this._filter = createFilter(filter); + this._graphDataSubject.next(this._graphDataSubject.value); + } + getColorByFrameRate(framerate: number): string { if (framerate >= 60) { return '#d6f0d1'; @@ -104,13 +118,10 @@ export class TimelineComponent implements OnDestroy { return this._allRecords.map((r) => this._getBarStyles(r, multiplicationFactor)); } - private _getBarStyles( - record: ProfilerFrame, - multiplicationFactor: number - ): { style: { [key: string]: string }; toolTip: string } { - const height = record.duration * multiplicationFactor; + private _getBarStyles(frame: ProfilerFrame, multiplicationFactor: number): GraphNode { + const height = frame.duration * multiplicationFactor; const colorPercentage = Math.max(10, Math.round((height / MAX_HEIGHT) * 100)); - const backgroundColor = this.getColorByFrameRate(this.estimateFrameRate(record.duration)); + const backgroundColor = this.getColorByFrameRate(this.estimateFrameRate(frame.duration)); const style = { 'background-image': `-webkit-linear-gradient(bottom, ${backgroundColor} ${colorPercentage}%, transparent ${colorPercentage}%)`, @@ -119,7 +130,7 @@ export class TimelineComponent implements OnDestroy { width: '25px', height: '50px', }; - const toolTip = `${record.source} TimeSpent: ${record.duration.toFixed(3)}ms`; - return { style, toolTip }; + const toolTip = `${frame.source} TimeSpent: ${frame.duration.toFixed(3)}ms`; + return { style, toolTip, frame }; } } diff --git a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.module.ts b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.module.ts index 20a9d578aa3..4986791e126 100644 --- a/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.module.ts +++ b/projects/ng-devtools/src/lib/devtools-tabs/profiler/recording/timeline/timeline.module.ts @@ -17,6 +17,7 @@ import { RecordingDialogComponent } from './recording-modal/recording-dialog/rec import { RecordingModalComponent } from './recording-modal/recording-modal.component'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatDialogModule } from '@angular/material/dialog'; +import { MatInputModule } from '@angular/material/input'; @NgModule({ declarations: [ @@ -38,6 +39,7 @@ import { MatDialogModule } from '@angular/material/dialog'; MatTooltipModule, MatIconModule, MatCardModule, + MatInputModule, NgxFlamegraphModule, MatSelectModule, ],