Browse Source

Merge branch 'main' into feature/setup-bull-board

pull/6501/head
Thomas Kaul 3 weeks ago
committed by GitHub
parent
commit
658f0b81b0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  3. 12
      apps/api/src/events/asset-profile-changed.event.ts
  4. 59
      apps/api/src/events/asset-profile-changed.listener.ts
  5. 30
      apps/api/src/events/portfolio-changed.listener.ts
  6. 4
      apps/client/src/app/pages/resources/overview/resources-overview.component.html
  7. 10
      libs/ui/src/lib/value/value.component.stories.ts
  8. 10
      package-lock.json
  9. 2
      package.json

7
CHANGELOG.md

@ -10,10 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Integrated _Bull Dashboard_ for a detailed jobs queue view in the admin control panel (experimental) - 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 ### Changed
- Improved the _Storybook_ stories of the value component
- Improved the language localization for Dutch (`nl`) - Improved the language localization for Dutch (`nl`)
- Upgraded `class-validator` from version `0.14.3` to `0.15.1`
### Fixed
- Fixed false _Redis_ health check failures by using unique keys and increasing the timeout to 5s
## 2.248.0 - 2026-03-07 ## 2.248.0 - 2026-03-07

11
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -6,7 +6,7 @@ import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import Keyv from 'keyv'; import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
import { createHash } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
@ -75,13 +75,16 @@ export class RedisCacheService {
} }
public async isHealthy() { public async isHealthy() {
const testKey = '__health_check__'; const HEALTH_CHECK_TIMEOUT = ms('5 seconds');
const testKey = `__health_check__${randomUUID().replace(/-/g, '')}`;
const testValue = Date.now().toString(); const testValue = Date.now().toString();
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
await this.set(testKey, testValue, ms('1 second')); await this.set(testKey, testValue, HEALTH_CHECK_TIMEOUT);
const result = await this.get(testKey); const result = await this.get(testKey);
if (result !== testValue) { if (result !== testValue) {
@ -91,7 +94,7 @@ export class RedisCacheService {
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check failed: timeout')), () => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds') HEALTH_CHECK_TIMEOUT
) )
) )
]); ]);

12
apps/api/src/events/asset-profile-changed.event.ts

@ -8,4 +8,16 @@ export class AssetProfileChangedEvent {
public static getName(): string { public static getName(): string {
return 'assetProfile.changed'; return 'assetProfile.changed';
} }
public getCurrency() {
return this.data.currency;
}
public getDataSource() {
return this.data.dataSource;
}
public getSymbol() {
return this.data.symbol;
}
} }

59
apps/api/src/events/asset-profile-changed.listener.ts

@ -4,14 +4,21 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event'; import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable() @Injectable()
export class AssetProfileChangedListener { export class AssetProfileChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -21,9 +28,47 @@ export class AssetProfileChangedListener {
) {} ) {}
@OnEvent(AssetProfileChangedEvent.getName()) @OnEvent(AssetProfileChangedEvent.getName())
public async handleAssetProfileChanged(event: AssetProfileChangedEvent) { public handleAssetProfileChanged(event: AssetProfileChangedEvent) {
const currency = event.getCurrency();
const dataSource = event.getDataSource();
const symbol = event.getSymbol();
const key = getAssetProfileIdentifier({
dataSource,
symbol
});
const existingTimer = this.debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.debounceTimers.set(
key,
setTimeout(() => {
this.debounceTimers.delete(key);
void this.processAssetProfileChanged({
currency,
dataSource,
symbol
});
}, AssetProfileChangedListener.DEBOUNCE_DELAY)
);
}
private async processAssetProfileChanged({
currency,
dataSource,
symbol
}: {
currency: string;
dataSource: DataSource;
symbol: string;
}) {
Logger.log( Logger.log(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`, `Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -31,16 +76,16 @@ export class AssetProfileChangedListener {
this.configurationService.get( this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false || ) === false ||
event.data.currency === DEFAULT_CURRENCY currency === DEFAULT_CURRENCY
) { ) {
return; return;
} }
const existingCurrencies = this.exchangeRateDataService.getCurrencies(); const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) { if (!existingCurrencies.includes(currency)) {
Logger.log( Logger.log(
`New currency ${event.data.currency} has been detected`, `New currency ${currency} has been detected`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -48,13 +93,13 @@ export class AssetProfileChangedListener {
} }
const { dateOfFirstActivity } = const { dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(event.data.currency); await this.activitiesService.getStatisticsByCurrency(currency);
if (dateOfFirstActivity) { if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({ await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(), dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
date: dateOfFirstActivity, date: dateOfFirstActivity,
symbol: `${DEFAULT_CURRENCY}${event.data.currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}); });
} }
} }

30
apps/api/src/events/portfolio-changed.listener.ts

@ -2,22 +2,44 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import ms from 'ms';
import { PortfolioChangedEvent } from './portfolio-changed.event'; import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable() @Injectable()
export class PortfolioChangedListener { export class PortfolioChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName()) @OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) { handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
const userId = event.getUserId();
const existingTimer = this.debounceTimers.get(userId);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.debounceTimers.set(
userId,
setTimeout(() => {
this.debounceTimers.delete(userId);
void this.processPortfolioChanged({ userId });
}, PortfolioChangedListener.DEBOUNCE_DELAY)
);
}
private async processPortfolioChanged({ userId }: { userId: string }) {
Logger.log( Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`, `Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener' 'PortfolioChangedListener'
); );
this.redisCacheService.removePortfolioSnapshotsByUserId({ await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
userId: event.getUserId()
});
} }
} }

4
apps/client/src/app/pages/resources/overview/resources-overview.component.html

@ -7,7 +7,9 @@
<div class="mb-4"> <div class="mb-4">
<h2 class="h5 mb-1 mt-0">{{ item.title }}</h2> <h2 class="h5 mb-1 mt-0">{{ item.title }}</h2>
<p class="mb-1">{{ item.description }}</p> <p class="mb-1">{{ item.description }}</p>
<a [routerLink]="item.routerLink">Explore {{ item.title }} →</a> <a [routerLink]="item.routerLink"
><ng-container i18n>Explore {{ item.title }}</ng-container></a
>
</div> </div>
} }
</div> </div>

10
libs/ui/src/lib/value/value.component.stories.ts

@ -16,6 +16,10 @@ export default {
deviceType: { deviceType: {
control: 'select', control: 'select',
options: ['desktop', 'mobile'] options: ['desktop', 'mobile']
},
size: {
control: 'select',
options: ['small', 'medium', 'large']
} }
} }
} as Meta<GfValueComponent>; } as Meta<GfValueComponent>;
@ -51,7 +55,11 @@ export const Label: Story = {
args: { args: {
locale: 'en-US', locale: 'en-US',
value: 7.25 value: 7.25
} },
render: (args) => ({
props: args,
template: `<gf-value [locale]="locale" [size]="size" [value]="value">Label</gf-value>`
})
}; };
export const PerformancePositive: Story = { export const PerformancePositive: Story = {

10
package-lock.json

@ -56,7 +56,7 @@
"chartjs-plugin-datalabels": "2.2.0", "chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.2.0", "cheerio": "1.2.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.14.3", "class-validator": "0.15.1",
"color": "5.0.3", "color": "5.0.3",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"countries-and-timezones": "3.8.0", "countries-and-timezones": "3.8.0",
@ -16459,14 +16459,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.3", "version": "0.15.1",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/validator": "^13.15.3", "@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1", "libphonenumber-js": "^1.11.1",
"validator": "^13.15.20" "validator": "^13.15.22"
} }
}, },
"node_modules/clean-css": { "node_modules/clean-css": {

2
package.json

@ -101,7 +101,7 @@
"chartjs-plugin-datalabels": "2.2.0", "chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.2.0", "cheerio": "1.2.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.14.3", "class-validator": "0.15.1",
"color": "5.0.3", "color": "5.0.3",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"countries-and-timezones": "3.8.0", "countries-and-timezones": "3.8.0",

Loading…
Cancel
Save