diff --git a/src/app/graphs/item-detail.ts b/src/app/graphs/item-detail.ts index 3dd3c87e..3d358046 100644 --- a/src/app/graphs/item-detail.ts +++ b/src/app/graphs/item-detail.ts @@ -26,7 +26,7 @@ export const commonGraphSettings: any = (text) => { }, }, title: { - text: "" + text: "", }, colors: ["#5DADE2", "#2ECC71", "#F4D03F", "#D98880", "#707B7C", "#7DCEA0", "#21618C", "#873600", "#AF7AC5", "#B7950B"], diff --git a/src/app/graphs/scenario-trends.ts b/src/app/graphs/scenario-trends.ts index 1f271208..ce5f0c08 100644 --- a/src/app/graphs/scenario-trends.ts +++ b/src/app/graphs/scenario-trends.ts @@ -194,4 +194,73 @@ export const customScenarioTrends = () => { }; }; - +export const labelTrends: any = (text, title = "") => { + return { + chart: { + type: "line", + zoomType: "x", + marginTop: 50, + className: "chart-sync", + }, + time: { + getTimezoneOffset: function (timestamp) { + const d = new Date(); + const timezoneOffset = d.getTimezoneOffset(); + return timezoneOffset; + } + }, + exporting: { + buttons: { + contextButton: { + enabled: false + }, + }, + }, + title: { + text: title, + }, + colors: ["#5DADE2", "#2ECC71", "#F4D03F", "#D98880", + "#707B7C", "#7DCEA0", "#21618C", "#873600", "#AF7AC5", "#B7950B"], + tooltip: { + split: true, + crosshairs: [true], + valueSuffix: ` ${text}`, + valueDecimals: 2, + }, + plotOptions: { + series: { + connectNulls: true, + }, + line: { + lineWidth: 1.5, + states: { + hover: { + lineWidth: 1.5 + } + }, + marker: { + enabled: false + }, + } + }, + xAxis: { + lineWidth: 0, + type: "category", + uniqueNames: true, + crosshair: { + snap: true + }, + labels: { + enabled: false + } + }, + yAxis: [{ + gridLineColor: "#f2f2f2", + lineWidth: 0, + title: { + text + }, + }, + ], + }; +}; diff --git a/src/app/item-detail/label-chart/label-chart.component.ts b/src/app/item-detail/label-chart/label-chart.component.ts index e0c129d0..ac623304 100644 --- a/src/app/item-detail/label-chart/label-chart.component.ts +++ b/src/app/item-detail/label-chart/label-chart.component.ts @@ -113,7 +113,6 @@ export class LabelChartComponent implements OnChanges { this.componentRef.chart = null; } this.labelChartOptions = deepmerge(this.labelCharts.get(this.chartMetric), {}); - console.log(this.labelChartOptions) this.updateLabelChartFlag = true; } diff --git a/src/app/items.service.model.ts b/src/app/items.service.model.ts index 5ad8222a..f0474001 100644 --- a/src/app/items.service.model.ts +++ b/src/app/items.service.model.ts @@ -176,6 +176,29 @@ export interface ScenarioTrendsData { id: string; } +export interface LabelTrendsData { + labelStats: [{ + avgResponseTime: number + bytesPerSecond: number + bytesSentPerSecond: number + connect: number + errorRate: number + label: string + latency: number + maxResponseTime: number + medianResponseTime: number + minResponseTime: number + n0: number + n5: number + n9: number + samples: number + throughput: number + },] + id: string, + startDate: string + +} + export interface ItemHistoryDetail { label: string; samples: number; @@ -294,3 +317,12 @@ export interface ResponseTimePerLabelDistribution { label: string values: number[] } + +export interface ScenarioTrendsUserSettings { + aggregatedTrends: boolean + labelMetrics: { + errorRate: boolean + percentile90: boolean + throughput: boolean + } +} diff --git a/src/app/notification/notification-messages.ts b/src/app/notification/notification-messages.ts index 59230713..04e80794 100644 --- a/src/app/notification/notification-messages.ts +++ b/src/app/notification/notification-messages.ts @@ -78,8 +78,8 @@ export class NotificationMessage { return this.statusCodeMessage(response, "Link was deleted"); } - scenarioThresholdUpdate(response) { - return this.statusCodeMessage(response, "Thresholds were updated"); + scenarioTrendsSettingsNotification(response) { + return this.statusCodeMessage(response, "Scenario trend settings updated"); } appInitialization(response) { diff --git a/src/app/scenario-api.service.ts b/src/app/scenario-api.service.ts index 888a81c3..339bca18 100644 --- a/src/app/scenario-api.service.ts +++ b/src/app/scenario-api.service.ts @@ -12,11 +12,13 @@ export class ScenarioApiService { private response = new BehaviorSubject({}); public response$ = this.response.asObservable(); - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) { + } getScenario(projectName, scenarioName): Observable { return this.http.get(`projects/${projectName}/scenarios/${scenarioName}`); } + updateScenario(projectName, scenarioName, body): Observable> { return this.http.put(`projects/${projectName}/scenarios/${scenarioName}`, body, { observe: "response" }); } @@ -50,12 +52,8 @@ export class ScenarioApiService { return this.http.post(`projects/${projectName}/scenarios/${scenarioName}/notifications`, body, { observe: "response" }); } - fetchThresholds(projectName, scenarioName) { - return this.http.get(`projects/${projectName}/scenarios/${scenarioName}/thresholds`); - } - - updateThresholds(projectName, scenarioName, body) { - return this.http.put(`projects/${projectName}/scenarios/${scenarioName}/thresholds`, body, { observe: "response" }); + updateScenarioTrendsSettings(projectName, scenarioName, body): Observable { + return this.http.post(`projects/${projectName}/scenarios/${scenarioName}/trends/settings`, body, { observe: "response" }); } setData(data) { diff --git a/src/app/scenario.service.ts b/src/app/scenario.service.ts index 78a0d0dc..0f3e15c5 100644 --- a/src/app/scenario.service.ts +++ b/src/app/scenario.service.ts @@ -30,4 +30,8 @@ export class ScenarioService { .subscribe(_ => this.notifications.next(_)); } + updateScenarioTrends(value) { + this.trends.next(value) + } + } diff --git a/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.css b/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.css new file mode 100644 index 00000000..e69de29b diff --git a/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.html b/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.html new file mode 100644 index 00000000..1f122670 --- /dev/null +++ b/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.html @@ -0,0 +1,76 @@ + + + + + + + + diff --git a/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.spec.ts b/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.spec.ts new file mode 100644 index 00000000..e45fecc7 --- /dev/null +++ b/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ScenarioTrendsSettingsComponent } from "./scenario-trends-settings.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; + +describe("ScenarioTrendsSettingsComponent", () => { + let component: ScenarioTrendsSettingsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ScenarioTrendsSettingsComponent], + imports: [HttpClientTestingModule] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ScenarioTrendsSettingsComponent); + component = fixture.componentInstance; + component.userSettings = { + aggregatedTrends: true, + labelMetrics: { + percentile90: true, + errorRate: true, + throughput: false, + } + } + component.params = { scenarioName: "test", projectName: "test" }; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.ts b/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.ts new file mode 100644 index 00000000..0e0c72c8 --- /dev/null +++ b/src/app/scenario/scenario-trends/scenario-trends-settings/scenario-trends-settings.component.ts @@ -0,0 +1,106 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { ScenarioApiService } from "../../../scenario-api.service"; +import { catchError } from "rxjs/operators"; +import { of } from "rxjs"; +import { NotificationMessage } from "../../../notification/notification-messages"; +import { ScenarioService } from "../../../scenario.service"; + +@Component({ + selector: "app-scenario-trends-settings", + templateUrl: "./scenario-trends-settings.component.html", + styleUrls: ["./scenario-trends-settings.component.css"] +}) +export class ScenarioTrendsSettingsComponent implements OnInit { + + @Input() userSettings; + @Input() params; + + scenarioTrendsSettingsForm: FormGroup; + + formControls = { + aggregatedTrends: null, + errorRate: null, + percentile90: null, + throughput: null, + }; + + metricsEditable; + + constructor( + private modalService: NgbModal, + private scenarioApiService: ScenarioApiService, + private scenarioService: ScenarioService, + private notification: NotificationMessage, + ) { + } + + ngOnInit(): void { + this.createFormControls(this.userSettings); + this.createForm(); + } + + createFormControls(settings) { + this.formControls.aggregatedTrends = new FormControl(settings.aggregatedTrends.toString(), [ + Validators.required + ]); + this.formControls.percentile90 = new FormControl(settings.labelMetrics.percentile90 || false, [ + Validators.required + ]) + this.formControls.errorRate = new FormControl(settings.labelMetrics.errorRate || false, [ + Validators.required + ]) + this.formControls.throughput = new FormControl(settings.labelMetrics.throughput || false, [ + Validators.required + ]) + } + + createForm() { + this.scenarioTrendsSettingsForm = new FormGroup({ + aggregatedTrends: this.formControls.aggregatedTrends, + percentile90: this.formControls.percentile90, + errorRate: this.formControls.errorRate, + throughput: this.formControls.throughput, + }); + } + + open(content) { + this.modalService.open(content, { ariaLabelledBy: "modal-basic-title", size: "lg" }); + } + + onCheckboxChange() { + this.isEditable(); + } + + isEditable() { + const enabledMetrics = Object.values([this.formControls.errorRate, this.formControls.percentile90, + this.formControls.throughput]) + .map(control => control.value).filter(value => value === true); + this.metricsEditable = enabledMetrics.length > 2; + } + + onSubmit() { + if (this.scenarioTrendsSettingsForm.valid) { + const { scenarioName, projectName } = this.params + const body = { + aggregatedTrends: this.scenarioTrendsSettingsForm.value.aggregatedTrends === "true", + labelMetrics: { + errorRate: this.scenarioTrendsSettingsForm.value.errorRate, + percentile90: this.scenarioTrendsSettingsForm.value.percentile90, + throughput: this.scenarioTrendsSettingsForm.value.throughput, + } + } + this.scenarioApiService.updateScenarioTrendsSettings(projectName, scenarioName, body) + .pipe(catchError(r => of(r))) + .subscribe(subscription => { + const message = this.notification.scenarioTrendsSettingsNotification(subscription); + this.scenarioApiService.setData(message) + this.scenarioService.updateScenarioTrends({ userSettings: body }) + }) + this.modalService.dismissAll() + + } + } + +} diff --git a/src/app/scenario/scenario-trends/scenario-trends.component.html b/src/app/scenario/scenario-trends/scenario-trends.component.html index 236de25f..eeb33a59 100644 --- a/src/app/scenario/scenario-trends/scenario-trends.component.html +++ b/src/app/scenario/scenario-trends/scenario-trends.component.html @@ -1,13 +1,64 @@ -
+
Scenario Trends last 15 test runs + +
+ +
+
+ + +
- + +
+ +
+
+ Scenario Trends + last 15 test runs + +
+ +
+
+ + + +
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + + diff --git a/src/app/scenario/scenario-trends/scenario-trends.component.scss b/src/app/scenario/scenario-trends/scenario-trends.component.scss index 9687a371..13245c7c 100644 --- a/src/app/scenario/scenario-trends/scenario-trends.component.scss +++ b/src/app/scenario/scenario-trends/scenario-trends.component.scss @@ -16,3 +16,8 @@ font-size: 13px; font-weight: normal; } + +.data-truncated-warning { + margin-right: 10px; + margin-top: 5px; +} diff --git a/src/app/scenario/scenario-trends/scenario-trends.component.ts b/src/app/scenario/scenario-trends/scenario-trends.component.ts index 24baf670..b766ccfe 100644 --- a/src/app/scenario/scenario-trends/scenario-trends.component.ts +++ b/src/app/scenario/scenario-trends/scenario-trends.component.ts @@ -1,11 +1,11 @@ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import * as Highcharts from "highcharts"; import * as moment from "moment"; -import { customScenarioTrends } from "src/app/graphs/scenario-trends"; +import { customScenarioTrends, labelTrends } from "src/app/graphs/scenario-trends"; import { Series } from "src/app/graphs/series.model"; import { bytesToMbps } from "src/app/item-detail/calculations"; -import { ScenarioTrendsData } from "src/app/items.service.model"; +import { LabelTrendsData, ScenarioTrendsData, ScenarioTrendsUserSettings } from "src/app/items.service.model"; import { ScenarioService } from "src/app/scenario.service"; @Component({ @@ -16,36 +16,61 @@ import { ScenarioService } from "src/app/scenario.service"; export class ScenarioTrendsComponent implements OnInit { @Input() params; Highcharts: typeof Highcharts = Highcharts; - updateLabelChartFlag = false; - customScenarioTimeChartOption = { + updateAggregatedScenarioTrendsChartFlag = false; + updateLabelScenarioTrendsChartFlag = false; + aggregatedScenarioTrendChartOption = { ...customScenarioTrends(), series: [] }; + labelScenarioTrendChartP90Option = { + ...labelTrends("ms", "Response Time [P90]"), series: [] + }; + labelScenarioTrendChartThroughputOption = { + ...labelTrends("req/s", "Throughput"), series: [] + }; + labelScenarioTrendChartErrorRateOption = { + ...labelTrends("%", "ErrorRate"), series: [] + }; + userSettings: ScenarioTrendsUserSettings; chartDataMapping; - itemIds; + itemIds = new Set(); + labelDataTruncated = false; constructor(private scenarioService: ScenarioService, private router: Router, ) { this.chartDataMapping = new Map([ ["percentil", { name: Series.ResponseTimeP90, onLoad: true, color: "rgb(17,122,139, 0.8)", tooltip: { valueSuffix: " ms" } }], - ["avgResponseTime", { name: Series.ResponseTimeAvg, onLoad: false, tooltip: { valueSuffix: " ms" } }], - ["avgLatency", { name: Series.LatencyAvg, onLoad: false, tooltip: { valueSuffix: " ms" } }], - ["avgConnect", { name: Series.ConnetcAvg, onLoad: false, tooltip: { valueSuffix: " ms" } }], - ["throughput", { name: Series.Throughput, yAxis: 2, onLoad: true, color: "rgb(41,128,187, 0.8)", tooltip: { valueSuffix: " reqs/s" } }], + ["avgResponseTime", { name: Series.ResponseTimeAvg, onLoad: false, tooltip: { valueSuffix: " ms" } }], + ["avgLatency", { name: Series.LatencyAvg, onLoad: false, tooltip: { valueSuffix: " ms" } }], + ["avgConnect", { name: Series.ConnetcAvg, onLoad: false, tooltip: { valueSuffix: " ms" } }], + ["throughput", { name: Series.Throughput, yAxis: 2, onLoad: true, color: "rgb(41,128,187, 0.8)", tooltip: { valueSuffix: " reqs/s" } }], ["maxVu", { name: "vu", yAxis: 1, onLoad: true, type: "spline", color: "grey", }], - ["errorRate", { name: Series.ErrorRate, yAxis: 3, onLoad: true, color: "rgb(231,76,60, 0.8)", tooltip: { valueSuffix: " %" } }], - ["network", { name: Series.Network, yAxis: 4, onLoad: false, transform: this.networkTransform, tooltip: { valueSuffix: " mbps" } }], + ["errorRate", { name: Series.ErrorRate, yAxis: 3, onLoad: true, color: "rgb(231,76,60, 0.8)", tooltip: { valueSuffix: " %" } }], + ["network", { name: Series.Network, yAxis: 4, onLoad: false, transform: this.networkTransform, tooltip: { valueSuffix: " mbps" } }], ]); } + chartCallback: Highcharts.ChartCallbackFunction = function (chart): void { + setTimeout(() => { + chart.reflow(); + }, 0); + }; + + ngOnInit() { - this.scenarioService.trends$.subscribe((_: ScenarioTrendsData[]) => this.generateChartLines(_)); + this.scenarioService.trends$.subscribe((_: { aggregatedTrends: ScenarioTrendsData[], labelTrends: LabelTrendsData[], userSettings: ScenarioTrendsUserSettings }) => { + if (!_) { + return; + } + this.userSettings = _.userSettings; + this.generateAggregateChartLines(_.aggregatedTrends); + this.generateLabelChartLines(_.labelTrends); + }); } - generateChartLines(data: ScenarioTrendsData[]) { + generateAggregateChartLines(data: ScenarioTrendsData[]) { if (!Array.isArray(data)) { return; } - this.itemIds = data.map(_ => _.id); const dates = data.map(_ => moment(_.overview.startDate).format("DD. MM. YYYY HH:mm:ss")); const series = []; const seriesData = data.reduce((acc, current) => { @@ -63,37 +88,70 @@ export class ScenarioTrendsComponent implements OnInit { }, {}); for (const key of Object.keys(seriesData)) { - const chartSerieSettings = this.chartDataMapping.get(key); - if (!chartSerieSettings) { + const chartSeriesSettings = this.chartDataMapping.get(key); + if (!chartSeriesSettings) { continue; } series.push({ - name: chartSerieSettings.name || key, - data: chartSerieSettings.transform ? chartSerieSettings.transform(seriesData[key]) : seriesData[key], - yAxis: chartSerieSettings.yAxis || 0, - visible: chartSerieSettings.onLoad || false, - color: chartSerieSettings.color, - type: chartSerieSettings.type, - tooltip: chartSerieSettings.tooltip, + name: chartSeriesSettings.name || key, + data: chartSeriesSettings.transform ? chartSeriesSettings.transform(seriesData[key]) : seriesData[key], + yAxis: chartSeriesSettings.yAxis || 0, + visible: chartSeriesSettings.onLoad || false, + color: chartSeriesSettings.color, + type: chartSeriesSettings.type, + tooltip: chartSeriesSettings.tooltip, }); } - this.customScenarioTimeChartOption.series = JSON.parse(JSON.stringify(series)); - this.customScenarioTimeChartOption.xAxis["categories"] = dates; + this.aggregatedScenarioTrendChartOption.series = JSON.parse(JSON.stringify(series)); + this.aggregatedScenarioTrendChartOption.xAxis["categories"] = dates; - this.updateLabelChartFlag = true; + this.updateAggregatedScenarioTrendsChartFlag = true; + } + + generateLabelChartLines(data: LabelTrendsData[]) { + if (!data) { + return; + } + + const seriesP90 = []; + const seriesErrorRate = []; + const seriesThroughput = []; + + for (const key of Object.keys(data)) { + if (seriesP90.length < 20) { + data[key].percentile90.forEach(dataValue => this.itemIds.add(dataValue[2])) + seriesP90.push({ name: key, data: data[key].percentile90.map(dataValue => ({ y: dataValue[1], name: dataValue[0] })) }); + seriesErrorRate.push({ name: key, data: data[key].errorRate.map(dataValue => ({ y: dataValue[1], name: dataValue[0] })) }); + seriesThroughput.push({ name: key, data: data[key].throughput.map(dataValue => ({ y: dataValue[1], name: dataValue[0] })) }); + } else { + this.labelDataTruncated = true; + break; + } + + + } + this.updateLabelChart(this.labelScenarioTrendChartP90Option, seriesP90); + this.updateLabelChart(this.labelScenarioTrendChartThroughputOption, seriesThroughput); + this.updateLabelChart(this.labelScenarioTrendChartErrorRateOption, seriesErrorRate); + this.updateLabelScenarioTrendsChartFlag = true; } onPointSelect(event) { if (event && event.point && event.point) { - const itemId = this.itemIds[event.point.index]; + const itemId = Array.from(this.itemIds)[event.point.index]; const { projectName, scenarioName } = this.params; this.router.navigate([`./project/${projectName}/scenario/${scenarioName}/item/${itemId}`]); } } + private updateLabelChart(chartOptions, series) { + chartOptions.series = JSON.parse(JSON.stringify(series)); + chartOptions.xAxis.categories = this.itemIds; + } + private networkTransform = (data) => { const network = data.map(_ => bytesToMbps(_)); return network; - } + }; } diff --git a/src/app/scenario/scenario.component.ts b/src/app/scenario/scenario.component.ts index 8f702738..7234e09f 100644 --- a/src/app/scenario/scenario.component.ts +++ b/src/app/scenario/scenario.component.ts @@ -18,10 +18,7 @@ const OFFSET = 15; styleUrls: ["./scenario.component.scss", "../shared-styles.css"], }) export class ScenarioComponent implements OnInit, OnDestroy { - overview$: Observable; items$: Observable; - trends$: Observable>; - processingItems$: Observable>; params; page = 1; pageSize = LIMIT; @@ -39,12 +36,12 @@ export class ScenarioComponent implements OnInit, OnDestroy { private sharedMainBarService: SharedMainBarService, ) { this.items$ = itemsService.items$; - this.trends$ = scenarioService.trends$; } ngOnDestroy() { this.itemsService.interval.unsubscribe(); this.subscription.unsubscribe(); + this.scenarioService.updateScenarioTrends(undefined) } ngOnInit() { diff --git a/src/app/scenario/scenario.module.ts b/src/app/scenario/scenario.module.ts index 67209700..bfa12b1e 100644 --- a/src/app/scenario/scenario.module.ts +++ b/src/app/scenario/scenario.module.ts @@ -23,6 +23,7 @@ import { } from "./external-notification/delete-external-notification/delete-external-notification.component"; import { RoleModule } from "../_directives/role.module"; import { AddNewItemModule } from "./add-new-item/add-new-item.module"; +import { ScenarioTrendsSettingsComponent } from "./scenario-trends/scenario-trends-settings/scenario-trends-settings.component"; const routes: Routes = [ @@ -36,7 +37,7 @@ const routes: Routes = [ @NgModule({ declarations: [ScenarioComponent, ScenarioTrendsComponent, SettingsScenarioComponent, DeleteScenarioComponent, ExternalNotificationComponent, - ItemControlsComponent, AddNewExternalNotificationComponent, DeleteExternalNotificationComponent, + ItemControlsComponent, AddNewExternalNotificationComponent, DeleteExternalNotificationComponent, ScenarioTrendsSettingsComponent, ], imports: [ CommonModule, RouterModule.forRoot(routes), NgxSpinnerModule, NgbModule, SharedModule, HighchartsChartModule,