From 7cf0cdc4ceffde8e4fc586aee7a27644300d25e0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 5 Jun 2022 19:00:20 +0200 Subject: [PATCH] Feature/add jobs of queue to admin control panel (#987) * Add jobs of queue to admin control panel * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/admin/admin.module.ts | 2 + .../src/app/admin/queue/queue.controller.ts | 41 ++++++++++ apps/api/src/app/admin/queue/queue.module.ts | 12 +++ apps/api/src/app/admin/queue/queue.service.ts | 32 ++++++++ apps/api/src/app/app.controller.ts | 7 +- apps/api/src/app/cache/cache.controller.ts | 26 ++++++- .../admin-jobs/admin-jobs.component.ts | 75 +++++++++++++++++++ .../app/components/admin-jobs/admin-jobs.html | 74 ++++++++++++++++++ .../admin-jobs/admin-jobs.module.ts | 13 ++++ .../app/components/admin-jobs/admin-jobs.scss | 5 ++ .../pages/admin/admin-page-routing.module.ts | 2 + .../src/app/pages/admin/admin-page.html | 3 +- .../src/app/pages/admin/admin-page.module.ts | 2 + .../src/app/pages/admin/admin-page.scss | 1 + apps/client/src/app/services/admin.service.ts | 5 ++ libs/common/src/lib/helper.ts | 4 + .../lib/interfaces/admin-jobs.interface.ts | 5 ++ libs/common/src/lib/interfaces/index.ts | 2 + 19 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/app/admin/queue/queue.controller.ts create mode 100644 apps/api/src/app/admin/queue/queue.module.ts create mode 100644 apps/api/src/app/admin/queue/queue.service.ts create mode 100644 apps/client/src/app/components/admin-jobs/admin-jobs.component.ts create mode 100644 apps/client/src/app/components/admin-jobs/admin-jobs.html create mode 100644 apps/client/src/app/components/admin-jobs/admin-jobs.module.ts create mode 100644 apps/client/src/app/components/admin-jobs/admin-jobs.scss create mode 100644 libs/common/src/lib/interfaces/admin-jobs.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a986f88..e6a9f261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the user id to the account page +- Added a new view with jobs of the queue to the admin control panel ### Changed diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 8e83236e..464ba706 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -11,6 +11,7 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; +import { QueueModule } from './queue/queue.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { AdminService } from './admin.service'; MarketDataModule, PrismaModule, PropertyModule, + QueueModule, SubscriptionModule, SymbolProfileModule ], diff --git a/apps/api/src/app/admin/queue/queue.controller.ts b/apps/api/src/app/admin/queue/queue.controller.ts new file mode 100644 index 00000000..16ec2efe --- /dev/null +++ b/apps/api/src/app/admin/queue/queue.controller.ts @@ -0,0 +1,41 @@ +import { AdminJobs } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + Controller, + Get, + HttpException, + Inject, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { QueueService } from './queue.service'; + +@Controller('admin/queue') +export class QueueController { + public constructor( + private readonly queueService: QueueService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get('jobs') + @UseGuards(AuthGuard('jwt')) + public async getJobs(): Promise { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.queueService.getJobs({}); + } +} diff --git a/apps/api/src/app/admin/queue/queue.module.ts b/apps/api/src/app/admin/queue/queue.module.ts new file mode 100644 index 00000000..62091f34 --- /dev/null +++ b/apps/api/src/app/admin/queue/queue.module.ts @@ -0,0 +1,12 @@ +import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; +import { Module } from '@nestjs/common'; + +import { QueueController } from './queue.controller'; +import { QueueService } from './queue.service'; + +@Module({ + controllers: [QueueController], + imports: [DataGatheringModule], + providers: [QueueService] +}) +export class QueueModule {} diff --git a/apps/api/src/app/admin/queue/queue.service.ts b/apps/api/src/app/admin/queue/queue.service.ts new file mode 100644 index 00000000..f8c5560e --- /dev/null +++ b/apps/api/src/app/admin/queue/queue.service.ts @@ -0,0 +1,32 @@ +import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; +import { AdminJobs } from '@ghostfolio/common/interfaces'; +import { InjectQueue } from '@nestjs/bull'; +import { Injectable } from '@nestjs/common'; +import { Queue } from 'bull'; + +@Injectable() +export class QueueService { + public constructor( + @InjectQueue(DATA_GATHERING_QUEUE) + private readonly dataGatheringQueue: Queue + ) {} + + public async getJobs({ + limit = 1000 + }: { + limit?: number; + }): Promise { + const jobs = await this.dataGatheringQueue.getJobs([ + 'active', + 'completed', + 'delayed', + 'failed', + 'paused', + 'waiting' + ]); + + return { + jobs: jobs.slice(0, limit) + }; + } +} diff --git a/apps/api/src/app/app.controller.ts b/apps/api/src/app/app.controller.ts index 43b37ad1..4d024f11 100644 --- a/apps/api/src/app/app.controller.ts +++ b/apps/api/src/app/app.controller.ts @@ -1,20 +1,15 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { Controller } from '@nestjs/common'; -import { RedisCacheService } from './redis-cache/redis-cache.service'; - @Controller() export class AppController { public constructor( - private readonly dataGatheringService: DataGatheringService, - private readonly redisCacheService: RedisCacheService + private readonly dataGatheringService: DataGatheringService ) { this.initialize(); } private async initialize() { - this.redisCacheService.reset(); - const isDataGatheringInProgress = await this.dataGatheringService.getIsInProgress(); diff --git a/apps/api/src/app/cache/cache.controller.ts b/apps/api/src/app/cache/cache.controller.ts index df535824..4783266b 100644 --- a/apps/api/src/app/cache/cache.controller.ts +++ b/apps/api/src/app/cache/cache.controller.ts @@ -1,9 +1,17 @@ import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; -import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; +import { + Controller, + HttpException, + Inject, + Post, + UseGuards +} from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @Controller('cache') export class CacheController { @@ -11,13 +19,23 @@ export class CacheController { private readonly cacheService: CacheService, private readonly redisCacheService: RedisCacheService, @Inject(REQUEST) private readonly request: RequestWithUser - ) { - this.redisCacheService.reset(); - } + ) {} @Post('flush') @UseGuards(AuthGuard('jwt')) public async flushCache(): Promise { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + this.redisCacheService.reset(); return this.cacheService.flush(); diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts b/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts new file mode 100644 index 00000000..ad94cb93 --- /dev/null +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts @@ -0,0 +1,75 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit +} from '@angular/core'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; +import { AdminJobs, User } from '@ghostfolio/common/interfaces'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-admin-jobs', + styleUrls: ['./admin-jobs.scss'], + templateUrl: './admin-jobs.html' +}) +export class AdminJobsComponent implements OnDestroy, OnInit { + public defaultDateTimeFormat: string; + public jobs: AdminJobs['jobs'] = []; + public user: User; + + private unsubscribeSubject = new Subject(); + + /** + * @constructor + */ + public constructor( + private adminService: AdminService, + private changeDetectorRef: ChangeDetectorRef, + private userService: UserService + ) { + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.defaultDateTimeFormat = getDateWithTimeFormatString( + this.user.settings.locale + ); + } + }); + } + + /** + * Initializes the controller + */ + public ngOnInit() { + this.fetchJobs(); + } + + public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) { + alert(JSON.stringify(aStacktrace, null, ' ')); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private fetchJobs() { + this.adminService + .fetchJobs() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ jobs }) => { + this.jobs = jobs; + + this.changeDetectorRef.markForCheck(); + }); + } +} diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.html b/apps/client/src/app/components/admin-jobs/admin-jobs.html new file mode 100644 index 00000000..c17bdcd2 --- /dev/null +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -0,0 +1,74 @@ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
#TypeData SourceSymbolCreatedFinishedStatus
{{ job.id }}{{ job.name }}{{ job.data?.dataSource }}{{ job.data?.symbol }} + {{ job.timestamp | date: defaultDateTimeFormat }} + + {{ job.finishedOn | date: defaultDateTimeFormat }} + + + + + + + + + + + +
+
+
+
diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.module.ts b/apps/client/src/app/components/admin-jobs/admin-jobs.module.ts new file mode 100644 index 00000000..d9659194 --- /dev/null +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.module.ts @@ -0,0 +1,13 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; + +import { AdminJobsComponent } from './admin-jobs.component'; + +@NgModule({ + declarations: [AdminJobsComponent], + imports: [CommonModule, MatButtonModule, MatMenuModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfAdminJobsModule {} diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.scss b/apps/client/src/app/components/admin-jobs/admin-jobs.scss new file mode 100644 index 00000000..b97d286c --- /dev/null +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.scss @@ -0,0 +1,5 @@ +@import '~apps/client/src/styles/ghostfolio-style'; + +:host { + display: block; +} diff --git a/apps/client/src/app/pages/admin/admin-page-routing.module.ts b/apps/client/src/app/pages/admin/admin-page-routing.module.ts index 64a42145..9c5b6fee 100644 --- a/apps/client/src/app/pages/admin/admin-page-routing.module.ts +++ b/apps/client/src/app/pages/admin/admin-page-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { AdminJobsComponent } from '@ghostfolio/client/components/admin-jobs/admin-jobs.component'; import { AdminMarketDataComponent } from '@ghostfolio/client/components/admin-market-data/admin-market-data.component'; import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-overview/admin-overview.component'; import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component'; @@ -14,6 +15,7 @@ const routes: Routes = [ canActivate: [AuthGuard], children: [ { path: '', redirectTo: 'overview', pathMatch: 'full' }, + { path: 'jobs', component: AdminJobsComponent }, { path: 'market-data', component: AdminMarketDataComponent }, { path: 'overview', component: AdminOverviewComponent }, { path: 'users', component: AdminUsersComponent } diff --git a/apps/client/src/app/pages/admin/admin-page.html b/apps/client/src/app/pages/admin/admin-page.html index fe63f5d4..9ecc38dc 100644 --- a/apps/client/src/app/pages/admin/admin-page.html +++ b/apps/client/src/app/pages/admin/admin-page.html @@ -5,7 +5,8 @@ *ngFor="let link of [ { iconName: 'reader-outline', path: 'overview' }, { iconName: 'people-outline', path: 'users' }, - { iconName: 'server-outline', path: 'market-data' } + { iconName: 'server-outline', path: 'market-data' }, + { iconName: 'flash-outline', path: 'jobs' } ]" #rla="routerLinkActive" mat-tab-link diff --git a/apps/client/src/app/pages/admin/admin-page.module.ts b/apps/client/src/app/pages/admin/admin-page.module.ts index b4222967..ddc5956e 100644 --- a/apps/client/src/app/pages/admin/admin-page.module.ts +++ b/apps/client/src/app/pages/admin/admin-page.module.ts @@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatMenuModule } from '@angular/material/menu'; import { MatTabsModule } from '@angular/material/tabs'; +import { GfAdminJobsModule } from '@ghostfolio/client/components/admin-jobs/admin-jobs.module'; import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module'; import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module'; import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module'; @@ -19,6 +20,7 @@ import { AdminPageComponent } from './admin-page.component'; imports: [ AdminPageRoutingModule, CommonModule, + GfAdminJobsModule, GfAdminMarketDataModule, GfAdminOverviewModule, GfAdminUsersModule, diff --git a/apps/client/src/app/pages/admin/admin-page.scss b/apps/client/src/app/pages/admin/admin-page.scss index 31ff834c..42a21abb 100644 --- a/apps/client/src/app/pages/admin/admin-page.scss +++ b/apps/client/src/app/pages/admin/admin-page.scss @@ -11,6 +11,7 @@ padding-bottom: constant(safe-area-inset-bottom); ::ng-deep { + gf-admin-jobs, gf-admin-market-data, gf-admin-overview, gf-admin-users { diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index ecb33c0d..208f931a 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -4,6 +4,7 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-dat import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { + AdminJobs, AdminMarketDataDetails, UniqueAsset } from '@ghostfolio/common/interfaces'; @@ -42,6 +43,10 @@ export class AdminService { ); } + public fetchJobs() { + return this.http.get(`/api/v1/admin/queue/jobs`); + } + public gatherMax() { return this.http.post(`/api/v1/admin/gather/max`, {}); } diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 69cfa592..f60e039f 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -77,6 +77,10 @@ export function getDateFormatString(aLocale?: string) { .join(''); } +export function getDateWithTimeFormatString(aLocale?: string) { + return `${getDateFormatString(aLocale)}, HH:mm:ss`; +} + export function getLocale() { return navigator.languages?.length ? navigator.languages[0] diff --git a/libs/common/src/lib/interfaces/admin-jobs.interface.ts b/libs/common/src/lib/interfaces/admin-jobs.interface.ts new file mode 100644 index 00000000..5379eec6 --- /dev/null +++ b/libs/common/src/lib/interfaces/admin-jobs.interface.ts @@ -0,0 +1,5 @@ +import { Job } from 'bull'; + +export interface AdminJobs { + jobs: Job[]; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index cb31e246..0d05f58d 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -1,6 +1,7 @@ import { Access } from './access.interface'; import { Accounts } from './accounts.interface'; import { AdminData } from './admin-data.interface'; +import { AdminJobs } from './admin-jobs.interface'; import { AdminMarketDataDetails } from './admin-market-data-details.interface'; import { AdminMarketData, @@ -40,6 +41,7 @@ export { Access, Accounts, AdminData, + AdminJobs, AdminMarketData, AdminMarketDataDetails, AdminMarketDataItem,