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
- 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
- Improved the _Storybook_ stories of the value component
- 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

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 Keyv from 'keyv';
import ms from 'ms';
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
@Injectable()
export class RedisCacheService {
@ -75,13 +75,16 @@ export class RedisCacheService {
}
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();
try {
await Promise.race([
(async () => {
await this.set(testKey, testValue, ms('1 second'));
await this.set(testKey, testValue, HEALTH_CHECK_TIMEOUT);
const result = await this.get(testKey);
if (result !== testValue) {
@ -91,7 +94,7 @@ export class RedisCacheService {
new Promise((_, reject) =>
setTimeout(
() => 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 {
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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable()
export class AssetProfileChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
@ -21,9 +28,47 @@ export class AssetProfileChangedListener {
) {}
@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(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`,
`Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener'
);
@ -31,16 +76,16 @@ export class AssetProfileChangedListener {
this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false ||
event.data.currency === DEFAULT_CURRENCY
currency === DEFAULT_CURRENCY
) {
return;
}
const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) {
if (!existingCurrencies.includes(currency)) {
Logger.log(
`New currency ${event.data.currency} has been detected`,
`New currency ${currency} has been detected`,
'AssetProfileChangedListener'
);
@ -48,13 +93,13 @@ export class AssetProfileChangedListener {
}
const { dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(event.data.currency);
await this.activitiesService.getStatisticsByCurrency(currency);
if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
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 { OnEvent } from '@nestjs/event-emitter';
import ms from 'ms';
import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable()
export class PortfolioChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName())
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(
`Portfolio of user '${event.getUserId()}' has changed`,
`Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener'
);
this.redisCacheService.removePortfolioSnapshotsByUserId({
userId: event.getUserId()
});
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
}
}

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

@ -7,7 +7,9 @@
<div class="mb-4">
<h2 class="h5 mb-1 mt-0">{{ item.title }}</h2>
<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>

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

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

10
package-lock.json

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

2
package.json

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

Loading…
Cancel
Save