Feature/add jobs of queue to admin control panel (#987)

* Add jobs of queue to admin control panel

* Update changelog
pull/988/head
Thomas Kaul 2 years ago committed by GitHub
parent 14a0eeab29
commit 7cf0cdc4ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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
],

@ -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<AdminJobs> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.queueService.getJobs({});
}
}

@ -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 {}

@ -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<AdminJobs> {
const jobs = await this.dataGatheringQueue.getJobs([
'active',
'completed',
'delayed',
'failed',
'paused',
'waiting'
]);
return {
jobs: jobs.slice(0, limit)
};
}
}

@ -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();

@ -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<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.redisCacheService.reset();
return this.cacheService.flush();

@ -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<void>();
/**
* @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();
});
}
}

@ -0,0 +1,74 @@
<div class="container">
<div class="row">
<div class="col">
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr class="mat-row">
<td class="mat-cell px-1 py-2">{{ job.id }}</td>
<td class="mat-cell px-1 py-2">{{ job.name }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">
{{ job.timestamp | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.finishedOn | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
<ion-icon
*ngIf="job.finishedOn"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ng-container *ngIf="!job.finishedOn">
<ion-icon
*ngIf="job.stacktrace?.length >= 1"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.stacktrace?.length < 1"
name="time-outline"
></ion-icon>
</ng-container>
</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button
i18n
mat-menu-item
[disabled]="job.stacktrace?.length < 1"
(click)="onViewStacktrace(job.stacktrace)"
>
View Stacktrace
</button>
</mat-menu>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</div>

@ -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 {}

@ -0,0 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

@ -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 }

@ -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

@ -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,

@ -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 {

@ -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<AdminJobs>(`/api/v1/admin/queue/jobs`);
}
public gatherMax() {
return this.http.post<void>(`/api/v1/admin/gather/max`, {});
}

@ -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]

@ -0,0 +1,5 @@
import { Job } from 'bull';
export interface AdminJobs {
jobs: Job<any>[];
}

@ -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,

Loading…
Cancel
Save