diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7e6ec6ff..f891077f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -83,6 +83,7 @@ import { EnvironmentDetailComponent } from './configuration/environments/environ import { VmTemplateDetailComponent } from './configuration/vmtemplates/vmtemplate-detail/vmtemplate-detail.component'; import { NgChartsModule } from 'ng2-charts'; import { SessionStatisticsComponent } from './session-statistics/session-statistics.component'; +import { SessionTimeStatisticsComponent } from './session-statistics/session-time-statistics/session-time-statistics.component'; import { VMTemplateServiceFormComponent } from './configuration/vmtemplates/edit-vmtemplate/vmtemplate-service-form/vmtemplate-service-form.component'; import { FilterScenariosComponent } from './filter-scenarios/filter-scenarios.component'; import { MDEditorComponent } from './scenario/md-editor/md-editor.component'; @@ -211,6 +212,7 @@ export function jwtOptionsFactory(): JwtConfig { AppComponent, HomeComponent, SessionStatisticsComponent, + SessionTimeStatisticsComponent, HeaderComponent, EventComponent, LoginComponent, diff --git a/src/app/session-statistics/session-statistics.component.html b/src/app/session-statistics/session-statistics.component.html index b4e9e687..772ecc8c 100644 --- a/src/app/session-statistics/session-statistics.component.html +++ b/src/app/session-statistics/session-statistics.component.html @@ -23,15 +23,17 @@

Session Statistics

name="scenarios" clrMulti="true" > - - {{ scenarioSelected }} + + {{ getScenarioName(scenario) }} - {{ scenario }} + {{ scenario.value }} @@ -165,10 +167,20 @@

Started Sessions (List)

- {{ item.key }} + {{ getScenarioName(item.key) }} {{ item.value }} +

Scenario Statistics

+
+
+ + +
+
diff --git a/src/app/session-statistics/session-statistics.component.ts b/src/app/session-statistics/session-statistics.component.ts index 9a777281..7fd1e033 100644 --- a/src/app/session-statistics/session-statistics.component.ts +++ b/src/app/session-statistics/session-statistics.component.ts @@ -1,11 +1,4 @@ -import { - Component, - Input, - OnChanges, - OnInit, - SimpleChanges, - ViewChild, -} from '@angular/core'; +import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'; import { ChartConfiguration, ChartData, ChartEvent, ChartType } from 'chart.js'; import { BaseChartDirective } from 'ng2-charts'; import DataLabelsPlugin, { Context } from 'chartjs-plugin-datalabels'; @@ -39,11 +32,15 @@ const ONE_DAY = 1000 * 60 * 60 * 24; styleUrls: ['./session-statistics.component.scss'], }) export class SessionStatisticsComponent implements OnInit, OnChanges { + // If no scheduledEvent is given, we display statistics about all progresses for a given time range + // If a scheduledEvent is given, we display statistics about all progresses from this scheduledEvent @Input() public scheduledEvent: ScheduledEvent; public currentScheduledEvent: ScheduledEvent; + public progressesCache: Progress[]; + public startView: 'minute' | 'day' | 'month' | 'year' = 'day'; public minView: 'minute' | 'day' | 'month' | 'year' = 'day'; public options: Intl.DateTimeFormatOptions = { @@ -108,7 +105,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { }, }, ]; - public scenariosWithSession: string[] = []; + public scenariosWithSessionMap: Map = new Map(); // Maps the id to the name public totalSessionsPerScenario: Map = new Map(); public descSort = ClrDatagridSortOrder.DESC; @@ -123,7 +120,14 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { this.currentScheduledEvent ) { this.currentScheduledEvent = this.scheduledEvent; - this.setDatesToScheduledEvent(this.scheduledEvent); + this.progressesCache = null; // Reset cache so data from the changed SE can be retreived + this.scenariosWithSessionMap = new Map(); + this.totalSessionsPerScenario = new Map(); + this.chartDetails.controls.scenarios.setValue(['*']); + this.setDatesToScheduledEvent( + this.scheduledEvent, + this.chartDetails.controls.observationPeriod.value + ); this.updateData(this.chartDetails.controls.observationPeriod.value); } } @@ -174,7 +178,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { ); if (this.scheduledEvent) { - this.setDatesToScheduledEvent(this.scheduledEvent); + this.setDatesToScheduledEvent(this.scheduledEvent, 'daily'); } this.updateLabels('daily'); @@ -214,7 +218,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { } public clearScenarios(): void { - this.chartDetails.controls.scenarios.reset(); + this.chartDetails.controls.scenarios.setValue(['*']); } private validateStartDate(): ValidatorFn { @@ -248,7 +252,10 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { }; } - private setDatesToScheduledEvent(se: ScheduledEvent) { + private setDatesToScheduledEvent( + se: ScheduledEvent, + observationPeriod: 'daily' | 'weekly' | 'monthly' + ) { const currentDate = new Date(); // Set default start date to beginning of the scheduledEvent @@ -261,12 +268,13 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { this.endDate = currentDate; } - if (this.chartDetails) { - this.chartDetails.controls.startDate.setValue( - this.startDate.toDateString() - ); - this.chartDetails.controls.endDate.setValue(this.endDate.toDateString()); - } + this.chartDetails.controls.endDate.setValue( + this.endDate.toLocaleDateString('en-US', this.options) + ); + this.chartDetails.controls.startDate.setValue( + this.startDate.toLocaleDateString('en-US', this.options) + ); + this.updateLabels(observationPeriod); } private updateData(observationPeriod: 'daily' | 'weekly' | 'monthly') { @@ -281,6 +289,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { this.progressService .listByRange(this.startDate, this.endDate) .subscribe((progresses: Progress[]) => { + this.progressesCache = progresses; this.processData(progresses, observationPeriod); }); } @@ -288,13 +297,16 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { private updateDataByScheduledEvent( observationPeriod: 'daily' | 'weekly' | 'monthly' ) { - this.progressService - .listByScheduledEvent(this.scheduledEvent.id, true) - .subscribe((progresses: Progress[]) => { - // TODO: Filter by range - progresses.forEach((p) => {}); - this.processData(progresses, observationPeriod); - }); + if (this.progressesCache) { + this.processData(this.progressesCache, observationPeriod); + } else { + this.progressService + .listByScheduledEvent(this.scheduledEvent.id, true) + .subscribe((progresses: Progress[]) => { + this.progressesCache = progresses; + this.processData(this.progressesCache, observationPeriod); + }); + } } private processData( @@ -362,8 +374,8 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { } } - public setStartDate(d: DlDateTimePickerChange) { - this.startDate = d.value; + private updateStartDate(d: Date) { + this.startDate = d; this.chartDetails.controls.startDate.setValue( this.startDate.toLocaleDateString('en-US', this.options) ); @@ -371,16 +383,22 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { this.chartDetails.controls.observationPeriod.value; this.updateLabels(observationPeriod); this.updateData(observationPeriod); - this.startDateSignpost.close(); } - public setEndDate(d: DlDateTimePickerChange) { + public setStartDate(d: DlDateTimePickerChange) { + this.updateStartDate(d.value); + if (this.startDateSignpost) { + this.startDateSignpost.close(); + } + } + + private updateEndDate(d: Date) { const observationPeriod: 'daily' | 'weekly' | 'monthly' = this.chartDetails.controls.observationPeriod.value; if (observationPeriod != 'monthly') { - this.endDate = d.value; + this.endDate = d; } else { - this.endDate = new Date(d.value.getFullYear(), d.value.getMonth() + 1, 0); + this.endDate = new Date(d.getFullYear(), d.getMonth() + 1, 0); } this.endDate.setHours(23, 59, 59, 999); this.updateLabels(observationPeriod); @@ -388,7 +406,13 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { this.chartDetails.controls.endDate.setValue( this.endDate.toLocaleDateString('en-US', this.options) ); - this.endDateSignpost.close(); + } + + public setEndDate(d: DlDateTimePickerChange) { + this.updateEndDate(d.value); + if (this.endDateSignpost) { + this.endDateSignpost.close(); + } } // events @@ -413,29 +437,30 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { } private setupScenariosWithSessions(progressData: Progress[]) { - this.scenariosWithSession = []; + this.scenariosWithSessionMap = new Map(); progressData.forEach((prog: Progress) => { - if (!this.scenariosWithSession.includes(prog.scenario_name)) { - this.scenariosWithSession.push(prog.scenario_name); - } + this.scenariosWithSessionMap.set(prog.scenario, prog.scenario_name); }); } private allScenariosSelected(): boolean { const selectedScenarios = this.chartDetails.controls.scenarios.value; - return selectedScenarios.length === 1 && selectedScenarios[0] === '*'; + return ( + !selectedScenarios || + (selectedScenarios.length === 1 && selectedScenarios[0] === '*') + ); } private prepareBarchartDatasets() { this.barChartData.datasets.length = 0; const selectedScenarios = this.chartDetails.controls.scenarios.value; if (this.allScenariosSelected()) { - this.scenariosWithSession.forEach((sWithSession: string) => { + this.scenariosWithSessionMap.forEach((sWithSession: string) => { this.barChartData.datasets.push({ data: Array.from({ length: this.barChartData.labels.length, }).fill(0), - label: sWithSession, + label: this.getScenarioName(sWithSession), stack: 'a', }); }); @@ -445,7 +470,7 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { data: Array.from({ length: this.barChartData.labels.length, }).fill(0), - label: sWithSession, + label: this.getScenarioName(sWithSession), stack: 'a', }); }); @@ -482,16 +507,18 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { return; } let evaluatedProgressData: Progress[] = progressData; - let selectedScenarios: string[] = this.scenariosWithSession; + let selectedScenarios: string[] = Array.from( + this.scenariosWithSessionMap.keys() + ); if (!this.allScenariosSelected()) { selectedScenarios = this.chartDetails.controls.scenarios.value; evaluatedProgressData = progressData.filter((progress: Progress) => - selectedScenarios.includes(progress.scenario_name) + selectedScenarios.includes(progress.scenario) ); } evaluatedProgressData.forEach((prog: Progress) => { const index = getIndex(prog); - (this.barChartData.datasets[selectedScenarios.indexOf(prog.scenario_name)] + (this.barChartData.datasets[selectedScenarios.indexOf(prog.scenario)] .data[index] as number) += 1; }); } @@ -500,8 +527,8 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { this.totalSessionsPerScenario.clear(); this.totalSessionsPerScenario = progressData.reduce( (totalSessions, progress) => { - const partialSum = totalSessions.get(progress.scenario_name) ?? 0; - totalSessions.set(progress.scenario_name, partialSum + 1); + const partialSum = totalSessions.get(progress.scenario) ?? 0; + totalSessions.set(progress.scenario, partialSum + 1); return totalSessions; }, new Map() @@ -559,4 +586,8 @@ export class SessionStatisticsComponent implements OnInit, OnChanges { this.barChartData.labels.push(labelDateString); } } + + public getScenarioName(scenarioId: string) { + return this.scenariosWithSessionMap.get(scenarioId) ?? scenarioId; + } } diff --git a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.html b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.html new file mode 100644 index 00000000..6a9052d6 --- /dev/null +++ b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.html @@ -0,0 +1,57 @@ +
+
+
+ + + + +
+
+
+
+
+
+ + +
+
+
+ + Step + Average Duration + Sessions + + Step {{ i + 1 }} + {{ formatDuration(duration) }} + {{ getNumberOfSessionsForStep(i) }} + + + Total + {{ formatDuration(totalDuration) }} + {{ getNumberOfSessionsForStep(0) }} + + +
+
diff --git a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.scss b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts new file mode 100644 index 00000000..d3d2750b --- /dev/null +++ b/src/app/session-statistics/session-time-statistics/session-time-statistics.component.ts @@ -0,0 +1,265 @@ +import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { ChartConfiguration, ChartData, ChartEvent, ChartType } from 'chart.js'; +import { BaseChartDirective } from 'ng2-charts'; +import DataLabelsPlugin, { Context } from 'chartjs-plugin-datalabels'; +import { Progress } from '../../data/progress'; +import { ProgressService } from '../../data/progress.service'; +import { durationFormatter } from '../../utils'; +import { + FormControl, + FormGroup, + NonNullableFormBuilder, + Validators, +} from '@angular/forms'; +import { deepCopy } from 'src/app/deepcopy'; + +type ChartDetailsFormGroup = FormGroup<{ + scenario: FormControl; +}>; + +@Component({ + selector: 'app-session-time-statistics', + templateUrl: './session-time-statistics.component.html', + styleUrls: ['./session-time-statistics.component.scss'], +}) +export class SessionTimeStatisticsComponent implements OnInit { + // If no scheduledEvent is given, we display statistics about all progresses for a given time range + // If a scheduledEvent is given, we display statistics about all progresses from this scheduledEvent + @Input() + public progresses: Progress[]; + + @Input() + public scenariosWithSessionMap: Map; //Maps the scenario id to the name + + public selectedScenario: string; + + public stepDurations: number[]; + public stepCounts: number[]; + public avgDuration: number[]; + public totalDuration: number; + + public chartDetails: ChartDetailsFormGroup; + public barChartData: ChartData<'bar'> = { + labels: [], + datasets: [], + }; + public barChartOptions: ChartConfiguration['options'] = { + responsive: true, + // We use these empty structures as placeholders for dynamic theming. + scales: { + x: {}, + y: { + min: 0, + ticks: { + precision: 0, + stepSize: 60, + maxTicksLimit: 10, + callback: function (value: number, index, ticks) { + // Format y-axis labels to time string + return durationFormatter(value); + }, + }, + }, + }, + plugins: { + legend: { + display: true, + position: 'top', + }, + datalabels: { + // only display datalabels if != 0 + display: (context: Context) => { + return context.dataset.data[context.dataIndex] != 0; + }, + anchor: 'end', + align: 'end', + formatter: function (duration: number, context) { + return durationFormatter(duration); + }, + }, + }, + }; + public barChartType: ChartType = 'bar'; + public barChartPlugins = [ + DataLabelsPlugin, + { + id: 'legendMargin', + // chart is of type Chart<'bar'>. However, we are forced to use chart.js version 3.4.0 because it is used by ng2-charts as peer dependency. + // And the "legend" property is not defined in the type definitions of this version. + + // We can not upgrade ng2-charts (and so its peer dependency) yet because it requires Angular 14. + beforeInit: function (chart: any) { + // Get the reference to the original fit function + const originalFit = (chart.legend as any).fit; + + // Override the fit function + (chart.legend as any).fit = function fit() { + // Call original function and bind scope in order to use `this` correctly inside it + originalFit.bind(chart.legend)(); + // Change the height + this.height += 20; + }; + }, + }, + ]; + + constructor( + public progressService: ProgressService, + private _fb: NonNullableFormBuilder + ) {} + + ngOnInit(): void { + const firstScenario = + this.scenariosWithSessionMap?.keys().next().value ?? ''; + this.chartDetails = this._fb.group({ + scenario: this._fb.control(firstScenario, [ + Validators.required, + Validators.minLength(1), + ]), + }); + + this.selectedScenario = firstScenario; + this.updateData(); + + this.chartDetails.controls.scenario.valueChanges.subscribe( + (scenario: string) => { + this.chartDetails.controls.scenario.setValue(scenario, { + emitEvent: false, + }); + this.selectedScenario = scenario; + this.updateData(); + } + ); + } + + @ViewChild(BaseChartDirective) chart: BaseChartDirective | undefined; + + public clearScenarios(): void { + this.chartDetails.controls.scenario.reset(); + } + + private updateData() { + console.log(this.progresses); + const evaluatedProgressData = this.progresses.filter( + (progress: Progress) => this.selectedScenario == progress.scenario + ); + + // TODO calculate data + let stepTime = []; + let stepProgressCount = []; + + evaluatedProgressData.forEach((p) => { + // Increase the count of progresses that have been to this step + for (let i = 0; i < p.max_step; i++) { + if (stepProgressCount[i]) { + stepProgressCount[i]++; + } else { + stepProgressCount[i] = 1; + } + } + + for (let i = 1; i < p.steps.length; i++) { + const step = p.steps[i].step; + let step_end_time; + if (i + 1 == p.steps.length) { + // This is the last step entry, we take the last update time to calculate the duration + step_end_time = p.last_update; + } else { + // Normal case is to take the next step entry + step_end_time = p.steps[i + 1].timestamp; + } + + let duration = + new Date(step_end_time).getTime() - + new Date(p.steps[i].timestamp).getTime(); + + if (stepTime[step - 1]) { + stepTime[step - 1] += duration; + } else { + stepTime[step - 1] = duration; + } + } + }); + + this.stepCounts = stepProgressCount; + this.stepDurations = stepTime; + this.avgDuration = []; + this.totalDuration = 0; + + for (let i = 0; i < this.stepDurations.length; i++) { + const avgStepDurationInSeconds = Math.round( + this.stepDurations[i] / this.stepCounts[i] / 1000 + ); + this.avgDuration.push(avgStepDurationInSeconds); + this.totalDuration += avgStepDurationInSeconds; + } + + this.processData(); + } + + private processData() { + this.prepareBarchartDatasets(); + this.updateBarchartData(); + this.chart?.update(); + } + + // events + public chartClicked({ + event, + active, + }: { + event?: ChartEvent; + active?: {}[]; + }): void { + // console.log(event, active); + } + + public chartHovered({ + event, + active, + }: { + event?: ChartEvent; + active?: {}[]; + }): void { + // console.log(event, active); + } + + private prepareBarchartDatasets() { + this.barChartData.datasets.length = 0; + this.barChartData.datasets.push({ + data: Array.from({ + length: this.barChartData.labels.length, + }).fill(0), + label: this.scenariosWithSessionMap.get(this.selectedScenario), + stack: 'a', + }); + } + + private updateBarchartData() { + if (this.barChartData.datasets.length === 0) { + // there are no scenarios selected and there is nothing to add ... so return! + return; + } + + this.barChartData.datasets[0].data = this.avgDuration; + + this.updateLabels(); + } + + // Info: Labels are not getting updated before calling this.chart.update() + private updateLabels() { + // We need to keep the same reference for our label array, otherwise label data is not updated correctly -> hence set length to 0 + this.barChartData.labels.length = 0; + for (let i = 0; i < this.avgDuration.length; i++) { + this.barChartData.labels.push('Step ' + (i + 1)); + } + } + + public formatDuration(duration: number) { + return durationFormatter(duration); + } + + public getNumberOfSessionsForStep(stepIndex: number) { + return this.stepCounts[stepIndex] ?? '-'; + } +} diff --git a/src/app/utils.ts b/src/app/utils.ts index 5df5f155..a9d6e7c4 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -1,29 +1,56 @@ -export function timeSince(date: Date, end: Date = new Date(), series: number = 2,) { - var seconds: number = Math.floor((end.getTime() - date.getTime()) / 1000); - var intervals = [31536000, 2592000, 86400, 3600, 60, 1]; - var intervalCounts = [0,0,0,0,0,0]; - var intervalStrings = ["year", "month", "day", "hour", "minute", "second"]; - var text = ""; +export function timeSince( + date: Date, + end: Date = new Date(), + series: number = 2 +) { + var seconds: number = Math.floor((end.getTime() - date.getTime()) / 1000); + var intervals = [31536000, 2592000, 86400, 3600, 60, 1]; + var intervalCounts = [0, 0, 0, 0, 0, 0]; + var intervalStrings = ['year', 'month', 'day', 'hour', 'minute', 'second']; + var text = ''; - for(let i = 0; i < intervals.length; i++){ - let interval = seconds / intervals[i]; - if (interval > 1) { - let count = Math.floor(interval) - intervalCounts[i] = count; - seconds -= count * intervals[i]; - continue; - } + for (let i = 0; i < intervals.length; i++) { + let interval = seconds / intervals[i]; + if (interval > 1) { + let count = Math.floor(interval); + intervalCounts[i] = count; + seconds -= count * intervals[i]; + continue; } + } - for(let i = 0; i < intervalCounts.length && series > 0; i++){ - if(intervalCounts[i] > 0 || i + 1 == intervalCounts.length){ - text += intervalCounts[i] + " " + intervalStrings[i] + (intervalCounts[i] != 1 ? "s" : ""); - series -= 1; - if(series > 0 && i + 1 != intervalCounts.length){ - text += ", "; - } + for (let i = 0; i < intervalCounts.length && series > 0; i++) { + if (intervalCounts[i] > 0 || i + 1 == intervalCounts.length) { + text += + intervalCounts[i] + + ' ' + + intervalStrings[i] + + (intervalCounts[i] != 1 ? 's' : ''); + series -= 1; + if (series > 0 && i + 1 != intervalCounts.length) { + text += ', '; } } + } + + return text; +} + +// Converts duration from a number to a text string +export function durationFormatter(duration: number) { + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + const seconds = duration % 60; - return text; -} \ No newline at end of file + let result = ''; + if (hours > 0) { + result += `${hours}h`; + } + if (minutes > 0 || (hours > 0 && seconds > 0)) { + result += `${minutes}m`; + } + if (seconds > 0) { + result += `${seconds}s`; + } + return result || '0s'; +}