Browse Source

Feature/setup Bull Dashboard (#6501)

* Initial setup

* Update changelog
pull/6512/head
Thomas Kaul 2 weeks ago
committed by GitHub
parent
commit
efa93a3b98
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 34
      apps/api/src/app/app.module.ts
  3. 8
      apps/api/src/app/user/user.service.ts
  4. 6
      apps/api/src/main.ts
  5. 28
      apps/api/src/middlewares/bull-board-auth.middleware.ts
  6. 2
      apps/api/src/services/configuration/configuration.service.ts
  7. 2
      apps/api/src/services/interfaces/environment.interface.ts
  8. 14
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  9. 14
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  10. 4
      apps/client/proxy.conf.json
  11. 51
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  12. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  13. 8
      libs/common/src/lib/config.ts
  14. 1
      libs/common/src/lib/permissions.ts
  15. 103
      package-lock.json
  16. 5
      package.json

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Integrated _Bull Dashboard_ for a detailed jobs queue view in the admin control panel (experimental)
- Added a debounce to the `PortfolioChangedListener` and `AssetProfileChangedListener` to minimize redundant _Redis_ and database operations
### Changed

34
apps/api/src/app/app.module.ts

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
@ -10,10 +11,13 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@ -70,6 +74,29 @@ import { UserModule } from './user/user.module';
AuthDeviceModule,
AuthModule,
BenchmarksModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forRoot({
adapter: ExpressAdapter,
boardOptions: {
uiConfig: {
boardLogo: {
height: 0,
path: '',
width: 0
},
boardTitle: 'Job Queues',
favIcon: {
alternative: '/assets/favicon-32x32.png',
default: '/assets/favicon-32x32.png'
}
}
},
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
})
]
: []),
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -105,7 +132,12 @@ import { UserModule } from './user/user.module';
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
exclude: [
`${BULL_BOARD_ROUTE}/*wildcard`,
'/.well-known/*wildcard',
'/api/*wildcard',
'/sitemap.xml'
],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: {
setHeaders: (res) => {

8
apps/api/src/app/user/user.service.ts

@ -530,9 +530,15 @@ export class UserService {
}
}
if (!environment.production && hasRole(user, Role.ADMIN)) {
if (hasRole(user, Role.ADMIN)) {
if (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) {
currentPermissions.push(permissions.accessAdminControlBullBoard);
}
if (!environment.production) {
currentPermissions.push(permissions.impersonateAllUsers);
}
}
user.accounts = user.accounts.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());

6
apps/api/src/main.ts

@ -1,4 +1,5 @@
import {
BULL_BOARD_ROUTE,
DEFAULT_HOST,
DEFAULT_PORT,
STORYBOOK_PATH,
@ -14,6 +15,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
@ -46,6 +48,7 @@ async function bootstrap() {
});
app.setGlobalPrefix('api', {
exclude: [
`${BULL_BOARD_ROUTE.substring(1)}{/*wildcard}`,
'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
// Exclude language-specific routes with an optional wildcard
@ -53,6 +56,7 @@ async function bootstrap() {
})
]
});
app.useGlobalPipes(
new ValidationPipe({
forbidNonWhitelisted: true,
@ -64,6 +68,8 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities
app.useBodyParser('json', { limit: '10mb' });
app.use(cookieParser());
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) {

28
apps/api/src/middlewares/bull-board-auth.middleware.ts

@ -0,0 +1,28 @@
import { BULL_BOARD_COOKIE_NAME } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import passport from 'passport';
@Injectable()
export class BullBoardAuthMiddleware implements NestMiddleware {
public use(req: Request, res: Response, next: NextFunction) {
const token = req.cookies?.[BULL_BOARD_COOKIE_NAME];
if (token) {
req.headers.authorization = `Bearer ${token}`;
}
passport.authenticate('jwt', { session: false }, (error, user) => {
if (
error ||
!hasPermission(user?.permissions, permissions.accessAdminControl)
) {
next(new ForbiddenException());
} else {
next();
}
})(req, res, next);
}
}

2
apps/api/src/services/configuration/configuration.service.ts

@ -30,6 +30,7 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
@ -43,6 +44,7 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_BULL_BOARD: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),

2
apps/api/src/services/interfaces/environment.interface.ts

@ -10,6 +10,7 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;
@ -19,6 +20,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_BULL_BOARD: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;

14
apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -9,6 +9,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import ms from 'ms';
@ -17,6 +19,18 @@ import { DataGatheringProcessor } from './data-gathering.processor';
@Module({
imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: DATA_GATHERING_QUEUE,
options: {
displayName: 'Data Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
limiter: {
duration: ms('4 seconds'),

14
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -13,6 +13,8 @@ import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
@ -23,6 +25,18 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
imports: [
AccountBalanceModule,
ActivitiesModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: {
displayName: 'Portfolio Snapshot Computation',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: {

4
apps/client/proxy.conf.json

@ -1,4 +1,8 @@
{
"/admin/queues": {
"target": "http://0.0.0.0:3333",
"secure": false
},
"/api": {
"target": "http://0.0.0.0:3333",
"secure": false

51
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -1,5 +1,8 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -7,6 +10,7 @@ import {
} from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services';
@ -15,10 +19,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
DestroyRef,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormGroup,
@ -41,6 +46,7 @@ import {
chevronUpCircleOutline,
ellipsisHorizontal,
ellipsisVertical,
openOutline,
pauseOutline,
playOutline,
removeCircleOutline,
@ -48,8 +54,6 @@ import {
} from 'ionicons/icons';
import { get } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -69,7 +73,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./admin-jobs.scss'],
templateUrl: './admin-jobs.html'
})
export class GfAdminJobsComponent implements OnDestroy, OnInit {
export class GfAdminJobsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort;
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW;
@ -81,6 +85,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public displayedColumns = [
'index',
'type',
@ -93,21 +98,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
'status',
'actions'
];
public hasPermissionToAccessBullBoard = false;
public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
private unsubscribeSubject = new Subject<void>();
private user: User;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -115,6 +123,11 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale
);
this.hasPermissionToAccessBullBoard = hasPermission(
this.user.permissions,
permissions.accessAdminControlBullBoard
);
}
});
@ -126,6 +139,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
chevronUpCircleOutline,
ellipsisHorizontal,
ellipsisVertical,
openOutline,
pauseOutline,
playOutline,
removeCircleOutline,
@ -139,7 +153,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
});
this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
const currentFilter = this.filterForm.get('status').value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
@ -151,7 +165,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onDeleteJob(aId: string) {
this.adminService
.deleteJob(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchJobs();
});
@ -162,7 +176,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
@ -171,12 +185,20 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onExecuteJob(aId: string) {
this.adminService
.executeJob(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchJobs();
});
}
public onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = `${BULL_BOARD_COOKIE_NAME}=${token}; path=${BULL_BOARD_ROUTE}; SameSite=Strict`;
window.open(BULL_BOARD_ROUTE, '_blank');
}
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({
title: JSON.stringify(aData, null, ' ')
@ -189,17 +211,12 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true;
this.adminService
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs);
this.dataSource.sort = this.sort;

9
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -1,6 +1,15 @@
<div class="container">
<div class="row">
<div class="col">
@if (hasPermissionToAccessBullBoard) {
<div class="d-flex justify-content-end mb-3">
<button mat-stroked-button (click)="onOpenBullBoard()">
<span><ng-container i18n>Overview</ng-container></span>
<ion-icon class="ml-2" name="open-outline" />
</button>
</div>
}
<form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status">

8
libs/common/src/lib/config.ts

@ -51,6 +51,14 @@ export const ASSET_CLASS_MAPPING = new Map<AssetClass, AssetSubClass[]>([
[AssetClass.REAL_ESTATE, []]
]);
export const BULL_BOARD_COOKIE_NAME = 'bull_board_token';
/**
* WARNING: This route is mirrored in `apps/client/proxy.conf.json`.
* If you update this value, you must also update the proxy configuration.
*/
export const BULL_BOARD_ROUTE = '/admin/queues';
export const CACHE_TTL_NO_CACHE = 1;
export const CACHE_TTL_INFINITE = 0;

1
libs/common/src/lib/permissions.ts

@ -4,6 +4,7 @@ import { Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
accessAdminControlBullBoard: 'accessAdminControlBullBoard',
accessAssistant: 'accessAssistant',
accessHoldingsChart: 'accessHoldingsChart',
createAccess: 'createAccess',

103
package-lock.json

@ -21,6 +21,9 @@
"@angular/platform-browser-dynamic": "21.1.1",
"@angular/router": "21.1.1",
"@angular/service-worker": "21.1.1",
"@bull-board/api": "6.20.3",
"@bull-board/express": "6.20.3",
"@bull-board/nestjs": "6.20.3",
"@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.5",
@ -55,6 +58,7 @@
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"color": "5.0.3",
"cookie-parser": "1.4.7",
"countries-and-timezones": "3.8.0",
"countries-list": "3.2.2",
"countup.js": "2.9.0",
@ -123,6 +127,7 @@
"@storybook/angular": "10.1.10",
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"@types/big.js": "6.2.2",
"@types/cookie-parser": "1.4.10",
"@types/fast-redact": "3.0.4",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "30.0.0",
@ -3641,6 +3646,53 @@
"devOptional": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bull-board/api": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.20.3.tgz",
"integrity": "sha512-cDrsJJsmF4DbbY8/5oHxO4qFtyFjxexsWQKHowsud/8H4mtZN7MZg4fCmNzfaxc9Ov7V6r9Y9F5G2Mq6t7ZEJg==",
"license": "MIT",
"dependencies": {
"redis-info": "^3.1.0"
},
"peerDependencies": {
"@bull-board/ui": "6.20.3"
}
},
"node_modules/@bull-board/express": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.20.3.tgz",
"integrity": "sha512-S6BGeSf/PLwjx5W1IrKxoV8G6iiMmLqT/pldZ6BiC1IDldedisTtAdL1z117swXPv1H7/3hy0vr03dUr8bUCPg==",
"license": "MIT",
"dependencies": {
"@bull-board/api": "6.20.3",
"@bull-board/ui": "6.20.3",
"ejs": "^3.1.10",
"express": "^5.2.1"
}
},
"node_modules/@bull-board/nestjs": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-6.20.3.tgz",
"integrity": "sha512-VFi96Z2M8k3G26H1ivzQnpjKszxh90vrUm78VtMZH/sh8wjm88mJFDXcOgFutOaddx7cc9VNXlKsTTcu6okPFQ==",
"license": "MIT",
"peerDependencies": {
"@bull-board/api": "^6.20.3",
"@nestjs/bull-shared": "^10.0.0 || ^11.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0",
"rxjs": "^7.8.1"
}
},
"node_modules/@bull-board/ui": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.20.3.tgz",
"integrity": "sha512-oANyYoW0X+xd0j/09DRyh3u7Q3wqBtXiLEWyZUJIi/Bjp/hINwiw20RwWuRcaFkqkFylEJL9l+pjmeSA9X5L2A==",
"license": "MIT",
"dependencies": {
"@bull-board/api": "6.20.3"
}
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
@ -12688,6 +12740,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@ -14979,7 +15041,6 @@
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"dev": true,
"license": "MIT"
},
"node_modules/async-function": {
@ -15373,7 +15434,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
@ -15627,7 +15687,6 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@ -16852,7 +16911,6 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/confbox": {
@ -16924,6 +16982,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@ -18812,7 +18889,6 @@
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
@ -20253,7 +20329,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
@ -20263,7 +20338,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -20273,7 +20347,6 @@
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@ -21485,7 +21558,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -23125,7 +23197,6 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
"integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.3",
@ -23144,7 +23215,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@ -25916,7 +25986,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@ -29532,6 +29601,15 @@
"node": ">=4"
}
},
"node_modules/redis-info": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redis-info/-/redis-info-3.1.0.tgz",
"integrity": "sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.11"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
@ -32233,7 +32311,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"

5
package.json

@ -66,6 +66,9 @@
"@angular/platform-browser-dynamic": "21.1.1",
"@angular/router": "21.1.1",
"@angular/service-worker": "21.1.1",
"@bull-board/api": "6.20.3",
"@bull-board/express": "6.20.3",
"@bull-board/nestjs": "6.20.3",
"@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.5",
@ -100,6 +103,7 @@
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"color": "5.0.3",
"cookie-parser": "1.4.7",
"countries-and-timezones": "3.8.0",
"countries-list": "3.2.2",
"countup.js": "2.9.0",
@ -168,6 +172,7 @@
"@storybook/angular": "10.1.10",
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"@types/big.js": "6.2.2",
"@types/cookie-parser": "1.4.10",
"@types/fast-redact": "3.0.4",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "30.0.0",

Loading…
Cancel
Save