Browse Source

Merge branch 'main' into task/upgrade-to-nx-22.6

pull/6704/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
c16c97f5c9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      CHANGELOG.md
  2. 4
      apps/api/src/app/activities/activities.module.ts
  3. 4
      apps/api/src/app/admin/admin.module.ts
  4. 9
      apps/api/src/app/admin/queue/queue.module.ts
  5. 20
      apps/api/src/app/admin/queue/queue.service.ts
  6. 4
      apps/api/src/app/app.module.ts
  7. 4
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  8. 4
      apps/api/src/app/import/import.module.ts
  9. 4
      apps/api/src/app/info/info.module.ts
  10. 167
      apps/api/src/app/info/info.service.ts
  11. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  12. 4
      apps/api/src/events/events.module.ts
  13. 6
      apps/api/src/services/cron/cron.module.ts
  14. 9
      apps/api/src/services/cron/cron.service.ts
  15. 2
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  16. 36
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  17. 212
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  18. 40
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.service.ts
  19. 8
      apps/client/src/app/components/admin-overview/admin-overview.html
  20. 11
      apps/client/src/app/services/user/user.service.ts
  21. 298
      apps/client/src/locales/messages.ca.xlf
  22. 298
      apps/client/src/locales/messages.de.xlf
  23. 298
      apps/client/src/locales/messages.es.xlf
  24. 298
      apps/client/src/locales/messages.fr.xlf
  25. 298
      apps/client/src/locales/messages.it.xlf
  26. 298
      apps/client/src/locales/messages.ko.xlf
  27. 298
      apps/client/src/locales/messages.nl.xlf
  28. 298
      apps/client/src/locales/messages.pl.xlf
  29. 298
      apps/client/src/locales/messages.pt.xlf
  30. 298
      apps/client/src/locales/messages.tr.xlf
  31. 298
      apps/client/src/locales/messages.uk.xlf
  32. 297
      apps/client/src/locales/messages.xlf
  33. 298
      apps/client/src/locales/messages.zh.xlf
  34. 29
      libs/common/src/lib/config.ts
  35. 2
      libs/common/src/lib/interfaces/statistics.interface.ts
  36. 4
      libs/ui/src/lib/activity-type/activity-type.component.html
  37. 22
      libs/ui/src/lib/assistant/assistant.component.ts
  38. 1
      libs/ui/src/lib/i18n.ts
  39. 26
      package-lock.json
  40. 6
      package.json
  41. 2
      prisma/migrations/20260409154017_added_loan_to_asset_sub_class/migration.sql
  42. 1
      prisma/schema.prisma

20
CHANGELOG.md

@ -9,11 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Extended the asset profile details dialog in the admin control panel to support editing countries for all asset types
- Extended the asset profile details dialog in the admin control panel to support editing sectors for all asset types
- Upgraded `angular` from version `21.1.1` to `21.2.7` - Upgraded `angular` from version `21.1.1` to `21.2.7`
- Upgraded `Nx` from version `22.5.3` to `22.6.4` - Upgraded `Nx` from version `22.5.3` to `22.6.4`
## 2.254.0 - 2026-03-10
### Added
- Added loan as an asset sub class
### Changed
- Extended the asset profile details dialog in the admin control panel to support editing countries for all asset types
- Extended the asset profile details dialog in the admin control panel to support editing sectors for all asset types
- Migrated the data collection for the _Open Startup_ (`/open`) page to the queue design pattern
- Improved the language localization for German (`de`)
- Upgraded `lodash` from version `4.17.23` to `4.18.1`
### Fixed
- Improved the style of the activity type component
## 2.253.0 - 2026-03-06 ## 2.253.0 - 2026-03-06
### Added ### Added

4
apps/api/src/app/activities/activities.module.ts

@ -10,7 +10,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -24,7 +24,7 @@ import { ActivitiesService } from './activities.service';
imports: [ imports: [
ApiModule, ApiModule,
CacheModule, CacheModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,

4
apps/api/src/app/admin/admin.module.ts

@ -9,7 +9,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -24,7 +24,7 @@ import { QueueModule } from './queue/queue.module';
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
DemoModule, DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,

9
apps/api/src/app/admin/queue/queue.module.ts

@ -1,5 +1,6 @@
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -8,7 +9,11 @@ import { QueueService } from './queue.service';
@Module({ @Module({
controllers: [QueueController], controllers: [QueueController],
imports: [DataGatheringModule, PortfolioSnapshotQueueModule], imports: [
DataGatheringQueueModule,
PortfolioSnapshotQueueModule,
StatisticsGatheringQueueModule
],
providers: [QueueService] providers: [QueueService]
}) })
export class QueueModule {} export class QueueModule {}

20
apps/api/src/app/admin/queue/queue.service.ts

@ -1,7 +1,8 @@
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
QUEUE_JOB_STATUS_LIST QUEUE_JOB_STATUS_LIST,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -15,7 +16,9 @@ export class QueueService {
@InjectQueue(DATA_GATHERING_QUEUE) @InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue, private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue private readonly portfolioSnapshotQueue: Queue,
@InjectQueue(STATISTICS_GATHERING_QUEUE)
private readonly statisticsGatheringQueue: Queue
) {} ) {}
public async deleteJob(aId: string) { public async deleteJob(aId: string) {
@ -38,6 +41,7 @@ export class QueueService {
await this.dataGatheringQueue.clean(300, queueStatus); await this.dataGatheringQueue.clean(300, queueStatus);
await this.portfolioSnapshotQueue.clean(300, queueStatus); await this.portfolioSnapshotQueue.clean(300, queueStatus);
await this.statisticsGatheringQueue.clean(300, queueStatus);
} }
} }
@ -58,13 +62,19 @@ export class QueueService {
limit?: number; limit?: number;
status?: JobStatus[]; status?: JobStatus[];
}): Promise<AdminJobs> { }): Promise<AdminJobs> {
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([ const [dataGatheringJobs, portfolioSnapshotJobs, statisticsGatheringJobs] =
await Promise.all([
this.dataGatheringQueue.getJobs(status), this.dataGatheringQueue.getJobs(status),
this.portfolioSnapshotQueue.getJobs(status) this.portfolioSnapshotQueue.getJobs(status),
this.statisticsGatheringQueue.getJobs(status)
]); ]);
const jobsWithState = await Promise.all( const jobsWithState = await Promise.all(
[...dataGatheringJobs, ...portfolioSnapshotJobs] [
...dataGatheringJobs,
...portfolioSnapshotJobs,
...statisticsGatheringJobs
]
.filter((job) => { .filter((job) => {
return job; return job;
}) })

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

@ -8,7 +8,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
@ -109,7 +109,7 @@ import { UserModule } from './user/user.module';
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
CronModule, CronModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
EventsModule, EventsModule,

4
apps/api/src/app/endpoints/watchlist/watchlist.module.ts

@ -5,7 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -17,7 +17,7 @@ import { WatchlistService } from './watchlist.service';
controllers: [WatchlistController], controllers: [WatchlistController],
imports: [ imports: [
BenchmarkModule, BenchmarkModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,

4
apps/api/src/app/import/import.module.ts

@ -12,7 +12,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -29,7 +29,7 @@ import { ImportService } from './import.service';
ApiModule, ApiModule,
CacheModule, CacheModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,

4
apps/api/src/app/info/info.module.ts

@ -8,7 +8,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -22,7 +22,7 @@ import { InfoService } from './info.service';
imports: [ imports: [
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
JwtModule.register({ JwtModule.register({

167
apps/api/src/app/info/info.service.ts

@ -7,26 +7,24 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS,
PROPERTY_GITHUB_CONTRIBUTORS,
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { encodeDataSource } from '@ghostfolio/common/helper';
DATE_FORMAT,
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as cheerio from 'cheerio'; import { subDays } from 'date-fns';
import { format, subDays } from 'date-fns'; import { isNil } from 'lodash';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -149,68 +147,6 @@ export class InfoService {
}); });
} }
private async countDockerHubPulls(): Promise<number> {
try {
const { pull_count } = (await fetch(
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { pull_count: number };
return pull_count;
} catch (error) {
Logger.error(error, 'InfoService - DockerHub');
return undefined;
}
}
private async countGitHubContributors(): Promise<number> {
try {
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}).then((res) => res.text());
const $ = cheerio.load(body);
return extractNumberFromString({
value: $(
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
).text()
});
} catch (error) {
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
}
private async countGitHubStargazers(): Promise<number> {
try {
const { stargazers_count } = (await fetch(
'https://api.github.com/repos/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { stargazers_count: number };
return stargazers_count;
} catch (error) {
Logger.error(error, 'InfoService - GitHub');
return undefined;
}
}
private async countNewUsers(aDays: number) { private async countNewUsers(aDays: number) {
return this.userService.count({ return this.userService.count({
where: { where: {
@ -230,12 +166,6 @@ export class InfoService {
}); });
} }
private async countSlackCommunityUsers() {
return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS
);
}
private async getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = await this.propertyService.getByKey<string>( const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID PROPERTY_DEMO_USER_ID
@ -267,65 +197,56 @@ export class InfoService {
} }
} catch {} } catch {}
const activeUsers1d = await this.countActiveUsers(1); const [
const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const dockerHubPulls = await this.countDockerHubPulls();
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
const uptime = await this.getUptime();
statistics = {
activeUsers1d, activeUsers1d,
activeUsers30d, activeUsers30d,
newUsers30d,
dockerHubPulls, dockerHubPulls,
gitHubContributors, gitHubContributors,
gitHubStargazers, gitHubStargazers,
newUsers30d,
slackCommunityUsers, slackCommunityUsers,
uptime uptime
] = await Promise.all([
this.countActiveUsers(1),
this.countActiveUsers(30),
this.countNewUsers(30),
this.propertyService.getByKey<string>(PROPERTY_DOCKER_HUB_PULLS),
this.propertyService.getByKey<string>(PROPERTY_GITHUB_CONTRIBUTORS),
this.propertyService.getByKey<string>(PROPERTY_GITHUB_STARGAZERS),
this.propertyService.getByKey<string>(PROPERTY_SLACK_COMMUNITY_USERS),
this.propertyService.getByKey<string>(PROPERTY_UPTIME)
]);
statistics = {
activeUsers1d,
activeUsers30d,
newUsers30d,
dockerHubPulls: dockerHubPulls
? Number.parseInt(dockerHubPulls, 10)
: undefined,
gitHubContributors: gitHubContributors
? Number.parseInt(gitHubContributors, 10)
: undefined,
gitHubStargazers: gitHubStargazers
? Number.parseInt(gitHubStargazers, 10)
: undefined,
slackCommunityUsers: slackCommunityUsers
? Number.parseInt(slackCommunityUsers, 10)
: undefined,
uptime: uptime ? Number.parseFloat(uptime) : undefined
}; };
if (
Object.values(statistics).every((value) => {
return !isNil(value);
})
) {
await this.redisCacheService.set( await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS, InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics) JSON.stringify(statistics)
); );
return statistics;
}
private async getUptime(): Promise<number> {
{
try {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
{
headers: {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME'
)}`
},
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json());
return data.attributes.availability / 100; return statistics;
} catch (error) {
Logger.error(error, 'InfoService - Better Stack');
return undefined;
}
}
} }
} }

4
apps/api/src/app/portfolio/portfolio.module.ts

@ -17,7 +17,7 @@ import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -38,7 +38,7 @@ import { RulesService } from './rules.service';
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule, I18nModule,

4
apps/api/src/events/events.module.ts

@ -3,7 +3,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -14,7 +14,7 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
RedisCacheModule RedisCacheModule

6
apps/api/src/services/cron/cron.module.ts

@ -2,7 +2,8 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,9 +13,10 @@ import { CronService } from './cron.service';
@Module({ @Module({
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringQueueModule,
ExchangeRateDataModule, ExchangeRateDataModule,
PropertyModule, PropertyModule,
StatisticsGatheringQueueModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],

9
apps/api/src/services/cron/cron.service.ts

@ -3,6 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.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 { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
@ -25,10 +26,18 @@ export class CronService {
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly statisticsGatheringService: StatisticsGatheringService,
private readonly twitterBotService: TwitterBotService, private readonly twitterBotService: TwitterBotService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Cron(CronExpression.EVERY_HOUR)
public async runEveryHour() {
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
await this.statisticsGatheringService.addJobsToQueue();
}
}
@Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE) @Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE)
public async runEveryHourAtRandomMinute() { public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {

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

@ -50,4 +50,4 @@ import { DataGatheringProcessor } from './data-gathering.processor';
providers: [DataGatheringProcessor, DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],
exports: [BullModule, DataEnhancerModule, DataGatheringService] exports: [BullModule, DataEnhancerModule, DataGatheringService]
}) })
export class DataGatheringModule {} export class DataGatheringQueueModule {}

36
apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts

@ -0,0 +1,36 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { STATISTICS_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 { StatisticsGatheringProcessor } from './statistics-gathering.processor';
import { StatisticsGatheringService } from './statistics-gathering.service';
@Module({
exports: [BullModule, StatisticsGatheringService],
imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: STATISTICS_GATHERING_QUEUE,
options: {
displayName: 'Statistics Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
name: STATISTICS_GATHERING_QUEUE
}),
ConfigurationModule,
PropertyModule
],
providers: [StatisticsGatheringProcessor, StatisticsGatheringService]
})
export class StatisticsGatheringQueueModule {}

212
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -0,0 +1,212 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_DOCKER_HUB_PULLS,
PROPERTY_GITHUB_CONTRIBUTORS,
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_UPTIME,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns';
@Injectable()
@Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService
) {}
@Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME)
public async gatherDockerHubPullsStatistics() {
Logger.log(
'Docker Hub pulls statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const dockerHubPulls = await this.countDockerHubPulls();
await this.propertyService.put({
key: PROPERTY_DOCKER_HUB_PULLS,
value: String(dockerHubPulls)
});
Logger.log(
'Docker Hub pulls statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
@Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME)
public async gatherGitHubContributorsStatistics() {
Logger.log(
'GitHub contributors statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const gitHubContributors = await this.countGitHubContributors();
await this.propertyService.put({
key: PROPERTY_GITHUB_CONTRIBUTORS,
value: String(gitHubContributors)
});
Logger.log(
'GitHub contributors statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
@Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME)
public async gatherGitHubStargazersStatistics() {
Logger.log(
'GitHub stargazers statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const gitHubStargazers = await this.countGitHubStargazers();
await this.propertyService.put({
key: PROPERTY_GITHUB_STARGAZERS,
value: String(gitHubStargazers)
});
Logger.log(
'GitHub stargazers statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
@Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME)
public async gatherUptimeStatistics() {
Logger.log(
'Uptime statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const uptime = await this.getUptime();
await this.propertyService.put({
key: PROPERTY_UPTIME,
value: String(uptime)
});
Logger.log(
'Uptime statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
}
private async countDockerHubPulls(): Promise<number> {
try {
const { pull_count } = (await fetch(
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { pull_count: number };
return pull_count;
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - DockerHub');
throw error;
}
}
private async countGitHubContributors(): Promise<number> {
try {
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}).then((res) => res.text());
const $ = cheerio.load(body);
const value = $(
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
).text();
if (!value) {
throw new Error('Could not find the contributors count in the page');
}
return extractNumberFromString({
value
});
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub');
throw error;
}
}
private async countGitHubStargazers(): Promise<number> {
try {
const { stargazers_count } = (await fetch(
'https://api.github.com/repos/ghostfolio/ghostfolio',
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json())) as { stargazers_count: number };
return stargazers_count;
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub');
throw error;
}
}
private async getUptime(): Promise<number> {
try {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
{
headers: {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME'
)}`
},
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json());
return data.attributes.availability / 100;
} catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - Better Stack');
throw error;
}
}
}

40
apps/api/src/services/queues/statistics-gathering/statistics-gathering.service.ts

@ -0,0 +1,40 @@
import {
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
GATHER_STATISTICS_PROCESS_JOB_OPTIONS,
GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME,
STATISTICS_GATHERING_QUEUE
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
@Injectable()
export class StatisticsGatheringService {
public constructor(
@InjectQueue(STATISTICS_GATHERING_QUEUE)
private readonly statisticsGatheringQueue: Queue
) {}
public async addJobsToQueue() {
return Promise.all(
[
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME
].map((jobName) => {
return this.statisticsGatheringQueue.add(
jobName,
{},
{
...GATHER_STATISTICS_PROCESS_JOB_OPTIONS,
jobId: jobName
}
);
})
);
}
}

8
apps/client/src/app/components/admin-overview/admin-overview.html

@ -107,7 +107,13 @@
<table> <table>
@for (coupon of coupons; track coupon) { @for (coupon of coupons; track coupon) {
<tr> <tr>
<td class="text-monospace">{{ coupon.code }}</td> <td>
<gf-value
class="text-monospace"
[enableCopyToClipboardButton]="true"
[value]="coupon.code"
/>
</td>
<td class="pl-2 text-right"> <td class="pl-2 text-right">
{{ formatStringValue(coupon.duration) }} {{ formatStringValue(coupon.duration) }}
</td> </td>

11
apps/client/src/app/services/user/user.service.ts

@ -3,14 +3,15 @@ import { Filter, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { DestroyRef, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ObservableStore } from '@codewithdan/observable-store'; import { ObservableStore } from '@codewithdan/observable-store';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, Subject, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { throwError } from 'rxjs'; import { throwError } from 'rxjs';
import { catchError, map, takeUntil } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from '../../components/subscription-interstitial-dialog/interfaces/interfaces'; import { SubscriptionInterstitialDialogParams } from '../../components/subscription-interstitial-dialog/interfaces/interfaces';
import { GfSubscriptionInterstitialDialogComponent } from '../../components/subscription-interstitial-dialog/subscription-interstitial-dialog.component'; import { GfSubscriptionInterstitialDialogComponent } from '../../components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
@ -22,9 +23,9 @@ import { UserStoreState } from './user-store.state';
}) })
export class UserService extends ObservableStore<UserStoreState> { export class UserService extends ObservableStore<UserStoreState> {
private deviceType: string; private deviceType: string;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private http: HttpClient, private http: HttpClient,
@ -163,7 +164,7 @@ export class UserService extends ObservableStore<UserStoreState> {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }

298
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

297
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

298
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

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

@ -46,7 +46,7 @@ export const ASSET_CLASS_MAPPING = new Map<AssetClass, AssetSubClass[]>([
AssetSubClass.STOCK AssetSubClass.STOCK
] ]
], ],
[AssetClass.FIXED_INCOME, [AssetSubClass.BOND]], [AssetClass.FIXED_INCOME, [AssetSubClass.BOND, AssetSubClass.LOAN]],
[AssetClass.LIQUIDITY, [AssetSubClass.CRYPTOCURRENCY]], [AssetClass.LIQUIDITY, [AssetSubClass.CRYPTOCURRENCY]],
[AssetClass.REAL_ESTATE, []] [AssetClass.REAL_ESTATE, []]
]); ]);
@ -75,6 +75,8 @@ export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1;
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW = export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW =
Number.MAX_SAFE_INTEGER; Number.MAX_SAFE_INTEGER;
export const STATISTICS_GATHERING_QUEUE = 'STATISTICS_GATHERING_QUEUE';
export const DEFAULT_CURRENCY = 'USD'; export const DEFAULT_CURRENCY = 'USD';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_HOST = '0.0.0.0'; export const DEFAULT_HOST = '0.0.0.0';
@ -183,6 +185,27 @@ export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = {
removeOnComplete: true removeOnComplete: true
}; };
export const GATHER_STATISTICS_PROCESS_JOB_OPTIONS: JobOptions = {
attempts: 5,
backoff: {
delay: ms('1 minute'),
type: 'exponential'
},
removeOnComplete: true
};
export const GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME =
'GATHER_STATISTICS_DOCKER_HUB_PULLS';
export const GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME =
'GATHER_STATISTICS_GITHUB_CONTRIBUTORS';
export const GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME =
'GATHER_STATISTICS_GITHUB_STARGAZERS';
export const GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME =
'GATHER_STATISTICS_UPTIME';
export const INVESTMENT_ACTIVITY_TYPES = [ export const INVESTMENT_ACTIVITY_TYPES = [
Type.BUY, Type.BUY,
Type.DIVIDEND, Type.DIVIDEND,
@ -209,6 +232,9 @@ export const PROPERTY_API_KEY_GHOSTFOLIO = 'API_KEY_GHOSTFOLIO';
export const PROPERTY_API_KEY_OPENROUTER = 'API_KEY_OPENROUTER'; export const PROPERTY_API_KEY_OPENROUTER = 'API_KEY_OPENROUTER';
export const PROPERTY_BENCHMARKS = 'BENCHMARKS'; export const PROPERTY_BENCHMARKS = 'BENCHMARKS';
export const PROPERTY_BETTER_UPTIME_MONITOR_ID = 'BETTER_UPTIME_MONITOR_ID'; export const PROPERTY_BETTER_UPTIME_MONITOR_ID = 'BETTER_UPTIME_MONITOR_ID';
export const PROPERTY_DOCKER_HUB_PULLS = 'DOCKER_HUB_PULLS';
export const PROPERTY_GITHUB_CONTRIBUTORS = 'GITHUB_CONTRIBUTORS';
export const PROPERTY_GITHUB_STARGAZERS = 'GITHUB_STARGAZERS';
export const PROPERTY_COUNTRIES_OF_SUBSCRIBERS = 'COUNTRIES_OF_SUBSCRIBERS'; export const PROPERTY_COUNTRIES_OF_SUBSCRIBERS = 'COUNTRIES_OF_SUBSCRIBERS';
export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_COUPONS = 'COUPONS';
export const PROPERTY_CURRENCIES = 'CURRENCIES'; export const PROPERTY_CURRENCIES = 'CURRENCIES';
@ -225,6 +251,7 @@ export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL';
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';
export const PROPERTY_UPTIME = 'UPTIME';
export const QUEUE_JOB_STATUS_LIST = [ export const QUEUE_JOB_STATUS_LIST = [
'active', 'active',

2
libs/common/src/lib/interfaces/statistics.interface.ts

@ -5,6 +5,6 @@ export interface Statistics {
gitHubContributors: number; gitHubContributors: number;
gitHubStargazers: number; gitHubStargazers: number;
newUsers30d: number; newUsers30d: number;
slackCommunityUsers: string; slackCommunityUsers: number;
uptime: number; uptime: number;
} }

4
libs/ui/src/lib/activity-type/activity-type.component.html

@ -20,5 +20,7 @@
} @else if (activityType === 'SELL') { } @else if (activityType === 'SELL') {
<ion-icon name="arrow-down-circle-outline" /> <ion-icon name="arrow-down-circle-outline" />
} }
<span class="d-none d-lg-block mx-1">{{ activityTypeLabel }}</span> <span class="d-none d-lg-block mx-1 text-nowrap">
{{ activityTypeLabel }}
</span>
</div> </div>

22
libs/ui/src/lib/assistant/assistant.component.ts

@ -11,6 +11,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef,
ElementRef, ElementRef,
HostListener, HostListener,
Input, Input,
@ -22,6 +23,7 @@ import {
ViewChildren, ViewChildren,
output output
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -40,7 +42,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { EMPTY, Observable, Subject, merge, of } from 'rxjs'; import { EMPTY, Observable, merge, of } from 'rxjs';
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -48,7 +50,6 @@ import {
map, map,
scan, scan,
switchMap, switchMap,
takeUntil,
tap tap
} from 'rxjs/operators'; } from 'rxjs/operators';
@ -146,12 +147,12 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
private keyManager: FocusKeyManager<GfAssistantListItemComponent>; private keyManager: FocusKeyManager<GfAssistantListItemComponent>;
private preselectionTimeout: ReturnType<typeof setTimeout>; private preselectionTimeout: ReturnType<typeof setTimeout>;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
private destroyRef: DestroyRef
) { ) {
addIcons({ closeCircleOutline, closeOutline, searchOutline }); addIcons({ closeCircleOutline, closeOutline, searchOutline });
} }
@ -333,7 +334,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
) )
); );
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe({ .subscribe({
next: (searchResults) => { next: (searchResults) => {
@ -477,7 +478,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = holdings this.holdings = holdings
.filter(({ assetSubClass }) => { .filter(({ assetSubClass }) => {
@ -556,9 +557,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
if (this.preselectionTimeout) { if (this.preselectionTimeout) {
clearTimeout(this.preselectionTimeout); clearTimeout(this.preselectionTimeout);
} }
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
} }
private getCurrentAssistantListItem() { private getCurrentAssistantListItem() {
@ -643,7 +641,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}; };
}); });
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -678,7 +676,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
); );
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -710,7 +708,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
); );
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
); );
} }

1
libs/ui/src/lib/i18n.ts

@ -56,6 +56,7 @@ const locales = {
COLLECTIBLE: $localize`Collectible`, COLLECTIBLE: $localize`Collectible`,
CRYPTOCURRENCY: $localize`Cryptocurrency`, CRYPTOCURRENCY: $localize`Cryptocurrency`,
ETF: $localize`ETF`, ETF: $localize`ETF`,
LOAN: $localize`Loan`,
MUTUALFUND: $localize`Mutual Fund`, MUTUALFUND: $localize`Mutual Fund`,
PRECIOUS_METAL: $localize`Precious Metal`, PRECIOUS_METAL: $localize`Precious Metal`,
PRIVATE_EQUITY: $localize`Private Equity`, PRIVATE_EQUITY: $localize`Private Equity`,

26
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.253.0", "version": "2.254.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.253.0", "version": "2.254.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -73,7 +73,7 @@
"http-status-codes": "2.3.0", "http-status-codes": "2.3.0",
"ionicons": "8.0.13", "ionicons": "8.0.13",
"jsonpath": "1.2.1", "jsonpath": "1.2.1",
"lodash": "4.17.23", "lodash": "4.18.1",
"marked": "17.0.2", "marked": "17.0.2",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"ng-extract-i18n-merge": "3.3.0", "ng-extract-i18n-merge": "3.3.0",
@ -132,7 +132,7 @@
"@types/google-spreadsheet": "3.1.5", "@types/google-spreadsheet": "3.1.5",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/jsonpath": "0.2.4", "@types/jsonpath": "0.2.4",
"@types/lodash": "4.17.23", "@types/lodash": "4.17.24",
"@types/node": "22.15.17", "@types/node": "22.15.17",
"@types/papaparse": "5.3.7", "@types/papaparse": "5.3.7",
"@types/passport-google-oauth20": "2.0.17", "@types/passport-google-oauth20": "2.0.17",
@ -8348,6 +8348,12 @@
"rxjs": "^7.1.0" "rxjs": "^7.1.0"
} }
}, },
"node_modules/@nestjs/config/node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/@nestjs/core": { "node_modules/@nestjs/core": {
"version": "11.1.14", "version": "11.1.14",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.14.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.14.tgz",
@ -14492,9 +14498,9 @@
} }
}, },
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
"version": "4.17.23", "version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -29197,9 +29203,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {

6
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.253.0", "version": "2.254.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -118,7 +118,7 @@
"http-status-codes": "2.3.0", "http-status-codes": "2.3.0",
"ionicons": "8.0.13", "ionicons": "8.0.13",
"jsonpath": "1.2.1", "jsonpath": "1.2.1",
"lodash": "4.17.23", "lodash": "4.18.1",
"marked": "17.0.2", "marked": "17.0.2",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"ng-extract-i18n-merge": "3.3.0", "ng-extract-i18n-merge": "3.3.0",
@ -177,7 +177,7 @@
"@types/google-spreadsheet": "3.1.5", "@types/google-spreadsheet": "3.1.5",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/jsonpath": "0.2.4", "@types/jsonpath": "0.2.4",
"@types/lodash": "4.17.23", "@types/lodash": "4.17.24",
"@types/node": "22.15.17", "@types/node": "22.15.17",
"@types/papaparse": "5.3.7", "@types/papaparse": "5.3.7",
"@types/passport-google-oauth20": "2.0.17", "@types/passport-google-oauth20": "2.0.17",

2
prisma/migrations/20260409154017_added_loan_to_asset_sub_class/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AssetSubClass" ADD VALUE 'LOAN';

1
prisma/schema.prisma

@ -309,6 +309,7 @@ enum AssetSubClass {
COMMODITY COMMODITY
CRYPTOCURRENCY CRYPTOCURRENCY
ETF ETF
LOAN
MUTUALFUND MUTUALFUND
PRECIOUS_METAL PRECIOUS_METAL
PRIVATE_EQUITY PRIVATE_EQUITY

Loading…
Cancel
Save