diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index eab4ecf8b..5267f40c8 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -28,6 +28,7 @@ import { AiService } from './ai.service'; @Module({ controllers: [AiController], + exports: [AiService], imports: [ ActivitiesModule, ApiModule, diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index d07768d69..362f4a728 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -1,4 +1,5 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PROPERTY_API_KEY_OPENROUTER, @@ -36,11 +37,18 @@ export class AiService { ]; public constructor( + private readonly configurationService: ConfigurationService, private readonly portfolioService: PortfolioService, private readonly propertyService: PropertyService ) {} - public async generateText({ prompt }: { prompt: string }) { + public async generateText({ + prompt, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: { + prompt: string; + requestTimeout?: number; + }) { const openRouterApiKey = await this.propertyService.getByKey( PROPERTY_API_KEY_OPENROUTER ); @@ -55,7 +63,8 @@ export class AiService { return generateText({ prompt, - model: openRouterService.chat(openRouterModel) + model: openRouterService.chat(openRouterModel), + timeout: requestTimeout }); } diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 5542ae933..37de9528e 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -1,5 +1,7 @@ +import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { + AiServiceHealthResponse, DataEnhancerHealthResponse, DataProviderHealthResponse } from '@ghostfolio/common/interfaces'; @@ -9,6 +11,7 @@ import { Get, HttpException, HttpStatus, + Logger, Param, Res, UseInterceptors @@ -21,7 +24,10 @@ import { HealthService } from './health.service'; @Controller('health') export class HealthController { - public constructor(private readonly healthService: HealthService) {} + public constructor( + private readonly aiService: AiService, + private readonly healthService: HealthService + ) {} @Get() public async getHealth(@Res() response: Response) { @@ -40,6 +46,29 @@ export class HealthController { } } + @Get('ai') + public async getHealthOfAiService( + @Res() response: Response + ): Promise> { + try { + const { text } = await this.aiService.generateText({ + prompt: 'Reply with the word "OK" and nothing else.' + }); + + if (text === 'OK') { + return response + .status(HttpStatus.OK) + .json({ status: getReasonPhrase(StatusCodes.OK) }); + } + } catch (error) { + Logger.error(error, 'HealthController'); + } + + return response + .status(HttpStatus.SERVICE_UNAVAILABLE) + .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); + } + @Get('data-enhancer/:name') public async getHealthOfDataEnhancer( @Param('name') name: string, diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts index b8c4d5810..c36924121 100644 --- a/apps/api/src/app/health/health.module.ts +++ b/apps/api/src/app/health/health.module.ts @@ -1,3 +1,4 @@ +import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; @@ -12,6 +13,7 @@ import { HealthService } from './health.service'; @Module({ controllers: [HealthController], imports: [ + AiModule, DataEnhancerModule, DataProviderModule, PropertyModule, diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index 357a08bbd..e425fbd10 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -4,6 +4,7 @@ import { } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { + AiServiceHealthResponse, DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioStatusResponse, DividendsResponse, @@ -13,27 +14,41 @@ import { } from '@ghostfolio/common/interfaces'; import { CommonModule } from '@angular/common'; -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { + HttpClient, + HttpErrorResponse, + HttpHeaders, + HttpParams +} from '@angular/common/http'; import { Component, DestroyRef, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatCardModule } from '@angular/material/card'; import { format, startOfYear } from 'date-fns'; -import { map, Observable } from 'rxjs'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { catchError, map, Observable, of, OperatorFunction } from 'rxjs'; + +import { FetchFailure, FetchResult } from './interfaces/interfaces'; @Component({ host: { class: 'page' }, - imports: [CommonModule], + imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule], selector: 'gf-api-page', styleUrls: ['./api-page.scss'], templateUrl: './api-page.html' }) export class GfApiPageComponent implements OnInit { - public assetProfile$: Observable; - public dividends$: Observable; - public historicalData$: Observable; - public isinLookupItems$: Observable; - public lookupItems$: Observable; - public quotes$: Observable; - public status$: Observable; + public aiServiceHealth$: Observable>; + public assetProfile$: Observable< + FetchResult + >; + public dividends$: Observable>; + public historicalData$: Observable< + FetchResult + >; + public isinLookupItems$: Observable>; + public lookupItems$: Observable>; + public quotes$: Observable>; + public status$: Observable>; private apiKey: string; @@ -45,6 +60,7 @@ export class GfApiPageComponent implements OnInit { public ngOnInit() { this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); + this.aiServiceHealth$ = this.fetchAiServiceHealth(); this.assetProfile$ = this.fetchAssetProfile({ symbol: 'AAPL' }); this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); @@ -54,13 +70,31 @@ export class GfApiPageComponent implements OnInit { this.status$ = this.fetchStatus(); } + public isFetchFailure(value: unknown): value is FetchFailure { + return typeof value === 'object' && value !== null && 'fetchError' in value; + } + + private catchFetchFailure(): OperatorFunction { + return catchError(({ error }: HttpErrorResponse) => { + const body = error as { status?: string }; + + return of({ fetchError: body?.status ?? 'Error' }); + }) as OperatorFunction; + } + + private fetchAiServiceHealth() { + return this.http + .get('/api/v1/health/ai') + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); + } + private fetchAssetProfile({ symbol }: { symbol: string }) { return this.http .get( `/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`, { headers: this.getHeaders() } ) - .pipe(takeUntilDestroyed(this.destroyRef)); + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); } private fetchDividends({ symbol }: { symbol: string }) { @@ -80,6 +114,7 @@ export class GfApiPageComponent implements OnInit { map(({ dividends }) => { return dividends; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -101,6 +136,7 @@ export class GfApiPageComponent implements OnInit { map(({ historicalData }) => { return historicalData; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -127,6 +163,7 @@ export class GfApiPageComponent implements OnInit { map(({ items }) => { return items; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -143,6 +180,7 @@ export class GfApiPageComponent implements OnInit { map(({ quotes }) => { return quotes; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -153,7 +191,7 @@ export class GfApiPageComponent implements OnInit { '/api/v2/data-providers/ghostfolio/status', { headers: this.getHeaders() } ) - .pipe(takeUntilDestroyed(this.destroyRef)); + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); } private getHeaders() { diff --git a/apps/client/src/app/pages/api/api-page.html b/apps/client/src/app/pages/api/api-page.html index 3c43484e6..07f5ec981 100644 --- a/apps/client/src/app/pages/api/api-page.html +++ b/apps/client/src/app/pages/api/api-page.html @@ -1,77 +1,173 @@
-
-

Status

-
{{ status$ | async | json }}
-
-
-

Asset Profile

-
{{ assetProfile$ | async | json }}
-
-
-

Lookup

- @if (lookupItems$) { - @let symbols = lookupItems$ | async; -
    - @for (item of symbols; track item.symbol) { -
  • {{ item.name }} ({{ item.symbol }})
  • - } -
- } -
-
-

Lookup (ISIN)

- @if (isinLookupItems$) { - @let symbols = isinLookupItems$ | async; -
    - @for (item of symbols; track item.symbol) { -
  • {{ item.name }} ({{ item.symbol }})
  • - } -
- } -
-
-

Quotes

- @if (quotes$) { - @let quotes = quotes$ | async; -
    - @for (quote of quotes | keyvalue; track quote) { -
  • - {{ quote.key }}: {{ quote.value.marketPrice }} - {{ quote.value.currency }} -
  • - } -
- } -
-
-

Historical

- @if (historicalData$) { - @let historicalData = historicalData$ | async; -
    - @for ( - historicalDataItem of historicalData | keyvalue; - track historicalDataItem - ) { -
  • - {{ historicalDataItem.key }}: - {{ historicalDataItem.value.marketPrice }} -
  • - } -
- } -
-
-

Dividends

- @if (dividends$) { - @let dividends = dividends$ | async; -
    - @for (dividend of dividends | keyvalue; track dividend) { -
  • - {{ dividend.key }}: - {{ dividend.value.marketPrice }} -
  • - } -
- } +
+
+ + + AI Service Health + + + @let aiServiceHealth = aiServiceHealth$ | async; + @if (isFetchFailure(aiServiceHealth)) { + 🔴 {{ aiServiceHealth.fetchError }} + } @else if (aiServiceHealth) { + 🟢 {{ aiServiceHealth.status }} + } @else { + + } + + + + + Status + + + @let status = status$ | async; + @if (isFetchFailure(status)) { + 🔴 {{ status.fetchError }} + } @else if (status) { +
{{ status | json }}
+ } @else { + + } +
+
+ + + Asset Profile + + + @let assetProfile = assetProfile$ | async; + @if (isFetchFailure(assetProfile)) { + 🔴 {{ assetProfile.fetchError }} + } @else if (assetProfile) { +
{{ assetProfile | json }}
+ } @else { + + } +
+
+ + + Lookup + + + @let symbols = lookupItems$ | async; + @if (isFetchFailure(symbols)) { + 🔴 {{ symbols.fetchError }} + } @else if (symbols) { +
    + @for (item of symbols; track item.symbol) { +
  • {{ item.name }} ({{ item.symbol }})
  • + } +
+ } @else { + + } +
+
+ + + Lookup (ISIN) + + + @let isinSymbols = isinLookupItems$ | async; + @if (isFetchFailure(isinSymbols)) { + 🔴 {{ isinSymbols.fetchError }} + } @else if (isinSymbols) { +
    + @for (item of isinSymbols; track item.symbol) { +
  • {{ item.name }} ({{ item.symbol }})
  • + } +
+ } @else { + + } +
+
+ + + Quotes + + + @let quotes = quotes$ | async; + @if (isFetchFailure(quotes)) { + 🔴 {{ quotes.fetchError }} + } @else if (quotes) { +
    + @for (quote of quotes | keyvalue; track quote) { +
  • + {{ quote.key }}: {{ quote.value.marketPrice }} + {{ quote.value.currency }} +
  • + } +
+ } @else { + + } +
+
+ + + Historical + + + @let historicalData = historicalData$ | async; + @if (isFetchFailure(historicalData)) { + 🔴 {{ historicalData.fetchError }} + } @else if (historicalData) { +
    + @for (item of historicalData | keyvalue; track item) { +
  • {{ item.key }}: {{ item.value.marketPrice }}
  • + } +
+ } @else { + + } +
+
+ + + Dividends + + + @let dividends = dividends$ | async; + @if (isFetchFailure(dividends)) { + 🔴 {{ dividends.fetchError }} + } @else if (dividends) { +
    + @for (dividend of dividends | keyvalue; track dividend) { +
  • {{ dividend.key }}: {{ dividend.value.marketPrice }}
  • + } +
+ } @else { + + } +
+
+
diff --git a/apps/client/src/app/pages/api/interfaces/interfaces.ts b/apps/client/src/app/pages/api/interfaces/interfaces.ts new file mode 100644 index 000000000..07de2b2f7 --- /dev/null +++ b/apps/client/src/app/pages/api/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +export interface FetchFailure { + fetchError: string; +} + +export type FetchResult = T | FetchFailure; diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index ad747d94e..89874da60 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -44,6 +44,7 @@ import type { ActivityResponse } from './responses/activity-response.interface'; import type { AdminUserResponse } from './responses/admin-user-response.interface'; import type { AdminUsersResponse } from './responses/admin-users-response.interface'; import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; +import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { AssetResponse } from './responses/asset-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; @@ -117,6 +118,7 @@ export { AdminUserResponse, AdminUsersResponse, AiPromptResponse, + AiServiceHealthResponse, ApiKeyResponse, AssertionCredentialJSON, AssetClassSelectorOption, diff --git a/libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts b/libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts new file mode 100644 index 000000000..58ffafafc --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts @@ -0,0 +1,3 @@ +export interface AiServiceHealthResponse { + status: string; +}