From 75a1a3e0063461afe70c568db0118e23c07b54a5 Mon Sep 17 00:00:00 2001 From: Ludek Novy <13610612+ludeknovy@users.noreply.github.com> Date: Sat, 11 Mar 2023 10:57:53 +0100 Subject: [PATCH] label comparison chart (#308) --- src/app/_services/chart-service-utils.ts | 90 ++++++++++++ .../comparison-chart.service.spec.ts | 16 ++ src/app/_services/comparison-chart.service.ts | 45 ++++++ src/app/_services/item-chart.service.ts | 101 +------------ .../analyze-charts.component.ts | 3 +- .../chart-interval.component.ts | 9 +- .../label-chart/label-chart.component.html | 9 +- .../label-chart/label-chart.component.ts | 137 ++++++++++++++---- .../request-stats-compare.component.html | 6 +- .../request-stats-compare.component.spec.ts | 9 +- .../request-stats-compare.component.ts | 14 +- .../stats-compare/stats-compare.component.ts | 5 +- 12 files changed, 312 insertions(+), 132 deletions(-) create mode 100644 src/app/_services/chart-service-utils.ts create mode 100644 src/app/_services/comparison-chart.service.spec.ts create mode 100644 src/app/_services/comparison-chart.service.ts diff --git a/src/app/_services/chart-service-utils.ts b/src/app/_services/chart-service-utils.ts new file mode 100644 index 00000000..bfbc1683 --- /dev/null +++ b/src/app/_services/chart-service-utils.ts @@ -0,0 +1,90 @@ +import { errorLineSettings, networkLineSettings, threadLineSettings, throughputLineSettings } from "../graphs/item-detail"; +import { bytesToMbps } from "../item-detail/calculations"; +import { Metrics } from "../item-detail/metrics"; + +export const getChartLines = (plot): ChartLines => { + const { + threads, overallTimeResponse, + overallThroughput, overAllFailRate, overAllNetworkV2, + responseTime, throughput, networkV2, minResponseTime, maxResponseTime, percentile90, + percentile95, percentile99, statusCodes, errorRate, + } = plot; + + const threadLine = { ...threadLineSettings, name: "virtual users", data: threads, tooltip: { valueSuffix: "" } }; + const errorLine = { ...errorLineSettings, ...overAllFailRate, tooltip: { valueSuffix: " %" } }; + const throughputLine = { ...throughputLineSettings, ...overallThroughput, tooltip: { valueSuffix: " reqs/s" } }; + + const chartLines = { + overall: new Map(), + labels: new Map(), + statusCodes: new Map() + }; + + if (overAllNetworkV2) { + const networkMbps = overAllNetworkV2.data.map((_) => { + return [_[0], bytesToMbps(_[1])]; + }); + const networkLine = { ...networkLineSettings, data: networkMbps, tooltip: { valueSuffix: " mbps" } }; + chartLines.overall.set(Metrics.Network, networkLine); + } + + // not all reports have status code plot data + if (statusCodes) { + chartLines.statusCodes.set(Metrics.StatusCodeInTime, { data: statusCodes }); + } + + chartLines.overall.set(Metrics.ResponseTimeAvg, { ...overallTimeResponse, tooltip: { valueSuffix: " ms" } }); + chartLines.overall.set(Metrics.Threads, threadLine); + chartLines.overall.set(Metrics.ErrorRate, errorLine); + chartLines.overall.set(Metrics.Throughput, throughputLine); + + if (networkV2) { + const networkMbps = networkV2.map((_) => { + _.data = _.data.map(__ => [__[0], bytesToMbps(__[1])]); + return _; + }); + + chartLines.labels.set(Metrics.Network, networkMbps.map((label) => ({ ...label, suffix: " mbps" }))); + } + + if (minResponseTime) { + chartLines.labels.set(Metrics.ResponseTimeMin, minResponseTime.map((label) => ({ ...label, suffix: " ms" }))); + } + + if (maxResponseTime) { + chartLines.labels.set(Metrics.ResponseTimeMax, maxResponseTime.map((label) => ({ ...label, suffix: " ms" }))); + } + if (percentile90) { + chartLines.labels.set(Metrics.ResponseTimeP90, percentile90.map((label) => ({ ...label, suffix: " ms" }))); + } + if (percentile95) { + chartLines.labels.set(Metrics.ResponseTimeP95, percentile95.map((label) => ({ ...label, suffix: " ms" }))); + } + if (percentile99) { + chartLines.labels.set(Metrics.ResponseTimeP99, percentile99.map((label) => ({ ...label, suffix: " ms" }))); + } + if (errorRate) { + chartLines.labels.set(Metrics.ErrorRate, errorRate.map((label) => ({ ...label, suffix: " %" }))); + } + chartLines.labels.set(Metrics.ResponseTimeAvg, responseTime.map((label) => ({ ...label, suffix: " ms" }))); + chartLines.labels.set(Metrics.Throughput, throughput.map((label) => ({ ...label, suffix: " reqs/s" }))); + + return { chartLines }; + +}; + + +export interface ChartLines { + chartLines: ChartLine; +} + +export interface ChartLine { + labels: Map; + overall: Map; +} + +export interface LabelChartLine { + name: string + data: [], + suffix: string +} diff --git a/src/app/_services/comparison-chart.service.spec.ts b/src/app/_services/comparison-chart.service.spec.ts new file mode 100644 index 00000000..736d796f --- /dev/null +++ b/src/app/_services/comparison-chart.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { ComparisonChartService } from "./comparison-chart.service"; + +describe("ComparisonChartService", () => { + let service: ComparisonChartService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ComparisonChartService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/_services/comparison-chart.service.ts b/src/app/_services/comparison-chart.service.ts new file mode 100644 index 00000000..37f066cb --- /dev/null +++ b/src/app/_services/comparison-chart.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { ChartLines, getChartLines } from "./chart-service-utils"; + +@Injectable({ + providedIn: "root" +}) +export class ComparisonChartService { + + + private plot$ = new BehaviorSubject({ chartLines: null }); + private histogramPlot$ = new BehaviorSubject<{ responseTimePerLabelDistribution: []}>(null); + + private interval$ = new BehaviorSubject(null); + selectedPlot$ = this.plot$.asObservable(); + histogram$ = this.histogramPlot$.asObservable() + + + setInterval(interval) { + this.interval$.next(interval); + } + + setHistogramPlot(plot) { + this.histogramPlot$.next(plot); + } + + resetPlot() { + this.plot$.next(null) + + } + + setComparisonPlot(defaultPlot, extraPlots) { + this.interval$.subscribe(interval => { + let comparisonPlot = null + if (!interval || interval === "Auto") { + comparisonPlot = defaultPlot + } else { + const extraPlotIntervalData = extraPlots?.find(extraPlot => extraPlot.interval === interval)?.data + comparisonPlot = extraPlotIntervalData || defaultPlot + } + this.plot$.next(comparisonPlot ? getChartLines(comparisonPlot): null) + }) + + } +} diff --git a/src/app/_services/item-chart.service.ts b/src/app/_services/item-chart.service.ts index a95cc80e..fae7795b 100644 --- a/src/app/_services/item-chart.service.ts +++ b/src/app/_services/item-chart.service.ts @@ -1,8 +1,6 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject } from "rxjs"; -import { errorLineSettings, networkLineSettings, threadLineSettings, throughputLineSettings } from "../graphs/item-detail"; -import { bytesToMbps } from "../item-detail/calculations"; -import { Metrics } from "../item-detail/metrics"; +import { ChartLines, getChartLines } from "./chart-service-utils"; @Injectable({ providedIn: "root" @@ -10,99 +8,16 @@ import { Metrics } from "../item-detail/metrics"; export class ItemChartService { private plot$ = new BehaviorSubject({ chartLines: null }); - selectedPlot$ = this.plot$.asObservable(); - - setCurrentPlot(plot) { - this.plot$.next(this.getChartLines(plot)); - } - - - private getChartLines(plot): ChartLines { - const { - threads, overallTimeResponse, - overallThroughput, overAllFailRate, overAllNetworkV2, - responseTime, throughput, networkV2, minResponseTime, maxResponseTime, percentile90, - percentile95, percentile99, statusCodes, errorRate, - } = plot; - - const threadLine = { ...threadLineSettings, name: "virtual users", data: threads, tooltip: { valueSuffix: "" } }; - const errorLine = { ...errorLineSettings, ...overAllFailRate, tooltip: { valueSuffix: " %" } }; - const throughputLine = { ...throughputLineSettings, ...overallThroughput, tooltip: { valueSuffix: " reqs/s" } }; - - const chartLines = { - overall: new Map(), - labels: new Map(), - statusCodes: new Map() - } - - if (overAllNetworkV2) { - const networkMbps = overAllNetworkV2.data.map((_) => { - return [_[0], bytesToMbps(_[1])]; - }); - const networkLine = { ...networkLineSettings, data: networkMbps, tooltip: { valueSuffix: " mbps" } }; - chartLines.overall.set(Metrics.Network, networkLine); - } - - // not all reports have status code plot data - if (statusCodes) { - chartLines.statusCodes.set(Metrics.StatusCodeInTime, { data: statusCodes }) - } - - chartLines.overall.set(Metrics.ResponseTimeAvg, { ...overallTimeResponse, tooltip: { valueSuffix: " ms" } }); - chartLines.overall.set(Metrics.Threads, threadLine); - chartLines.overall.set(Metrics.ErrorRate, errorLine); - chartLines.overall.set(Metrics.Throughput, throughputLine); - - if (networkV2) { - const networkMbps = networkV2.map((_) => { - _.data = _.data.map(__ => [__[0], bytesToMbps(__[1])]); - return _; - }); - - chartLines.labels.set(Metrics.Network, networkMbps.map((label) => ({ ...label, suffix: " mbps" }))); - } + private interval; - if (minResponseTime) { - chartLines.labels.set(Metrics.ResponseTimeMin, minResponseTime.map((label) => ({ ...label, suffix: " ms" }))); - } - - if (maxResponseTime) { - chartLines.labels.set(Metrics.ResponseTimeMax, maxResponseTime.map((label) => ({ ...label, suffix: " ms" }))); - } - if (percentile90) { - chartLines.labels.set(Metrics.ResponseTimeP90, percentile90.map((label) => ({ ...label, suffix: " ms" }))); - } - if (percentile95) { - chartLines.labels.set(Metrics.ResponseTimeP95, percentile95.map((label) => ({ ...label, suffix: " ms" }))); - } - if (percentile99) { - chartLines.labels.set(Metrics.ResponseTimeP99, percentile99.map((label) => ({ ...label, suffix: " ms" }))); - } - if (errorRate) { - chartLines.labels.set(Metrics.ErrorRate, errorRate.map((label) => ({ ...label, suffix: " %" }))) - } - chartLines.labels.set(Metrics.ResponseTimeAvg, responseTime.map((label) => ({ ...label, suffix: " ms" }))); - chartLines.labels.set(Metrics.Throughput, throughput.map((label) => ({ ...label, suffix: " reqs/s" }))); + selectedPlot$ = this.plot$.asObservable(); - return { chartLines } + setInterval(interval) { + this.interval = interval; } - -} - - -interface ChartLines { - chartLines: ChartLine -} - -export interface ChartLine { - labels: Map - overall: Map -} - -export interface LabelChartLine { - name: string - data: [], - suffix: string + setCurrentPlot(plot) { + this.plot$.next(getChartLines(plot)); + } } diff --git a/src/app/item-detail/analyze-charts/analyze-charts.component.ts b/src/app/item-detail/analyze-charts/analyze-charts.component.ts index e36ec8cf..069e1b95 100644 --- a/src/app/item-detail/analyze-charts/analyze-charts.component.ts +++ b/src/app/item-detail/analyze-charts/analyze-charts.component.ts @@ -3,7 +3,8 @@ import { customChartSettings } from "src/app/graphs/item-detail"; import * as Highcharts from "highcharts"; import { ItemsApiService } from "src/app/items-api.service"; import { AnalyzeChartService } from "../../analyze-chart.service"; -import { ChartLine, ItemChartService } from "src/app/_services/item-chart.service"; +import { ItemChartService } from "src/app/_services/item-chart.service"; +import { ChartLine } from "../../_services/chart-service-utils"; @Component({ selector: "app-analyze-charts", diff --git a/src/app/item-detail/chart-interval/chart-interval.component.ts b/src/app/item-detail/chart-interval/chart-interval.component.ts index 508a026e..d55ba40a 100644 --- a/src/app/item-detail/chart-interval/chart-interval.component.ts +++ b/src/app/item-detail/chart-interval/chart-interval.component.ts @@ -1,6 +1,8 @@ import { Component, Input, OnInit } from "@angular/core"; import { ItemDataPlot, ItemExtraPlot } from "src/app/items.service.model"; import { ItemChartService } from "src/app/_services/item-chart.service"; +import { interval } from "rxjs"; +import { ComparisonChartService } from "../../_services/comparison-chart.service"; @Component({ selector: "app-chart-interval", @@ -11,7 +13,10 @@ export class ChartIntervalComponent implements OnInit { @Input() intervals: { defaultInterval: ItemDataPlot, extraIntervals: ItemExtraPlot[] } - constructor(private itemChartService: ItemChartService) {} + constructor( + private itemChartService: ItemChartService, + private comparisonChartService: ComparisonChartService + ) {} availableIntervals: string[] defaultIntervalName = "Auto" @@ -29,6 +34,8 @@ export class ChartIntervalComponent implements OnInit { } else { newPlotData = this.intervals.extraIntervals.find(interval => interval.interval === inputInterval)?.data } + this.itemChartService.setInterval(interval) + this.comparisonChartService.setInterval(inputInterval) this.itemChartService.setCurrentPlot(newPlotData) } diff --git a/src/app/item-detail/label-chart/label-chart.component.html b/src/app/item-detail/label-chart/label-chart.component.html index 37a7c02b..bb83df53 100644 --- a/src/app/item-detail/label-chart/label-chart.component.html +++ b/src/app/item-detail/label-chart/label-chart.component.html @@ -1,7 +1,7 @@
Label Chart - {{chartMetric}} - +
@@ -25,6 +25,11 @@
+
+ +
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 ac623304..9a7e8f2c 100644 --- a/src/app/item-detail/label-chart/label-chart.component.ts +++ b/src/app/item-detail/label-chart/label-chart.component.ts @@ -2,9 +2,10 @@ import { Component, Input, OnChanges, SimpleChanges, ViewChild } from "@angular/ import * as Highcharts from "highcharts"; import { commonGraphSettings, responseTimeDistribution } from "src/app/graphs/item-detail"; import * as deepmerge from "deepmerge"; -import { ChartLine } from "src/app/_services/item-chart.service"; import { Metrics } from "../metrics"; import { ResponseTimePerLabelDistribution } from "../../items.service.model"; +import { ChartLine } from "../../_services/chart-service-utils"; +import { ComparisonChartService } from "../../_services/comparison-chart.service"; @Component({ selector: "app-label-chart", @@ -18,17 +19,24 @@ export class LabelChartComponent implements OnChanges { @Input() activated: boolean; @Input() histogramData: ResponseTimePerLabelDistribution[]; @ViewChild("labelChart") componentRef; + @ViewChild("labelComparisonChart") labelComparisonChartComponentRef Highcharts: typeof Highcharts = Highcharts; chartMetric = "Response Times"; labelChartOptions = commonGraphSettings("ms"); // default empty chart for rendering + comparisonLabelChartOptions = null; updateLabelChartFlag = false; chartKeys; + comparisonChartKeys; chartShouldExpand = false; chart: Highcharts.Chart; + comparisonChart: Highcharts.Chart; chartCallback; + comparisonChartCallback; labelCharts = new Map(); + comparisonLabelCharts = new Map() expanded = false; + chartEnum = LabelChartType private responseTimeMetricGroup: string[]; metricChartMap = new Map([ @@ -42,77 +50,143 @@ export class LabelChartComponent implements OnChanges { [Metrics.ResponseTimeP99, commonGraphSettings("ms")], [Metrics.ErrorRate, commonGraphSettings("%")] ]); + LabelChartType: LabelChartType; - constructor() { + constructor( + private comparisonChartService: ComparisonChartService + ) { this.chartCallback = chart => { this.chart = chart; }; + this.comparisonChartCallback = chart => { + this.comparisonChart = chart; + }; this.responseTimeMetricGroup = [ Metrics.ResponseTimeP90, Metrics.ResponseTimeAvg, Metrics.ResponseTimeMin, Metrics.ResponseTimeMax, Metrics.ResponseTimeP95, Metrics.ResponseTimeP99]; } + ngOnChanges(changes: SimpleChanges) { // chart expanded if (!changes.activated?.previousValue && changes.activated?.currentValue) { - this.setChartAggregation(); - this.getChartsKey(); - this.setHistogramChart(); - this.changeChart({ target: { innerText: this.chartMetric } }); - this.chart.reflow(); + this.setChartAggregation(this.chartLines, LabelChartType.Default); + this.getChartsKey(LabelChartType.Default); + this.setHistogramChart(this.histogramData, LabelChartType.Default); + this.setChart(this.chartMetric, LabelChartType.Default); this.expanded = true; + + this.comparisonChartService.histogram$.subscribe(plot => { + this.setHistogramChart(plot.responseTimePerLabelDistribution, LabelChartType.Comparison); + }) + + this.comparisonChartService.selectedPlot$.subscribe(plot => { + if (!plot) { + if (this.comparisonChart) { + this.comparisonLabelChartOptions = null + this.comparisonChart = null + } + } + if (!plot?.chartLines) { + return; + } + this.setChartAggregation(plot.chartLines, LabelChartType.Comparison); + this.getChartsKey(LabelChartType.Comparison); + this.setChart(this.chartMetric, LabelChartType.Comparison); + }); } // aggregation changed, we need to refresh the data but only for opened charts if (changes.chartLines?.currentValue && this.expanded) { - this.chartLines = changes.chartLines.currentValue; - this.setChartAggregation(); - this.changeChart({ target: { innerText: this.chartMetric } }); + this.setChartAggregation(changes.chartLines.currentValue, LabelChartType.Default); + this.setChart(this.chartMetric,LabelChartType.Default); } } - private setChartAggregation() { - const threadLine = this.chartLines.overall.get(Metrics.Threads); + private setChartAggregation(chartLines, chartType: LabelChartType) { + const threadLine = chartLines.overall.get(Metrics.Threads); const availableMetrics = Array.from(this.chartLines.labels.keys()); const responseTimesSeries = []; availableMetrics.forEach((metric: Metrics) => { - const labelMetricsData = this.chartLines.labels.get(metric).find(data => data.name === this.label); + const labelMetricsData = chartLines.labels.get(metric).find(data => data.name === this.label); + if (!labelMetricsData) { + return + } const chartSettings = this.metricChartMap.get(metric); if (this.responseTimeMetricGroup.includes(metric)) { responseTimesSeries.push({ data: labelMetricsData.data, suffix: labelMetricsData.suffix, name: metric, yAxis: 0, id: metric }); } else { - this.labelCharts.set(metric, { - ...chartSettings, - series: [{ data: labelMetricsData.data, suffix: labelMetricsData.suffix, name: metric, id: metric }, threadLine] - }); + if (chartType === LabelChartType.Default) { + this.labelCharts.set(metric, { + ...chartSettings, + series: [{ data: labelMetricsData.data, suffix: labelMetricsData.suffix, name: metric, id: metric }, threadLine] + }); + } else { + this.comparisonLabelCharts.set(metric, { + ...chartSettings, + series: [{ data: labelMetricsData.data, suffix: labelMetricsData.suffix, name: metric, id: metric }, threadLine] + }); + } + } }); - this.labelCharts.set("Response Times", { ...commonGraphSettings("ms"), series: [...responseTimesSeries, threadLine] }); + if (chartType === LabelChartType.Default) { + this.labelCharts.set("Response Times", { ...commonGraphSettings("ms"), series: [...responseTimesSeries, threadLine] }); + } else { + if (responseTimesSeries.length > 0) { + this.comparisonLabelCharts.set("Response Times", { ...commonGraphSettings("ms"), series: [...responseTimesSeries, threadLine] }); + } + } } - private setHistogramChart() { - if (this.histogramData) { + private setHistogramChart(histogramData, chartType: LabelChartType) { + if (chartType === LabelChartType.Default) { this.chartKeys.push("Histogram"); - const histogram = this.histogramData.find(data => data.label === this.label); + const histogram = histogramData.find(data => data.label === this.label); this.labelCharts.set("Histogram", responseTimeDistribution(histogram.values)); + } else { + const histogram = histogramData.find(data => data.label === this.label); + this.comparisonLabelCharts.set("Histogram", responseTimeDistribution(histogram.values)); } } - private getChartsKey() { - this.chartKeys = Array.from(this.labelCharts.keys()); + private getChartsKey(chartType: LabelChartType) { + if (chartType === LabelChartType.Default) { + this.chartKeys = Array.from(this.labelCharts.keys()); + } + else { + this.comparisonChartKeys = Array.from(this.comparisonLabelCharts.keys()); + } } - - changeChart(event) { + changeChartAggregation(event) { this.chartMetric = event.target.innerText; - if(this.chart) { - this.chart.destroy(); - this.componentRef.chart = null; + this.setChart(this.chartMetric, LabelChartType.Comparison) + this.setChart(this.chartMetric, LabelChartType.Default) + } + + + setChart(metric, chartType: LabelChartType) { + if (chartType === LabelChartType.Default) { + if (this.chart) { + this.chart.destroy(); + this.componentRef.chart = null; + } + this.labelChartOptions = deepmerge(this.labelCharts.get(metric), {}); + } else { + if (this.comparisonChart) { + this.comparisonChart.destroy(); + this.labelComparisonChartComponentRef.chart = null; + } + const chart = this.comparisonLabelCharts.get(metric) + if (chart) { + this.comparisonLabelChartOptions = deepmerge(this.comparisonLabelCharts.get(metric), {}); + } + } - this.labelChartOptions = deepmerge(this.labelCharts.get(this.chartMetric), {}); this.updateLabelChartFlag = true; } @@ -125,3 +199,8 @@ export class LabelChartComponent implements OnChanges { } +enum LabelChartType { + Default, + Comparison + +} diff --git a/src/app/item-detail/request-stats/request-stats-compare.component.html b/src/app/item-detail/request-stats/request-stats-compare.component.html index 65e291d8..5f9fe7ab 100644 --- a/src/app/item-detail/request-stats/request-stats-compare.component.html +++ b/src/app/item-detail/request-stats/request-stats-compare.component.html @@ -97,7 +97,7 @@
Request Statistics - +