feat(devtools): introduce streamed visualization of the profiling data

This commit is contained in:
mgechev 2020-05-04 16:40:55 -07:00 committed by Minko Gechev
parent 7df0c4d3e9
commit ac2cd3757b
19 changed files with 256 additions and 138 deletions

View file

@ -15,11 +15,8 @@
<input type="file" (change)="importProfilerResults($event)" placeholder="Upload file" accept=".json" />
</span>
</p>
<p class="recording" [class.hidden]="state !== 'recording'">
<ng-recording-modal></ng-recording-modal>
</p>
<p class="visualization" *ngIf="state === 'visualizing'">
<ng-recording-timeline [records]="stream" (exportProfile)="exportProfilerResults()"> </ng-recording-timeline>
<p class="visualization" *ngIf="state !== 'idle'">
<ng-recording-timeline [stream]="stream" (exportProfile)="exportProfilerResults()"> </ng-recording-timeline>
</p>
</div>
</div>

View file

@ -1,9 +1,9 @@
import { Component, ViewChildren, QueryList, OnInit, OnDestroy } from '@angular/core';
import { RecordingModalComponent } from './recording/recording-modal/recording-modal.component';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { MessageBus, Events, ProfilerFrame } from 'protocol';
import { FileApiService } from '../../file-api-service';
import { MatDialog } from '@angular/material/dialog';
import { ProfilerImportDialogComponent } from './profiler-import-dialog/profiler-import-dialog.component';
import { Subject } from 'rxjs';
type State = 'idle' | 'recording' | 'visualizing';
@ -17,10 +17,10 @@ const PROFILER_VERSION = 1;
})
export class ProfilerComponent implements OnInit, OnDestroy {
state: State = 'idle';
stream: ProfilerFrame[] = [];
buffer: ProfilerFrame[] = [];
stream = new Subject<ProfilerFrame[]>();
@ViewChildren(RecordingModalComponent) recordingRef: QueryList<RecordingModalComponent>;
// We collect this buffer so we can have it available for export.
private _buffer: ProfilerFrame[] = [];
constructor(
private _fileApiService: FileApiService,
@ -30,23 +30,26 @@ export class ProfilerComponent implements OnInit, OnDestroy {
startRecording(): void {
this.state = 'recording';
this.recordingRef.forEach((r) => r.start());
this._messageBus.emit('startProfiling');
}
stopRecording(): void {
this.state = 'idle';
this.recordingRef.forEach((r) => r.stop());
this.state = 'visualizing';
this._messageBus.emit('stopProfiling');
this.stream.complete();
}
ngOnInit(): void {
this._messageBus.on('profilerResults', (remainingRecords) => {
this._profilerFinished(remainingRecords);
if (remainingRecords.duration > 0 && remainingRecords.source) {
this.stream.next([remainingRecords]);
this._buffer.push(remainingRecords);
}
});
this._messageBus.on('sendProfilerChunk', (chunkOfRecords: ProfilerFrame) => {
this.buffer.push(chunkOfRecords);
this.stream.next([chunkOfRecords]);
this._buffer.push(chunkOfRecords);
});
this._fileApiService.uploadedData.subscribe((importedFile) => {
@ -69,34 +72,27 @@ export class ProfilerComponent implements OnInit, OnDestroy {
processDataDialog.afterClosed().subscribe((result) => {
if (result) {
this._viewProfilerData(importedFile.stream);
this.state = 'visualizing';
this._buffer = importedFile.buffer;
setTimeout(() => this.stream.next(importedFile.buffer));
}
});
} else {
this._viewProfilerData(importedFile.stream);
this.state = 'visualizing';
this._buffer = importedFile.buffer;
setTimeout(() => this.stream.next(importedFile.buffer));
}
});
}
private _profilerFinished(remainingRecords: ProfilerFrame): void {
const flattenedBuffer = [].concat.apply([], this.buffer);
this._viewProfilerData([...flattenedBuffer, remainingRecords]);
this.buffer = [];
}
ngOnDestroy(): void {
this._fileApiService.uploadedData.unsubscribe();
}
private _viewProfilerData(stream: ProfilerFrame[]): void {
this.state = 'visualizing';
this.stream = stream;
}
exportProfilerResults(): void {
const fileToExport = {
version: PROFILER_VERSION,
stream: this.stream,
buffer: this._buffer,
};
this._fileApiService.saveObjectAsJSON(fileToExport);
}
@ -106,7 +102,8 @@ export class ProfilerComponent implements OnInit, OnDestroy {
}
discardRecording(): void {
this.stream = [];
this.stream = new Subject<ProfilerFrame[]>();
this.state = 'idle';
this._buffer = [];
}
}

View file

@ -1,28 +1,17 @@
import { NgModule } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { CommonModule } from '@angular/common';
import { MatSelectModule } from '@angular/material/select';
import { MatDialogModule } from '@angular/material/dialog';
import { FormsModule } from '@angular/forms';
import { ProfilerComponent } from './profiler.component';
import { RecordingModalComponent } from './recording/recording-modal/recording-modal.component';
import { TimelineModule } from './recording/timeline/timeline.module';
import { RecordingDialogComponent } from './recording/recording-modal/recording-dialog/recording-dialog.component';
import { MatButtonModule } from '@angular/material/button';
import { ProfilerImportDialogComponent } from './profiler-import-dialog/profiler-import-dialog.component';
@NgModule({
declarations: [ProfilerComponent, RecordingModalComponent, RecordingDialogComponent, ProfilerImportDialogComponent],
imports: [
CommonModule,
MatDialogModule,
MatSelectModule,
FormsModule,
MatProgressBarModule,
TimelineModule,
MatButtonModule,
],
declarations: [ProfilerComponent, ProfilerImportDialogComponent],
imports: [CommonModule, MatDialogModule, MatSelectModule, FormsModule, TimelineModule, MatButtonModule],
exports: [ProfilerComponent],
entryComponents: [ProfilerImportDialogComponent],
})

View file

@ -1,18 +1,19 @@
<div class="bar-graph-container">
<p class="txt-frames">{{ currentFrameIndex + 1 }}/{{ graphData.length }}</p>
<button mat-icon-button (click)="move.emit(-1)" [disabled]="currentFrameIndex == 0">
<p class="txt-frames">{{ currentFrameIndex + 1 }}/{{ (graphData$ | async)?.length }}</p>
<button mat-icon-button (click)="move.emit(-1)" [disabled]="currentFrameIndex <= 0">
<mat-icon>chevron_left</mat-icon>
</button>
<div #barContainer class="bar-container">
<cdk-virtual-scroll-viewport #barContainer orientation="horizontal" [itemSize]="itemWidth" class="bar-container">
<div
*ngFor="let d of graphData; let i = index"
[matTooltip]="d.toolTip"
*cdkVirtualFor="let d of graphData$ | async; let i = index"
[ngStyle]="d.style"
[class.selected]="i === currentFrameIndex"
(click)="selectFrame.emit(i)"
></div>
</div>
<button mat-icon-button (click)="move.emit(1)" [disabled]="currentFrameIndex == graphData.length - 1">
</cdk-virtual-scroll-viewport>
<button mat-icon-button (click)="move.emit(1)" [disabled]="currentFrameIndex >= (graphData$ | async)?.length - 1">
<mat-icon>chevron_right</mat-icon>
</button>
</div>

View file

@ -19,9 +19,14 @@
.bar-container {
max-width: calc(100vw - 150px);
display: flex;
align-items: baseline;
overflow-x: auto;
width: 100%;
height: 100%;
::ng-deep .cdk-virtual-scroll-content-wrapper {
display: flex;
}
&::-webkit-scrollbar {
display: none;

View file

@ -1,25 +1,67 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
import { GraphNode } from '../record-formatter/record-formatter';
import { Observable, Subscription } from 'rxjs';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
const ITEM_WIDTH = 29;
@Component({
selector: 'ng-frame-selector',
templateUrl: './frame-selector.component.html',
styleUrls: ['./frame-selector.component.scss'],
})
export class FrameSelectorComponent {
export class FrameSelectorComponent implements OnDestroy {
@ViewChild('barContainer') barContainer: ElementRef;
@Input() set currentFrame(value: number) {
this.currentFrameIndex = value;
this.barContainer?.nativeElement?.children?.[value]?.scrollIntoView({
behavior: 'auto',
block: 'end',
inline: 'nearest',
});
this._ensureVisible(value);
}
@Input() graphData: GraphNode[];
@Input() profilerFramesLength: number;
@Input() set graphData$(graphData: Observable<GraphNode[]>) {
this._graphData$ = graphData;
this._graphDataSubscription = this._graphData$.subscribe((items) =>
setTimeout(() => {
this.viewPort.scrollToIndex(items.length);
})
);
}
get graphData$(): Observable<GraphNode[]> {
return this._graphData$;
}
@Output() move = new EventEmitter<number>();
@Output() selectFrame = new EventEmitter<number>();
@ViewChild(CdkVirtualScrollViewport) viewPort: CdkVirtualScrollViewport;
currentFrameIndex: number;
get itemWidth(): number {
return ITEM_WIDTH;
}
private _graphData$: Observable<GraphNode[]>;
private _graphDataSubscription: Subscription;
ngOnDestroy(): void {
if (this._graphDataSubscription) {
this._graphDataSubscription.unsubscribe();
}
}
private _ensureVisible(index: number): void {
if (!this.viewPort) {
return;
}
const scrollParent = this.viewPort.elementRef.nativeElement;
// The left most point we see an element
const left = scrollParent.scrollLeft;
// That's the right most point we currently see an element.
const right = left + scrollParent.offsetWidth;
const itemLeft = index * this.itemWidth;
if (itemLeft < left) {
scrollParent.scrollTo({ left: itemLeft });
} else if (right < itemLeft + this.itemWidth) {
scrollParent.scrollTo({ left: itemLeft - scrollParent.offsetWidth + this.itemWidth });
}
}
}

View file

@ -1,5 +1,5 @@
<section class="main-wrapper">
<h2>Recording</h2>
<h2>Interact with your app to preview change detection</h2>
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</section>

View file

@ -1,3 +1,3 @@
<section *ngIf="visible" id="recorder-wrapper">
<section id="recorder-wrapper">
<ng-recording-dialog></ng-recording-dialog>
</section>

View file

@ -1,4 +1,4 @@
@import '../../../../../../../../node_modules/@angular/cdk/overlay-prebuilt.css';
@import '../../../../../../../../../node_modules/@angular/cdk/overlay-prebuilt.css';
:host {
overflow: hidden;

View file

@ -5,14 +5,4 @@ import { Component } from '@angular/core';
templateUrl: './recording-modal.component.html',
styleUrls: ['./recording-modal.component.scss'],
})
export class RecordingModalComponent {
visible = false;
stop(): void {
this.visible = false;
}
start(): void {
this.visible = true;
}
}
export class RecordingModalComponent {}

View file

@ -1,6 +1,6 @@
<div class="controls">
<button mat-stroked-button (click)="exportProfile.emit()">Export to JSON</button>
<mat-form-field>
<button *ngIf="!empty" mat-stroked-button (click)="exportProfile.emit()">Export to JSON</button>
<mat-form-field *ngIf="record">
<mat-select [value]="visualizationMode" (selectionChange)="this.changeVisualizationMode.emit($event.value)">
<mat-option [value]="flameGraphMode">
Flame graph
@ -14,12 +14,14 @@
</mat-select>
</mat-form-field>
<label *ngIf="estimatedFrameRate >= 60"> Time spent: {{ record.duration | number }} ms </label>
<label *ngIf="estimatedFrameRate >= 60 && record"> Time spent: {{ record?.duration | number }} ms </label>
<label [style.color]="frameColor" *ngIf="estimatedFrameRate < 60">
Time spent: {{ record.duration | number }} ms
<label [style.color]="frameColor" *ngIf="estimatedFrameRate < 60 && record">
Time spent: {{ record?.duration | number }} ms
</label>
<label [style.color]="frameColor" *ngIf="estimatedFrameRate < 60"> Frame rate: {{ estimatedFrameRate }} fps </label>
<label *ngIf="record.source">Source: {{ record.source }}</label>
<label [style.color]="frameColor" *ngIf="estimatedFrameRate < 60 && record">
Frame rate: {{ estimatedFrameRate }} fps
</label>
<label *ngIf="record?.source && record">Source: {{ record?.source }}</label>
</div>

View file

@ -1,3 +1,8 @@
:host {
height: 60px;
display: block;
}
.controls {
top: 0;
left: 300px;

View file

@ -8,10 +8,11 @@ import { ProfilerFrame } from 'protocol';
styleUrls: ['./timeline-controls.component.scss'],
})
export class TimelineControlsComponent {
@Input() record: ProfilerFrame;
@Input() record: ProfilerFrame | undefined;
@Input() estimatedFrameRate: number;
@Input() frameColor: string;
@Input() visualizationMode: VisualizationMode;
@Input() empty: boolean;
@Output() changeVisualizationMode = new EventEmitter<VisualizationMode>();
@Output() exportProfile = new EventEmitter<void>();

View file

@ -1,26 +1,34 @@
<ng-container *ngIf="profilerFrames.length > 0; else noRecords">
<ng-container *ngTemplateOutlet="recordView; context: { record: frame }"></ng-container>
</ng-container>
<ng-recording-modal *ngIf="!hasFrames && !visualizing"></ng-recording-modal>
<ng-template #noRecords>
Nothing was profiled
</ng-template>
<p class="info" *ngIf="!hasFrames && visualizing">
There's no information to show.
</p>
<ng-template let-record="record" #recordView>
<ng-timeline-controls
[frameColor]="getColorByFrameRate(estimateFrameRate(record.duration))"
[record]="record"
[estimatedFrameRate]="estimateFrameRate(record.duration)"
[visualizationMode]="visualizationMode"
(changeVisualizationMode)="visualizationMode = $event"
(exportProfile)="exportProfile.emit($event)"
></ng-timeline-controls>
<ng-frame-selector
(move)="move($event)"
(selectFrame)="selectFrame($event)"
[profilerFramesLength]="profilerFrames.length"
[graphData]="graphData"
[currentFrame]="currentFrameIndex"
></ng-frame-selector>
<ng-timeline-visualizer [visualizationMode]="visualizationMode" [frame]="record"></ng-timeline-visualizer>
</ng-template>
<ng-timeline-controls
[class.hidden]="!hasFrames"
[record]="frame"
[empty]="!hasFrames"
[frameColor]="getColorByFrameRate(estimateFrameRate(frame?.duration))"
[estimatedFrameRate]="estimateFrameRate(frame?.duration)"
[visualizationMode]="visualizationMode"
(changeVisualizationMode)="visualizationMode = $event"
(exportProfile)="exportProfile.emit($event)"
></ng-timeline-controls>
<ng-frame-selector
[class.hidden]="!hasFrames"
(move)="move($event)"
(selectFrame)="selectFrame($event)"
[graphData$]="graphData$"
[currentFrame]="currentFrameIndex"
></ng-frame-selector>
<p class="info" *ngIf="hasFrames && currentFrameIndex < 0">
Select a bar to preview a particular change detection cycle.
</p>
<ng-timeline-visualizer
*ngIf="hasFrames && currentFrameIndex >= 0"
[visualizationMode]="visualizationMode"
[frame]="frame"
></ng-timeline-visualizer>

View file

@ -4,3 +4,16 @@
height: 100%;
display: block;
}
.info {
font-size: 1.2em;
text-align: center;
}
.hidden {
/*
intentionally using visibility: hidden
display: none breaks the virtual scroll
*/
visibility: hidden;
}

View file

@ -1,6 +1,8 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
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';
export enum VisualizationMode {
FlameGraph,
@ -14,22 +16,41 @@ const MAX_HEIGHT = 50;
selector: 'ng-recording-timeline',
templateUrl: './timeline.component.html',
styleUrls: ['./timeline.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TimelineComponent {
@Input() set records(data: ProfilerFrame[]) {
this.profilerFrames = data.filter((frame) => frame.duration > 0);
this.renderBarChart(this.profilerFrames);
export class TimelineComponent implements OnDestroy {
@Input() set stream(data: Observable<ProfilerFrame[]>) {
if (this._subscription) {
this._subscription.unsubscribe();
}
this._allRecords = [];
this._maxDuration = -Infinity;
this._subscription = data.subscribe({
next: (frames: ProfilerFrame[]): void => {
this._processFrames(frames);
},
complete: (): void => {
this.visualizing = true;
},
});
}
@Input() profilerFrames: ProfilerFrame[] = [];
@Output() exportProfile = new EventEmitter<void>();
visualizationMode = VisualizationMode.BarGraph;
graphData: GraphNode[] = [];
currentFrameIndex = 0;
currentFrameIndex = -1;
private _maxDuration = -Infinity;
private _subscription: Subscription;
private _allRecords: ProfilerFrame[] = [];
private _graphDataSubject = new BehaviorSubject<GraphNode[]>([]);
visualizing = false;
graphData$ = this._graphDataSubject.asObservable().pipe(share());
get hasFrames(): boolean {
return this._allRecords.length > 0;
}
get frame(): ProfilerFrame {
return this.profilerFrames[this.currentFrameIndex];
return this._allRecords[this.currentFrameIndex];
}
estimateFrameRate(timeSpent: number): number {
@ -39,7 +60,7 @@ export class TimelineComponent {
move(value: number): void {
const newVal = this.currentFrameIndex + value;
if (newVal > -1 && newVal < this.profilerFrames.length) {
if (newVal > -1 && newVal < this._allRecords.length) {
this.currentFrameIndex = newVal;
}
}
@ -59,26 +80,59 @@ export class TimelineComponent {
return 'red';
}
renderBarChart(records: ProfilerFrame[]): void {
const maxValue = records.reduce((acc: number, frame: ProfilerFrame) => Math.max(acc, frame.duration), 0);
const multiplicationFactor = parseFloat((MAX_HEIGHT / maxValue).toFixed(2));
this.graphData = records.map((r) => {
const height = r.duration * multiplicationFactor;
const colorPercentage = Math.round((height / MAX_HEIGHT) * 100);
const backgroundColor = this.getColorByFrameRate(this.estimateFrameRate(r.duration));
ngOnDestroy(): void {
if (this._subscription) {
this._subscription.unsubscribe();
}
}
const style = {
'margin-left': '1px',
'margin-right': '1px',
background: `-webkit-linear-gradient(bottom, ${backgroundColor} ${colorPercentage}%, #f3f3f3 ${colorPercentage}%)`,
border: '1px solid #d0d0d0',
cursor: 'pointer',
'min-width': '25px',
width: '25px',
height: '50px',
};
const toolTip = `${r.source} TimeSpent: ${r.duration.toFixed(3)}ms`;
return { style, toolTip };
});
private _processFrames(frames: ProfilerFrame[]): void {
let regenerate = false;
for (const frame of frames) {
if (frame.duration >= this._maxDuration) {
regenerate = true;
}
this._allRecords.push(frame);
}
if (regenerate) {
this._graphDataSubject.next(this._generateBars());
return;
}
const multiplicationFactor = parseFloat((MAX_HEIGHT / this._maxDuration).toFixed(2));
frames.forEach((frame) => this._graphDataSubject.value.push(this._getBarStyles(frame, multiplicationFactor)));
// We need to pass a new reference, because the CDK virtual scroll
// has OnPush strategy, so it doesn't update the UI otherwise.
// If this turns out ot be a bottleneck, we can easily create an immutable reference.
this._graphDataSubject.next(this._graphDataSubject.value.slice());
}
private _generateBars(): GraphNode[] {
const maxValue = this._allRecords.reduce((acc: number, frame: ProfilerFrame) => Math.max(acc, frame.duration), 0);
const multiplicationFactor = parseFloat((MAX_HEIGHT / maxValue).toFixed(2));
this._maxDuration = Math.max(this._maxDuration, maxValue);
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;
const colorPercentage = Math.round((height / MAX_HEIGHT) * 100);
const backgroundColor = this.getColorByFrameRate(this.estimateFrameRate(record.duration));
const style = {
'margin-left': '1px',
'margin-right': '1px',
background: `-webkit-linear-gradient(bottom, ${backgroundColor} ${colorPercentage}%, #f3f3f3 ${colorPercentage}%)`,
border: '1px solid #d0d0d0',
cursor: 'pointer',
'min-width': '25px',
width: '25px',
height: '50px',
};
const toolTip = `${record.source} TimeSpent: ${record.duration.toFixed(3)}ms`;
return { style, toolTip };
}
}

View file

@ -10,13 +10,27 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { FrameSelectorComponent } from './frame-selector/frame-selector.component';
import { TimelineControlsComponent } from './timeline-controls/timeline-controls.component';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { RecordingDialogComponent } from './recording-modal/recording-dialog/recording-dialog.component';
import { RecordingModalComponent } from './recording-modal/recording-modal.component';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatDialogModule } from '@angular/material/dialog';
@NgModule({
declarations: [TimelineComponent, FrameSelectorComponent, TimelineControlsComponent],
declarations: [
TimelineComponent,
RecordingDialogComponent,
RecordingModalComponent,
FrameSelectorComponent,
TimelineControlsComponent,
],
imports: [
ScrollingModule,
CommonModule,
FormsModule,
RecordingVisualizerModule,
MatDialogModule,
MatProgressBarModule,
MatButtonModule,
MatTooltipModule,
MatIconModule,