mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
feat(devtools): add filtering functionality in the profiler
Allow to filter using a simple DSL: ``` search := query* query := `source:` \w+ | `duration:` [>|<|=|>=|<=]\d+ (ms)? ```
This commit is contained in:
parent
0b24fe08f5
commit
ff99b9bb03
9 changed files with 384 additions and 26 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Arguments = unknown> {
|
||||
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<DurationArgument> {
|
||||
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<SourceArgument> {
|
||||
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);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ export interface AppEntry<T> {
|
|||
export interface GraphNode {
|
||||
toolTip: string;
|
||||
style: any;
|
||||
frame: ProfilerFrame;
|
||||
}
|
||||
|
||||
export abstract class RecordFormatter<T> {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,29 @@
|
|||
<div class="controls">
|
||||
<mat-form-field *ngIf="record">
|
||||
<mat-select [value]="visualizationMode" (selectionChange)="this.changeVisualizationMode.emit($event.value)">
|
||||
<mat-option [value]="flameGraphMode">
|
||||
Flame graph
|
||||
</mat-option>
|
||||
<mat-option [value]="treeMapMode">
|
||||
Tree map
|
||||
</mat-option>
|
||||
<mat-option [value]="barGraphMode">
|
||||
Bar chart
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="visual-controls">
|
||||
<mat-form-field>
|
||||
<mat-label>Filter</mat-label>
|
||||
<input
|
||||
matInput
|
||||
class="filter-input"
|
||||
(keyup)="filter.emit($event.target.value)"
|
||||
placeholder="duration: >30 source: click"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field [class.hidden]="!record">
|
||||
<mat-select [value]="visualizationMode" (selectionChange)="this.changeVisualizationMode.emit($event.value)">
|
||||
<mat-option [value]="flameGraphMode">
|
||||
Flame graph
|
||||
</mat-option>
|
||||
<mat-option [value]="treeMapMode">
|
||||
Tree map
|
||||
</mat-option>
|
||||
<mat-option [value]="barGraphMode">
|
||||
Bar chart
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="details"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,19 @@
|
|||
height: 60px;
|
||||
}
|
||||
|
||||
.visual-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 80px;
|
||||
justify-content: space-around;
|
||||
margin-right: 25px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export class TimelineControlsComponent {
|
|||
@Output() changeVisualizationMode = new EventEmitter<VisualizationMode>();
|
||||
@Output() exportProfile = new EventEmitter<void>();
|
||||
@Output() toggleChangeDetection = new EventEmitter<boolean>();
|
||||
@Output() filter = new EventEmitter<string>();
|
||||
|
||||
flameGraphMode = VisualizationMode.FlameGraph;
|
||||
treeMapMode = VisualizationMode.TreeMap;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<p class="info" *ngIf="!hasFrames && visualizing">There's no information to show.</p>
|
||||
|
||||
<div style="margin: 10px; height: 100%">
|
||||
<div style="margin: 10px; height: 100%;">
|
||||
<ng-timeline-controls
|
||||
[class.hidden]="!hasFrames"
|
||||
[record]="frame"
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
(changeVisualizationMode)="visualizationMode = $event"
|
||||
(exportProfile)="exportProfile.emit($event)"
|
||||
(toggleChangeDetection)="changeDetection = $event"
|
||||
(filter)="setFilter($event)"
|
||||
></ng-timeline-controls>
|
||||
|
||||
<ng-frame-selector
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { Component, EventEmitter, Input, Output, OnDestroy } from '@angular/core
|
|||
import { ProfilerFrame } from 'protocol';
|
||||
import { GraphNode } from './record-formatter/record-formatter';
|
||||
import { Observable, Subscription, BehaviorSubject } from 'rxjs';
|
||||
import { share } from 'rxjs/operators';
|
||||
import { map, share } from 'rxjs/operators';
|
||||
import { mergeFrames } from './record-formatter/frame-merger';
|
||||
import { createFilter, Filter, noopFilter } from './filter';
|
||||
|
||||
export enum VisualizationMode {
|
||||
FlameGraph,
|
||||
|
|
@ -24,6 +25,7 @@ export class TimelineComponent implements OnDestroy {
|
|||
this._subscription.unsubscribe();
|
||||
}
|
||||
this._allRecords = [];
|
||||
this._filtered = [];
|
||||
this._maxDuration = -Infinity;
|
||||
this._subscription = data.subscribe({
|
||||
next: (frames: ProfilerFrame[]): void => {
|
||||
|
|
@ -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<GraphNode[]>([]);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue