From 5759349390271017afa64c4b1b76b5ac1a4fc722 Mon Sep 17 00:00:00 2001 From: Wilson Kurniawan Date: Sat, 2 Apr 2022 22:37:04 +0800 Subject: [PATCH] [#11618] Usage statistics page for maintainers (#11691) --- .../GenerateUsageStatisticsObjects.java | 56 ++++ .../ui/webapi/GetTimeZonesAction.java | 14 +- .../app/pages-admin/admin-page.component.ts | 4 + src/web/app/pages-admin/admin-pages.module.ts | 14 +- .../maintainer-page.component.ts | 8 + .../maintainer-page.module.ts | 17 +- .../logs-page.component.spec.ts.snap | 0 .../logs-page}/logs-page.component.html | 0 .../logs-page}/logs-page.component.scss | 0 .../logs-page}/logs-page.component.spec.ts | 8 +- .../logs-page}/logs-page.component.ts | 22 +- .../logs-page}/logs-page.module.ts | 8 +- .../timezone-page.component.html} | 0 .../timezone-page.component.scss} | 0 .../timezone-page.component.spec.ts} | 12 +- .../timezone-page/timezone-page.component.ts} | 8 +- .../timezone-page/timezone-page.module.ts} | 10 +- ...age-statistics-page.component.spec.ts.snap | 287 ++++++++++++++++++ .../stats-line-chart.component.html | 2 + .../stats-line-chart.component.scss | 10 + .../stats-line-chart.component.spec.ts | 25 ++ .../stats-line-chart.component.ts | 150 +++++++++ .../usage-statistics-page.component.html | 69 +++++ .../usage-statistics-page.component.scss | 7 + .../usage-statistics-page.component.spec.ts | 227 ++++++++++++++ .../usage-statistics-page.component.ts | 210 +++++++++++++ .../usage-statistics-page.module.ts | 37 +++ .../services/usage-statistics.service.spec.ts | 21 ++ src/web/services/usage-statistics.service.ts | 26 ++ 29 files changed, 1212 insertions(+), 40 deletions(-) create mode 100644 src/client/java/teammates/client/scripts/GenerateUsageStatisticsObjects.java rename src/web/app/{pages-logs => pages-monitoring/logs-page}/__snapshots__/logs-page.component.spec.ts.snap (100%) rename src/web/app/{pages-logs => pages-monitoring/logs-page}/logs-page.component.html (100%) rename src/web/app/{pages-logs => pages-monitoring/logs-page}/logs-page.component.scss (100%) rename src/web/app/{pages-logs => pages-monitoring/logs-page}/logs-page.component.spec.ts (97%) rename src/web/app/{pages-logs => pages-monitoring/logs-page}/logs-page.component.ts (95%) rename src/web/app/{pages-logs => pages-monitoring/logs-page}/logs-page.module.ts (69%) rename src/web/app/{pages-admin/admin-timezone-page/admin-timezone-page.component.html => pages-monitoring/timezone-page/timezone-page.component.html} (100%) rename src/web/app/{pages-admin/admin-timezone-page/admin-timezone-page.component.scss => pages-monitoring/timezone-page/timezone-page.component.scss} (100%) rename src/web/app/{pages-admin/admin-timezone-page/admin-timezone-page.component.spec.ts => pages-monitoring/timezone-page/timezone-page.component.spec.ts} (64%) rename src/web/app/{pages-admin/admin-timezone-page/admin-timezone-page.component.ts => pages-monitoring/timezone-page/timezone-page.component.ts} (82%) rename src/web/app/{pages-admin/admin-timezone-page/admin-timezone-page.module.ts => pages-monitoring/timezone-page/timezone-page.module.ts} (68%) create mode 100644 src/web/app/pages-monitoring/usage-stats-page/__snapshots__/usage-statistics-page.component.spec.ts.snap create mode 100644 src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.html create mode 100644 src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.scss create mode 100644 src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.spec.ts create mode 100644 src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.ts create mode 100644 src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.html create mode 100644 src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.scss create mode 100644 src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.spec.ts create mode 100644 src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.ts create mode 100644 src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.module.ts create mode 100644 src/web/services/usage-statistics.service.spec.ts create mode 100644 src/web/services/usage-statistics.service.ts diff --git a/src/client/java/teammates/client/scripts/GenerateUsageStatisticsObjects.java b/src/client/java/teammates/client/scripts/GenerateUsageStatisticsObjects.java new file mode 100644 index 00000000000..caa12f59605 --- /dev/null +++ b/src/client/java/teammates/client/scripts/GenerateUsageStatisticsObjects.java @@ -0,0 +1,56 @@ +package teammates.client.scripts; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Random; + +import teammates.client.connector.DatastoreClient; +import teammates.common.datatransfer.attributes.UsageStatisticsAttributes; +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.TimeHelper; +import teammates.logic.api.Logic; + +/** + * Generates usage statistics objects, mostly for testing purpose. + */ +public class GenerateUsageStatisticsObjects extends DatastoreClient { + + // Enough number of statistics for one whole month + private static final int NUM_OF_STATISTICS_OBJECTS = 24 * 31; + + private final Logic logic = Logic.inst(); + + public static void main(String[] args) { + new GenerateUsageStatisticsObjects().doOperationRemotely(); + } + + @Override + protected void doOperation() { + Instant inst = Instant.now(); + Random rand = new Random(); + + for (int i = 1; i <= NUM_OF_STATISTICS_OBJECTS; i++) { + Instant endTime = TimeHelper.getInstantNearestHourBefore(inst); + Instant startTime = endTime.minus(60, ChronoUnit.MINUTES); + + UsageStatisticsAttributes stats = UsageStatisticsAttributes.builder(startTime, 60) + .withNumResponses(rand.nextInt(500)) + .withNumCourses(rand.nextInt(8)) + .withNumStudents(rand.nextInt(200)) + .withNumInstructors(rand.nextInt(15)) + .withNumAccountRequests(rand.nextInt(5)) + .withNumEmails(rand.nextInt(400)) + .withNumSubmissions(rand.nextInt(300)) + .build(); + try { + logic.createUsageStatistics(stats); + } catch (EntityAlreadyExistsException | InvalidParametersException e) { + e.printStackTrace(); + } + + inst = inst.minus(1, ChronoUnit.HOURS); + } + } + +} diff --git a/src/main/java/teammates/ui/webapi/GetTimeZonesAction.java b/src/main/java/teammates/ui/webapi/GetTimeZonesAction.java index d2731799f5d..c1f9bbc4385 100644 --- a/src/main/java/teammates/ui/webapi/GetTimeZonesAction.java +++ b/src/main/java/teammates/ui/webapi/GetTimeZonesAction.java @@ -11,7 +11,19 @@ /** * Action: get supported time zones. */ -class GetTimeZonesAction extends AdminOnlyAction { +class GetTimeZonesAction extends Action { + + @Override + AuthType getMinAuthLevel() { + return AuthType.LOGGED_IN; + } + + @Override + void checkSpecificAccessControl() throws UnauthorizedAccessException { + if (!userInfo.isMaintainer && !userInfo.isAdmin) { + throw new UnauthorizedAccessException("Only Maintainers or Admin are allowed to access this resource."); + } + } @Override public JsonResult execute() { diff --git a/src/web/app/pages-admin/admin-page.component.ts b/src/web/app/pages-admin/admin-page.component.ts index 35e62491766..96ee586ff0f 100644 --- a/src/web/app/pages-admin/admin-page.component.ts +++ b/src/web/app/pages-admin/admin-page.component.ts @@ -41,6 +41,10 @@ export class AdminPageComponent implements OnInit { url: '/web/admin/logs', display: 'Logs', }, + { + url: '/web/admin/stats', + display: 'Usage Statistics', + }, ]; isFetchingAuthDetails: boolean = false; diff --git a/src/web/app/pages-admin/admin-pages.module.ts b/src/web/app/pages-admin/admin-pages.module.ts index 862b5e4ae66..fddd6180fbe 100644 --- a/src/web/app/pages-admin/admin-pages.module.ts +++ b/src/web/app/pages-admin/admin-pages.module.ts @@ -39,17 +39,25 @@ const routes: Routes = [ }, { path: 'timezone', - loadChildren: () => import('./admin-timezone-page/admin-timezone-page.module') - .then((m: any) => m.AdminTimezonePageModule), + loadChildren: () => import('../pages-monitoring/timezone-page/timezone-page.module') + .then((m: any) => m.TimezonePageModule), }, { path: 'logs', data: { isAdmin: true, }, - loadChildren: () => import('../pages-logs/logs-page.module') + loadChildren: () => import('../pages-monitoring/logs-page/logs-page.module') .then((m: any) => m.LogsPageModule), }, + { + path: 'stats', + loadChildren: () => import('../pages-monitoring/usage-stats-page/usage-statistics-page.module') + .then((m: any) => m.UsageStatisticsPageModule), + data: { + pageTitle: 'Usage Statistics', + }, + }, { path: '', pathMatch: 'full', diff --git a/src/web/app/pages-maintainer/maintainer-page.component.ts b/src/web/app/pages-maintainer/maintainer-page.component.ts index 815e1ba62a6..aa8efd24669 100644 --- a/src/web/app/pages-maintainer/maintainer-page.component.ts +++ b/src/web/app/pages-maintainer/maintainer-page.component.ts @@ -25,6 +25,14 @@ export class MaintainerPageComponent implements OnInit { url: '/web/maintainer', display: 'Home', }, + { + url: '/web/maintainer/timezone', + display: 'Timezone Listing', + }, + { + url: '/web/maintainer/stats', + display: 'Usage Statistics', + }, ]; isFetchingAuthDetails: boolean = false; diff --git a/src/web/app/pages-maintainer/maintainer-page.module.ts b/src/web/app/pages-maintainer/maintainer-page.module.ts index 4956429d0b3..3e56efb6d5a 100644 --- a/src/web/app/pages-maintainer/maintainer-page.module.ts +++ b/src/web/app/pages-maintainer/maintainer-page.module.ts @@ -3,7 +3,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { PageNotFoundComponent } from '../page-not-found/page-not-found.component'; import { PageNotFoundModule } from '../page-not-found/page-not-found.module'; -import { LogsPageComponent } from '../pages-logs/logs-page.component'; const routes: Routes = [ { @@ -11,7 +10,21 @@ const routes: Routes = [ data: { isAdmin: false, }, - component: LogsPageComponent, + loadChildren: () => import('../pages-monitoring/logs-page/logs-page.module') + .then((m: any) => m.LogsPageModule), + }, + { + path: 'timezone', + loadChildren: () => import('../pages-monitoring/timezone-page/timezone-page.module') + .then((m: any) => m.TimezonePageModule), + }, + { + path: 'stats', + loadChildren: () => import('../pages-monitoring/usage-stats-page/usage-statistics-page.module') + .then((m: any) => m.UsageStatisticsPageModule), + data: { + pageTitle: 'Usage Statistics', + }, }, { path: '', diff --git a/src/web/app/pages-logs/__snapshots__/logs-page.component.spec.ts.snap b/src/web/app/pages-monitoring/logs-page/__snapshots__/logs-page.component.spec.ts.snap similarity index 100% rename from src/web/app/pages-logs/__snapshots__/logs-page.component.spec.ts.snap rename to src/web/app/pages-monitoring/logs-page/__snapshots__/logs-page.component.spec.ts.snap diff --git a/src/web/app/pages-logs/logs-page.component.html b/src/web/app/pages-monitoring/logs-page/logs-page.component.html similarity index 100% rename from src/web/app/pages-logs/logs-page.component.html rename to src/web/app/pages-monitoring/logs-page/logs-page.component.html diff --git a/src/web/app/pages-logs/logs-page.component.scss b/src/web/app/pages-monitoring/logs-page/logs-page.component.scss similarity index 100% rename from src/web/app/pages-logs/logs-page.component.scss rename to src/web/app/pages-monitoring/logs-page/logs-page.component.scss diff --git a/src/web/app/pages-logs/logs-page.component.spec.ts b/src/web/app/pages-monitoring/logs-page/logs-page.component.spec.ts similarity index 97% rename from src/web/app/pages-logs/logs-page.component.spec.ts rename to src/web/app/pages-monitoring/logs-page/logs-page.component.spec.ts index 1750d620b4a..468e594cdf3 100644 --- a/src/web/app/pages-logs/logs-page.component.spec.ts +++ b/src/web/app/pages-monitoring/logs-page/logs-page.component.spec.ts @@ -4,10 +4,10 @@ import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { of } from 'rxjs'; import SpyInstance = jest.SpyInstance; -import { LogService } from '../../services/log.service'; -import { StatusMessageService } from '../../services/status-message.service'; -import { TimezoneService } from '../../services/timezone.service'; -import { GeneralLogEntry, LogEvent, LogSeverity } from '../../types/api-output'; +import { LogService } from '../../../services/log.service'; +import { StatusMessageService } from '../../../services/status-message.service'; +import { TimezoneService } from '../../../services/timezone.service'; +import { GeneralLogEntry, LogEvent, LogSeverity } from '../../../types/api-output'; import { LogsPageComponent } from './logs-page.component'; import { LogsPageModule } from './logs-page.module'; diff --git a/src/web/app/pages-logs/logs-page.component.ts b/src/web/app/pages-monitoring/logs-page/logs-page.component.ts similarity index 95% rename from src/web/app/pages-logs/logs-page.component.ts rename to src/web/app/pages-monitoring/logs-page/logs-page.component.ts index 06bb52c2c2c..df1cbb9b475 100644 --- a/src/web/app/pages-logs/logs-page.component.ts +++ b/src/web/app/pages-monitoring/logs-page/logs-page.component.ts @@ -2,10 +2,10 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { EMPTY } from 'rxjs'; import { expand, finalize, reduce, tap } from 'rxjs/operators'; -import { LogService } from '../../services/log.service'; -import { StatusMessageService } from '../../services/status-message.service'; -import { TimezoneService } from '../../services/timezone.service'; -import { ApiConst } from '../../types/api-const'; +import { LogService } from '../../../services/log.service'; +import { StatusMessageService } from '../../../services/status-message.service'; +import { TimezoneService } from '../../../services/timezone.service'; +import { ApiConst } from '../../../types/api-const'; import { ActionClasses, GeneralLogEntry, @@ -15,13 +15,13 @@ import { QueryLogsParams, RequestLogUser, SourceLocation, -} from '../../types/api-output'; -import { DateFormat } from '../components/datepicker/datepicker.component'; -import { LogsHistogramDataModel } from '../components/logs-histogram/logs-histogram-model'; -import { LogsTableRowModel } from '../components/logs-table/logs-table-model'; -import { collapseAnim } from '../components/teammates-common/collapse-anim'; -import { TimeFormat } from '../components/timepicker/timepicker.component'; -import { ErrorMessageOutput } from '../error-message-output'; +} from '../../../types/api-output'; +import { DateFormat } from '../../components/datepicker/datepicker.component'; +import { LogsHistogramDataModel } from '../../components/logs-histogram/logs-histogram-model'; +import { LogsTableRowModel } from '../../components/logs-table/logs-table-model'; +import { collapseAnim } from '../../components/teammates-common/collapse-anim'; +import { TimeFormat } from '../../components/timepicker/timepicker.component'; +import { ErrorMessageOutput } from '../../error-message-output'; /** * Model for searching of logs. diff --git a/src/web/app/pages-logs/logs-page.module.ts b/src/web/app/pages-monitoring/logs-page/logs-page.module.ts similarity index 69% rename from src/web/app/pages-logs/logs-page.module.ts rename to src/web/app/pages-monitoring/logs-page/logs-page.module.ts index 670d7152e1c..ce988f2cc5e 100644 --- a/src/web/app/pages-logs/logs-page.module.ts +++ b/src/web/app/pages-monitoring/logs-page/logs-page.module.ts @@ -3,10 +3,10 @@ import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; -import { LoadingSpinnerModule } from '../components/loading-spinner/loading-spinner.module'; -import { LogsHistogramModule } from '../components/logs-histogram/logs-histogram.module'; -import { LogsTableModule } from '../components/logs-table/logs-table.module'; -import { SortableTableModule } from '../components/sortable-table/sortable-table.module'; +import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; +import { LogsHistogramModule } from '../../components/logs-histogram/logs-histogram.module'; +import { LogsTableModule } from '../../components/logs-table/logs-table.module'; +import { SortableTableModule } from '../../components/sortable-table/sortable-table.module'; import { LogsPageComponent } from './logs-page.component'; const routes: Routes = [ diff --git a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.html b/src/web/app/pages-monitoring/timezone-page/timezone-page.component.html similarity index 100% rename from src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.html rename to src/web/app/pages-monitoring/timezone-page/timezone-page.component.html diff --git a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.scss b/src/web/app/pages-monitoring/timezone-page/timezone-page.component.scss similarity index 100% rename from src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.scss rename to src/web/app/pages-monitoring/timezone-page/timezone-page.component.scss diff --git a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.spec.ts b/src/web/app/pages-monitoring/timezone-page/timezone-page.component.spec.ts similarity index 64% rename from src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.spec.ts rename to src/web/app/pages-monitoring/timezone-page/timezone-page.component.spec.ts index e8010f458cc..4025dcaa0e8 100644 --- a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.spec.ts +++ b/src/web/app/pages-monitoring/timezone-page/timezone-page.component.spec.ts @@ -1,15 +1,15 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; -import { AdminTimezonePageComponent } from './admin-timezone-page.component'; +import { TimezonePageComponent } from './timezone-page.component'; -describe('AdminTimezonePageComponent', () => { - let component: AdminTimezonePageComponent; - let fixture: ComponentFixture; +describe('TimezonePageComponent', () => { + let component: TimezonePageComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [AdminTimezonePageComponent], + declarations: [TimezonePageComponent], imports: [ HttpClientTestingModule, LoadingSpinnerModule, @@ -19,7 +19,7 @@ describe('AdminTimezonePageComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(AdminTimezonePageComponent); + fixture = TestBed.createComponent(TimezonePageComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.ts b/src/web/app/pages-monitoring/timezone-page/timezone-page.component.ts similarity index 82% rename from src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.ts rename to src/web/app/pages-monitoring/timezone-page/timezone-page.component.ts index e776782527d..742ef1758dd 100644 --- a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.component.ts +++ b/src/web/app/pages-monitoring/timezone-page/timezone-page.component.ts @@ -7,11 +7,11 @@ import { TimeZones } from '../../../types/api-output'; * Timezone listing page for admin use. */ @Component({ - selector: 'tm-admin-timezone-page', - templateUrl: './admin-timezone-page.component.html', - styleUrls: ['./admin-timezone-page.component.scss'], + selector: 'tm-timezone-page', + templateUrl: './timezone-page.component.html', + styleUrls: ['./timezone-page.component.scss'], }) -export class AdminTimezonePageComponent implements OnInit { +export class TimezonePageComponent implements OnInit { javaTzVersion: string = ''; javaTimezones: Record = {}; diff --git a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.module.ts b/src/web/app/pages-monitoring/timezone-page/timezone-page.module.ts similarity index 68% rename from src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.module.ts rename to src/web/app/pages-monitoring/timezone-page/timezone-page.module.ts index ca3b1d5cde1..f79e55b16b7 100644 --- a/src/web/app/pages-admin/admin-timezone-page/admin-timezone-page.module.ts +++ b/src/web/app/pages-monitoring/timezone-page/timezone-page.module.ts @@ -2,12 +2,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; -import { AdminTimezonePageComponent } from './admin-timezone-page.component'; +import { TimezonePageComponent } from './timezone-page.component'; const routes: Routes = [ { path: '', - component: AdminTimezonePageComponent, + component: TimezonePageComponent, }, ]; @@ -16,10 +16,10 @@ const routes: Routes = [ */ @NgModule({ declarations: [ - AdminTimezonePageComponent, + TimezonePageComponent, ], exports: [ - AdminTimezonePageComponent, + TimezonePageComponent, ], imports: [ CommonModule, @@ -27,4 +27,4 @@ const routes: Routes = [ LoadingSpinnerModule, ], }) -export class AdminTimezonePageModule { } +export class TimezonePageModule { } diff --git a/src/web/app/pages-monitoring/usage-stats-page/__snapshots__/usage-statistics-page.component.spec.ts.snap b/src/web/app/pages-monitoring/usage-stats-page/__snapshots__/usage-statistics-page.component.spec.ts.snap new file mode 100644 index 00000000000..c74a1577355 --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/__snapshots__/usage-statistics-page.component.spec.ts.snap @@ -0,0 +1,287 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UsageStatisticsPageComponent should snap with default fields 1`] = ` + +

+ Data available from: 1 January 2016, 00:00 UTC +

+
+
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+ : +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+ : +
+
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+
+`; diff --git a/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.html b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.html new file mode 100644 index 00000000000..42ff3871906 --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.html @@ -0,0 +1,2 @@ +
+
diff --git a/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.scss b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.scss new file mode 100644 index 00000000000..21449dcc5b7 --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.scss @@ -0,0 +1,10 @@ +div.tooltip { + position: absolute; + text-align: left; + padding: 2px; + font: 12px sans-serif; + background: lightsteelblue; + border: 0; + border-radius: 8px; + pointer-events: none; +} diff --git a/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.spec.ts b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.spec.ts new file mode 100644 index 00000000000..b487989430a --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { StatsLineChartComponent } from './stats-line-chart.component'; + +describe('StatsLineChartComponent', () => { + let component: StatsLineChartComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [StatsLineChartComponent], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StatsLineChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.ts b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.ts new file mode 100644 index 00000000000..65a6b991b7b --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/stats-line-chart/stats-line-chart.component.ts @@ -0,0 +1,150 @@ +import { Component, ElementRef, Input, OnChanges, SimpleChanges } from '@angular/core'; +import * as d3 from 'd3'; +import { DataPoint } from '../usage-statistics-page.component'; + +/** + * Line chart for the statistics. + * + * Adapted from: https://medium.com/weekly-webtips/build-a-simple-line-chart-with-d3-js-in-angular-ccd06e328bff + */ +@Component({ + selector: 'tm-stats-line-chart', + templateUrl: './stats-line-chart.component.html', + styleUrls: ['./stats-line-chart.component.scss'], +}) +export class StatsLineChartComponent implements OnChanges { + + @Input() + data!: DataPoint[]; + + @Input() + timeRange: { startTime: number, endTime: number } = { startTime: 0, endTime: 0 }; + + @Input() + dataName!: string; + + private width = 700; + private height = 700; + private margin = 50; + private svg: any; + private svgInner: any; + private yScale: any; + private xScale: any; + private xAxis: any; + private yAxis: any; + private lineGroup: any; + + constructor(private chartElem: ElementRef) { } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.data && this.data && this.timeRange) { + this.initializeChart(); + this.drawChart(); + + window.addEventListener('resize', () => this.drawChart()); + } + } + + private initializeChart(): void { + if (this.svg) { + d3.select('svg').remove(); + } + const startTime = new Date(this.timeRange.startTime); + const endTime = new Date(this.timeRange.endTime); + this.svg = d3 + .select(this.chartElem.nativeElement) + .select('.line-chart') + .append('svg') + .attr('height', this.height); + + this.svgInner = this.svg + .append('g') + .style('transform', `translate(${this.margin}px, ${this.margin}px)`); + + this.yScale = d3 + .scaleLinear() + .domain([ + d3.max(this.data, (d: DataPoint) => d.value) + 1, + d3.min(this.data, (d: DataPoint) => d.value) - 1, + ]) + .range([0, this.height - 2 * this.margin]); + + this.xScale = d3 + .scaleTime() + .domain([startTime, endTime]); + + this.yAxis = this.svgInner + .append('g') + .attr('id', 'y-axis') + .style('transform', `translate(${this.margin}px, 0)`); + + this.xAxis = this.svgInner + .append('g') + .attr('id', 'x-axis') + .style('transform', `translate(0, ${this.height - 2 * this.margin}px)`); + + this.lineGroup = this.svgInner + .append('g') + .append('path') + .attr('id', 'line') + .style('fill', 'none') + .style('stroke', '#007BFF') + .style('stroke-width', '2px'); + } + + private drawChart(): void { + this.width = this.chartElem.nativeElement.getBoundingClientRect().width; + this.svg.attr('width', this.width); + + this.xScale.range([this.margin, this.width - 2 * this.margin]); + + const xAxis = d3 + .axisBottom(this.xScale) + .ticks(10) + .tickFormat(d3.timeFormat('%b %d, %H:%M')); + + this.xAxis.call(xAxis); + + const yAxis = d3 + .axisLeft(this.yScale); + + this.yAxis.call(yAxis); + + const line = d3 + .line() + .x((d: number[]) => d[0]) + .y((d: number[]) => d[1]); + + const points: [number, number][] = this.data.map((d: DataPoint) => [ + this.xScale(new Date(d.date)), + this.yScale(d.value), + ]); + + this.lineGroup.attr('d', line(points)); + + const div = d3.select('div.tooltip'); + this.svgInner + .selectAll('dot') + .data(this.data) + .enter() + .append('circle') + .attr('r', 3) + .attr('cx', (d: any) => this.xScale(new Date(d.date))) + .attr('cy', (d: any) => this.yScale(d.value)) + .attr('fill', '#FFC107') + .on('mouseover', (d: any) => { + div.transition() + .duration(200) + .style('opacity', 0.9); + div.html(`Time: ${new Date(d.date).toString()}
New ${this.dataName} count: ${d.value}`) + .style('left', `${d3.event.pageX}px`) + .style('top', `${d3.event.pageY - 32}px`); + }) + .on('mouseout', () => { + div.transition() + .duration(500) + .style('opacity', 0); + }); + } + +} diff --git a/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.html b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.html new file mode 100644 index 00000000000..81ec96774ef --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.html @@ -0,0 +1,69 @@ +

Data available from: 1 January 2016, 00:00 UTC

+
+
+
+
+
+ +
+
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+ + +
+
+ +
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+ +
+ +
diff --git a/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.scss b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.scss new file mode 100644 index 00000000000..bb2987bafa0 --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.scss @@ -0,0 +1,7 @@ +.bg-form { + background-color: #EAEFF5; +} + +.no-border { + border: 0; +} diff --git a/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.spec.ts b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.spec.ts new file mode 100644 index 00000000000..f98900a353c --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.spec.ts @@ -0,0 +1,227 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { UsageStatisticsService } from '../../../services/usage-statistics.service'; +import { UsageStatistics } from '../../../types/api-output'; +import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; +import { StatsLineChartComponent } from './stats-line-chart/stats-line-chart.component'; +import { AggregationType, StatisticsType, UsageStatisticsPageComponent } from './usage-statistics-page.component'; + +const generateData = (startTime: number, iterations: number): UsageStatistics[] => { + const stats = []; + let time = startTime; + for (let i = 1; i <= iterations; i += 1) { + stats.push({ + startTime: time, + timePeriod: 60, + numResponses: i, + numCourses: 0, + numStudents: 0, + numInstructors: 0, + numAccountRequests: 0, + numEmails: 0, + numSubmissions: 0, + }); + time += 60 * 60 * 1000; + } + return stats; +}; + +describe('UsageStatisticsPageComponent', () => { + let component: UsageStatisticsPageComponent; + let fixture: ComponentFixture; + let usageStatisticsService: UsageStatisticsService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + UsageStatisticsPageComponent, + StatsLineChartComponent, + ], + imports: [ + NgbDatepickerModule, + NgbTimepickerModule, + FormsModule, + HttpClientTestingModule, + LoadingSpinnerModule, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UsageStatisticsPageComponent); + component = fixture.componentInstance; + usageStatisticsService = TestBed.inject(UsageStatisticsService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should snap with default fields', () => { + expect(fixture).toMatchSnapshot(); + }); + + it('should fetch usage statistics successfully', () => { + component.timezone = 'UTC'; + component.formModel = { + fromDate: { year: 2022, month: 3, day: 27 }, + fromTime: { hour: 22, minute: 45 }, + toDate: { year: 2022, month: 3, day: 28 }, + toTime: { hour: 0, minute: 15 }, + dataType: StatisticsType.NUM_RESPONSES, + aggregationType: AggregationType.HOURLY, + }; + + const statsObjects: UsageStatistics[] = [{ + startTime: new Date('2022-03-27T23:00:00Z').getTime(), + timePeriod: 60, + numResponses: 100, + numCourses: 3, + numStudents: 2, + numInstructors: 2, + numAccountRequests: 1, + numEmails: 50, + numSubmissions: 99, + }, { + startTime: new Date('2022-03-28T00:00:00Z').getTime(), + timePeriod: 60, + numResponses: 400, + numCourses: 1, + numStudents: 1, + numInstructors: 5, + numAccountRequests: 2, + numEmails: 61, + numSubmissions: 71, + }]; + + const spy = jest.spyOn(usageStatisticsService, 'getUsageStatistics').mockReturnValue(of({ + result: statsObjects, + })); + + fixture.detectChanges(); + + component.getUsageStatistics(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(component.hasQueried).toBe(true); + expect(component.isLoading).toBe(false); + expect(component.fetchedData).toEqual(statsObjects); + expect(component.timeRange).toEqual({ + startTime: new Date('2022-03-27T22:45:00Z').getTime(), + endTime: new Date('2022-03-28T00:15:00Z').getTime(), + }); + }); + + it('should process fetched data correctly', () => { + component.formModel.dataType = StatisticsType.NUM_RESPONSES; + component.formModel.aggregationType = AggregationType.HOURLY; + const startTime = new Date('2022-01-27T22:00:00Z').getTime(); + const generatedData = generateData(startTime, 3 * 24); + component.fetchedData = generatedData; + + fixture.detectChanges(); + + component.drawLineChart(); + + expect(component.itemName).toEqual('responses'); + expect(component.dataToDraw).toEqual(generatedData.map((us: UsageStatistics) => ({ + value: us.numResponses, + date: new Date(us.startTime).toISOString(), + }))); + }); + + it('should aggregate data by day correctly', () => { + component.formModel.dataType = StatisticsType.NUM_RESPONSES; + component.formModel.aggregationType = AggregationType.DAILY; + const startTime = new Date('2022-01-27T22:00:00Z').getTime(); + component.fetchedData = generateData(startTime, 10 * 24); + + fixture.detectChanges(); + + component.drawLineChart(); + + const expectedDataToDraw = [ + { + date: '2022-01-27T22:00:00.000Z', + value: 3, + }, + { + date: '2022-01-28T00:00:00.000Z', + value: 348, + }, + { + date: '2022-01-29T00:00:00.000Z', + value: 924, + }, + { + date: '2022-01-30T00:00:00.000Z', + value: 1500, + }, + { + date: '2022-01-31T00:00:00.000Z', + value: 2076, + }, + { + date: '2022-02-01T00:00:00.000Z', + value: 2652, + }, + { + date: '2022-02-02T00:00:00.000Z', + value: 3228, + }, + { + date: '2022-02-03T00:00:00.000Z', + value: 3804, + }, + { + date: '2022-02-04T00:00:00.000Z', + value: 4380, + }, + { + date: '2022-02-05T00:00:00.000Z', + value: 4956, + }, + { + date: '2022-02-06T00:00:00.000Z', + value: 5049, + }, + ]; + + expect(component.itemName).toEqual('responses'); + expect(component.dataToDraw).toEqual(expectedDataToDraw); + }); + + it('should force apply aggregation if there are too many data points', () => { + component.formModel.dataType = StatisticsType.NUM_STUDENTS; + component.formModel.aggregationType = AggregationType.HOURLY; + const startTime = new Date('2022-01-27T22:00:00Z').getTime(); + component.fetchedData = generateData(startTime, 31 * 24); + + fixture.detectChanges(); + + component.drawLineChart(); + + expect(component.itemName).toEqual('students'); + expect(component.dataToDraw.length).toEqual(32); + }); + + it('should not apply aggregation if there are too few data points', () => { + component.formModel.dataType = StatisticsType.NUM_COURSES; + component.formModel.aggregationType = AggregationType.DAILY; + const startTime = new Date('2022-01-27T22:00:00Z').getTime(); + component.fetchedData = generateData(startTime, 6 * 24); + + fixture.detectChanges(); + + component.drawLineChart(); + + expect(component.itemName).toEqual('courses'); + expect(component.dataToDraw.length).toEqual(144); + }); + +}); diff --git a/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.ts b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.ts new file mode 100644 index 00000000000..2d340dcb69d --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.component.ts @@ -0,0 +1,210 @@ +import { Component, OnInit } from '@angular/core'; +import { StatusMessageService } from '../../../services/status-message.service'; +import { TimezoneService } from '../../../services/timezone.service'; +import { UsageStatisticsService } from '../../../services/usage-statistics.service'; +import { UsageStatistics, UsageStatisticsRange } from '../../../types/api-output'; +import { DateFormat } from '../../components/datepicker/datepicker.component'; +import { TimeFormat } from '../../components/timepicker/timepicker.component'; +import { ErrorMessageOutput } from '../../error-message-output'; + +export enum StatisticsType { + NUM_RESPONSES, + NUM_COURSES, + NUM_STUDENTS, + NUM_INSTRUCTORS, + NUM_ACCOUNT_REQUESTS, + NUM_EMAILS, + NUM_SUBMISSIONS, +} + +export enum AggregationType { + HOURLY, + DAILY, +} + +interface FormQueryModel { + fromDate: DateFormat; + fromTime: TimeFormat; + toDate: DateFormat; + toTime: TimeFormat; + dataType: StatisticsType; + aggregationType: AggregationType; +} + +export interface DataPoint { + value: number; + date: string; +} + +/** + * Usage statistics page. + */ +@Component({ + selector: 'tm-usage-statistics-page', + templateUrl: './usage-statistics-page.component.html', + styleUrls: ['./usage-statistics-page.component.scss'], +}) +export class UsageStatisticsPageComponent implements OnInit { + + StatisticsType = StatisticsType; + AggregationType = AggregationType; + + itemName = 'responses'; + + formModel: FormQueryModel = { + fromDate: { year: 0, month: 0, day: 0 }, + fromTime: { hour: 0, minute: 0 }, + toDate: { year: 0, month: 0, day: 0 }, + toTime: { hour: 0, minute: 0 }, + dataType: StatisticsType.NUM_RESPONSES, + aggregationType: AggregationType.HOURLY, + }; + dateToday: DateFormat = { year: 0, month: 0, day: 0 }; + earliestSearchDate: DateFormat = { year: 2016, month: 1, day: 1 }; + timeRange: { startTime: number, endTime: number } = { startTime: 0, endTime: 0 }; + hasQueried = false; + isLoading = false; + fetchedData: UsageStatistics[] = []; + dataToDraw: DataPoint[] = []; + timezone = 'UTC'; + + constructor( + private usageStatisticsService: UsageStatisticsService, + private timezoneService: TimezoneService, + private statusMessageService: StatusMessageService, + ) {} + + ngOnInit(): void { + this.timezone = this.timezoneService.guessTimezone(); + + const now = new Date(); + this.dateToday.year = now.getFullYear(); + this.dateToday.month = now.getMonth() + 1; + this.dateToday.day = now.getDate(); + + // Start with statistics from the past week + const fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + this.formModel.fromDate = { + year: fromDate.getFullYear(), + month: fromDate.getMonth() + 1, + day: fromDate.getDate(), + }; + this.formModel.toDate = { ...this.dateToday }; + this.formModel.fromTime = { hour: fromDate.getHours(), minute: fromDate.getMinutes() }; + this.formModel.toTime = { hour: now.getHours(), minute: now.getMinutes() }; + } + + getUsageStatistics(): void { + this.hasQueried = true; + this.isLoading = true; + const timestampFrom = this.timezoneService.resolveLocalDateTime( + this.formModel.fromDate, this.formModel.fromTime, this.timezone); + const timestampUntil = this.timezoneService.resolveLocalDateTime( + this.formModel.toDate, this.formModel.toTime, this.timezone); + this.usageStatisticsService.getUsageStatistics( + timestampFrom, timestampUntil, + ).subscribe((statsRange: UsageStatisticsRange) => { + this.timeRange = { + startTime: timestampFrom, + endTime: timestampUntil, + }; + this.fetchedData = statsRange.result; + this.drawLineChart(); + this.isLoading = false; + }, (e: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(e.error.message); + this.isLoading = false; + }); + } + + changeStatsType(type: StatisticsType): void { + this.formModel.dataType = type; + this.drawLineChart(); + } + + changeAggregationType(type: AggregationType): void { + this.formModel.aggregationType = type; + this.drawLineChart(); + } + + drawLineChart(): void { + if (!this.fetchedData.length) { + return; + } + if (+this.formModel.aggregationType === AggregationType.DAILY && this.fetchedData.length < 24 * 7) { + // Do not allow daily aggregation if there are too few data, e.g. less than one week + this.statusMessageService.showWarningToast('There is too little data to be aggregated daily.'); + this.formModel.aggregationType = AggregationType.HOURLY; + } else if (+this.formModel.aggregationType === AggregationType.HOURLY && this.fetchedData.length > 30 * 24) { + // Do not allow hourly aggregation if there are too many data, e.g. more than one month + this.statusMessageService.showWarningToast('There is too many data to be aggregated hourly.'); + this.formModel.aggregationType = AggregationType.DAILY; + } + const aggregateDaily = +this.formModel.aggregationType === AggregationType.DAILY; + let dataToDraw = this.fetchedData.map((statisticsObj: UsageStatistics) => { + let value: number; + switch (+this.formModel.dataType) { + case StatisticsType.NUM_RESPONSES: + value = statisticsObj.numResponses; + this.itemName = 'responses'; + break; + case StatisticsType.NUM_COURSES: + value = statisticsObj.numCourses; + this.itemName = 'courses'; + break; + case StatisticsType.NUM_STUDENTS: + value = statisticsObj.numStudents; + this.itemName = 'students'; + break; + case StatisticsType.NUM_INSTRUCTORS: + value = statisticsObj.numInstructors; + this.itemName = 'instructors'; + break; + case StatisticsType.NUM_ACCOUNT_REQUESTS: + value = statisticsObj.numAccountRequests; + this.itemName = 'account requests'; + break; + case StatisticsType.NUM_EMAILS: + value = statisticsObj.numEmails; + this.itemName = 'emails sent'; + break; + case StatisticsType.NUM_SUBMISSIONS: + value = statisticsObj.numSubmissions; + this.itemName = 'submissions'; + break; + default: + throw new Error('Unexpected statsType'); + } + return { + value, + date: new Date(statisticsObj.startTime).toISOString(), + }; + }); + + if (aggregateDaily) { + const aggregated: DataPoint[] = []; + let current: DataPoint = { + value: 0, + date: dataToDraw[0].date, + }; + for (const hourlyData of dataToDraw) { + const shouldChangeDate = new Date(hourlyData.date).getUTCHours() === 0; + if (shouldChangeDate) { + aggregated.push(current); + current = { + value: hourlyData.value, + date: hourlyData.date, + }; + } else { + current.value += hourlyData.value; + } + } + aggregated.push(current); + dataToDraw = aggregated; + } + + this.dataToDraw = dataToDraw; + } + +} diff --git a/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.module.ts b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.module.ts new file mode 100644 index 00000000000..d7306413bd9 --- /dev/null +++ b/src/web/app/pages-monitoring/usage-stats-page/usage-statistics-page.module.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; +import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; +import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; +import { StatsLineChartComponent } from './stats-line-chart/stats-line-chart.component'; +import { UsageStatisticsPageComponent } from './usage-statistics-page.component'; + +const routes: Routes = [ + { + path: '', + component: UsageStatisticsPageComponent, + }, +]; + +/** + * Module for usage statistics page. + */ +@NgModule({ + declarations: [ + UsageStatisticsPageComponent, + StatsLineChartComponent, + ], + exports: [ + UsageStatisticsPageComponent, + ], + imports: [ + CommonModule, + RouterModule.forChild(routes), + NgbDatepickerModule, + NgbTimepickerModule, + FormsModule, + LoadingSpinnerModule, + ], +}) +export class UsageStatisticsPageModule { } diff --git a/src/web/services/usage-statistics.service.spec.ts b/src/web/services/usage-statistics.service.spec.ts new file mode 100644 index 00000000000..d7d5317f04f --- /dev/null +++ b/src/web/services/usage-statistics.service.spec.ts @@ -0,0 +1,21 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { UsageStatisticsService } from './usage-statistics.service'; + +describe('UsageStatisticsService', () => { + let service: UsageStatisticsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + ], + }); + service = TestBed.inject(UsageStatisticsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + +}); diff --git a/src/web/services/usage-statistics.service.ts b/src/web/services/usage-statistics.service.ts new file mode 100644 index 00000000000..52ea9994711 --- /dev/null +++ b/src/web/services/usage-statistics.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ResourceEndpoints } from '../types/api-const'; +import { UsageStatisticsRange } from '../types/api-output'; +import { HttpRequestService } from './http-request.service'; + +/** + * Handles usage statistics provision. + */ +@Injectable({ + providedIn: 'root', +}) +export class UsageStatisticsService { + + constructor(private httpRequestService: HttpRequestService) { } + + getUsageStatistics(startTime: number, endTime: number): Observable { + const paramMap: Record = { + starttime: `${startTime}`, + endtime: `${endTime}`, + }; + + return this.httpRequestService.get(ResourceEndpoints.USAGE_STATISTICS, paramMap); + } + +}