Browse Source

Add AI service health check and improve layout

pull/6828/head
Thomas Kaul 2 weeks ago
parent
commit
ca066f09a5
  1. 1
      apps/api/src/app/endpoints/ai/ai.module.ts
  2. 13
      apps/api/src/app/endpoints/ai/ai.service.ts
  3. 31
      apps/api/src/app/health/health.controller.ts
  4. 2
      apps/api/src/app/health/health.module.ts
  5. 62
      apps/client/src/app/pages/api/api-page.component.ts
  6. 244
      apps/client/src/app/pages/api/api-page.html
  7. 5
      apps/client/src/app/pages/api/interfaces/interfaces.ts
  8. 2
      libs/common/src/lib/interfaces/index.ts
  9. 3
      libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts

1
apps/api/src/app/endpoints/ai/ai.module.ts

@ -28,6 +28,7 @@ import { AiService } from './ai.service';
@Module({ @Module({
controllers: [AiController], controllers: [AiController],
exports: [AiService],
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ApiModule, ApiModule,

13
apps/api/src/app/endpoints/ai/ai.service.ts

@ -1,4 +1,5 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; 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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
@ -36,11 +37,18 @@ export class AiService {
]; ];
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService 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<string>( const openRouterApiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_OPENROUTER PROPERTY_API_KEY_OPENROUTER
); );
@ -55,7 +63,8 @@ export class AiService {
return generateText({ return generateText({
prompt, prompt,
model: openRouterService.chat(openRouterModel) model: openRouterService.chat(openRouterModel),
timeout: requestTimeout
}); });
} }

31
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { import {
AiServiceHealthResponse,
DataEnhancerHealthResponse, DataEnhancerHealthResponse,
DataProviderHealthResponse DataProviderHealthResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -9,6 +11,7 @@ import {
Get, Get,
HttpException, HttpException,
HttpStatus, HttpStatus,
Logger,
Param, Param,
Res, Res,
UseInterceptors UseInterceptors
@ -21,7 +24,10 @@ import { HealthService } from './health.service';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
public constructor(private readonly healthService: HealthService) {} public constructor(
private readonly aiService: AiService,
private readonly healthService: HealthService
) {}
@Get() @Get()
public async getHealth(@Res() response: Response) { public async getHealth(@Res() response: Response) {
@ -40,6 +46,29 @@ export class HealthController {
} }
} }
@Get('ai')
public async getHealthOfAiService(
@Res() response: Response
): Promise<Response<AiServiceHealthResponse>> {
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') @Get('data-enhancer/:name')
public async getHealthOfDataEnhancer( public async getHealthOfDataEnhancer(
@Param('name') name: string, @Param('name') name: string,

2
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 { 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 { 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'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
@ -12,6 +13,7 @@ import { HealthService } from './health.service';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
imports: [ imports: [
AiModule,
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
PropertyModule, PropertyModule,

62
apps/client/src/app/pages/api/api-page.component.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AiServiceHealthResponse,
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DividendsResponse, DividendsResponse,
@ -13,27 +14,41 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common'; 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 { Component, DestroyRef, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
import { format, startOfYear } from 'date-fns'; 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({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [CommonModule], imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule],
selector: 'gf-api-page', selector: 'gf-api-page',
styleUrls: ['./api-page.scss'], styleUrls: ['./api-page.scss'],
templateUrl: './api-page.html' templateUrl: './api-page.html'
}) })
export class GfApiPageComponent implements OnInit { export class GfApiPageComponent implements OnInit {
public assetProfile$: Observable<DataProviderGhostfolioAssetProfileResponse>; public aiServiceHealth$: Observable<FetchResult<AiServiceHealthResponse>>;
public dividends$: Observable<DividendsResponse['dividends']>; public assetProfile$: Observable<
public historicalData$: Observable<HistoricalResponse['historicalData']>; FetchResult<DataProviderGhostfolioAssetProfileResponse>
public isinLookupItems$: Observable<LookupResponse['items']>; >;
public lookupItems$: Observable<LookupResponse['items']>; public dividends$: Observable<FetchResult<DividendsResponse['dividends']>>;
public quotes$: Observable<QuotesResponse['quotes']>; public historicalData$: Observable<
public status$: Observable<DataProviderGhostfolioStatusResponse>; FetchResult<HistoricalResponse['historicalData']>
>;
public isinLookupItems$: Observable<FetchResult<LookupResponse['items']>>;
public lookupItems$: Observable<FetchResult<LookupResponse['items']>>;
public quotes$: Observable<FetchResult<QuotesResponse['quotes']>>;
public status$: Observable<FetchResult<DataProviderGhostfolioStatusResponse>>;
private apiKey: string; private apiKey: string;
@ -45,6 +60,7 @@ export class GfApiPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
this.aiServiceHealth$ = this.fetchAiServiceHealth();
this.assetProfile$ = this.fetchAssetProfile({ symbol: 'AAPL' }); this.assetProfile$ = this.fetchAssetProfile({ symbol: 'AAPL' });
this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
@ -54,13 +70,31 @@ export class GfApiPageComponent implements OnInit {
this.status$ = this.fetchStatus(); this.status$ = this.fetchStatus();
} }
public isFetchFailure(value: unknown): value is FetchFailure {
return typeof value === 'object' && value !== null && 'fetchError' in value;
}
private catchFetchFailure<T>(): OperatorFunction<T, T | FetchFailure> {
return catchError(({ error }: HttpErrorResponse) => {
const body = error as { status?: string };
return of<FetchFailure>({ fetchError: body?.status ?? 'Error' });
}) as OperatorFunction<T, T | FetchFailure>;
}
private fetchAiServiceHealth() {
return this.http
.get<AiServiceHealthResponse>('/api/v1/health/ai')
.pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef));
}
private fetchAssetProfile({ symbol }: { symbol: string }) { private fetchAssetProfile({ symbol }: { symbol: string }) {
return this.http return this.http
.get<DataProviderGhostfolioAssetProfileResponse>( .get<DataProviderGhostfolioAssetProfileResponse>(
`/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`, `/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ headers: this.getHeaders() } { headers: this.getHeaders() }
) )
.pipe(takeUntilDestroyed(this.destroyRef)); .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef));
} }
private fetchDividends({ symbol }: { symbol: string }) { private fetchDividends({ symbol }: { symbol: string }) {
@ -80,6 +114,7 @@ export class GfApiPageComponent implements OnInit {
map(({ dividends }) => { map(({ dividends }) => {
return dividends; return dividends;
}), }),
this.catchFetchFailure(),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -101,6 +136,7 @@ export class GfApiPageComponent implements OnInit {
map(({ historicalData }) => { map(({ historicalData }) => {
return historicalData; return historicalData;
}), }),
this.catchFetchFailure(),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -127,6 +163,7 @@ export class GfApiPageComponent implements OnInit {
map(({ items }) => { map(({ items }) => {
return items; return items;
}), }),
this.catchFetchFailure(),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -143,6 +180,7 @@ export class GfApiPageComponent implements OnInit {
map(({ quotes }) => { map(({ quotes }) => {
return quotes; return quotes;
}), }),
this.catchFetchFailure(),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
} }
@ -153,7 +191,7 @@ export class GfApiPageComponent implements OnInit {
'/api/v2/data-providers/ghostfolio/status', '/api/v2/data-providers/ghostfolio/status',
{ headers: this.getHeaders() } { headers: this.getHeaders() }
) )
.pipe(takeUntilDestroyed(this.destroyRef)); .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef));
} }
private getHeaders() { private getHeaders() {

244
apps/client/src/app/pages/api/api-page.html

@ -1,77 +1,173 @@
<div class="container"> <div class="container">
<div class="mb-3"> <div class="mb-5 row">
<h2 class="text-center">Status</h2> <div class="col">
<div>{{ status$ | async | json }}</div> <mat-card appearance="outlined" class="mb-3">
</div> <mat-card-header>
<div class="mb-3"> <mat-card-title>AI Service Health</mat-card-title>
<h2 class="text-center">Asset Profile</h2> </mat-card-header>
<div>{{ assetProfile$ | async | json }}</div> <mat-card-content>
</div> @let aiServiceHealth = aiServiceHealth$ | async;
<div> @if (isFetchFailure(aiServiceHealth)) {
<h2 class="text-center">Lookup</h2> 🔴 {{ aiServiceHealth.fetchError }}
@if (lookupItems$) { } @else if (aiServiceHealth) {
@let symbols = lookupItems$ | async; 🟢 {{ aiServiceHealth.status }}
<ul> } @else {
@for (item of symbols; track item.symbol) { <ngx-skeleton-loader
<li>{{ item.name }} ({{ item.symbol }})</li> animation="pulse"
} [theme]="{ height: '1.5rem', width: '100%' }"
</ul> />
} }
</div> </mat-card-content>
<div> </mat-card>
<h2 class="text-center">Lookup (ISIN)</h2> <mat-card appearance="outlined" class="mb-3">
@if (isinLookupItems$) { <mat-card-header>
@let symbols = isinLookupItems$ | async; <mat-card-title>Status</mat-card-title>
<ul> </mat-card-header>
@for (item of symbols; track item.symbol) { <mat-card-content>
<li>{{ item.name }} ({{ item.symbol }})</li> @let status = status$ | async;
} @if (isFetchFailure(status)) {
</ul> 🔴 {{ status.fetchError }}
} } @else if (status) {
</div> <pre><code>{{ status | json }}</code></pre>
<div> } @else {
<h2 class="text-center">Quotes</h2> <ngx-skeleton-loader
@if (quotes$) { animation="pulse"
@let quotes = quotes$ | async; [theme]="{ height: '1.5rem', width: '100%' }"
<ul> />
@for (quote of quotes | keyvalue; track quote) { }
<li> </mat-card-content>
{{ quote.key }}: {{ quote.value.marketPrice }} </mat-card>
{{ quote.value.currency }} <mat-card appearance="outlined" class="mb-3">
</li> <mat-card-header>
} <mat-card-title>Asset Profile</mat-card-title>
</ul> </mat-card-header>
} <mat-card-content>
</div> @let assetProfile = assetProfile$ | async;
<div> @if (isFetchFailure(assetProfile)) {
<h2 class="text-center">Historical</h2> 🔴 {{ assetProfile.fetchError }}
@if (historicalData$) { } @else if (assetProfile) {
@let historicalData = historicalData$ | async; <pre><code>{{ assetProfile | json }}</code></pre>
<ul> } @else {
@for ( <ngx-skeleton-loader
historicalDataItem of historicalData | keyvalue; animation="pulse"
track historicalDataItem [theme]="{ height: '1.5rem', width: '100%' }"
) { />
<li> }
{{ historicalDataItem.key }}: </mat-card-content>
{{ historicalDataItem.value.marketPrice }} </mat-card>
</li> <mat-card appearance="outlined" class="mb-3">
} <mat-card-header>
</ul> <mat-card-title>Lookup</mat-card-title>
} </mat-card-header>
</div> <mat-card-content>
<div> @let symbols = lookupItems$ | async;
<h2 class="text-center">Dividends</h2> @if (isFetchFailure(symbols)) {
@if (dividends$) { 🔴 {{ symbols.fetchError }}
@let dividends = dividends$ | async; } @else if (symbols) {
<ul> <ul>
@for (dividend of dividends | keyvalue; track dividend) { @for (item of symbols; track item.symbol) {
<li> <li>{{ item.name }} ({{ item.symbol }})</li>
{{ dividend.key }}: }
{{ dividend.value.marketPrice }} </ul>
</li> } @else {
} <ngx-skeleton-loader
</ul> animation="pulse"
} [theme]="{ height: '1.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Lookup (ISIN)</mat-card-title>
</mat-card-header>
<mat-card-content>
@let isinSymbols = isinLookupItems$ | async;
@if (isFetchFailure(isinSymbols)) {
🔴 {{ isinSymbols.fetchError }}
} @else if (isinSymbols) {
<ul>
@for (item of isinSymbols; track item.symbol) {
<li>{{ item.name }} ({{ item.symbol }})</li>
}
</ul>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '1.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Quotes</mat-card-title>
</mat-card-header>
<mat-card-content>
@let quotes = quotes$ | async;
@if (isFetchFailure(quotes)) {
🔴 {{ quotes.fetchError }}
} @else if (quotes) {
<ul>
@for (quote of quotes | keyvalue; track quote) {
<li>
{{ quote.key }}: {{ quote.value.marketPrice }}
{{ quote.value.currency }}
</li>
}
</ul>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '1.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Historical</mat-card-title>
</mat-card-header>
<mat-card-content>
@let historicalData = historicalData$ | async;
@if (isFetchFailure(historicalData)) {
🔴 {{ historicalData.fetchError }}
} @else if (historicalData) {
<ul>
@for (item of historicalData | keyvalue; track item) {
<li>{{ item.key }}: {{ item.value.marketPrice }}</li>
}
</ul>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '1.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Dividends</mat-card-title>
</mat-card-header>
<mat-card-content>
@let dividends = dividends$ | async;
@if (isFetchFailure(dividends)) {
🔴 {{ dividends.fetchError }}
} @else if (dividends) {
<ul>
@for (dividend of dividends | keyvalue; track dividend) {
<li>{{ dividend.key }}: {{ dividend.value.marketPrice }}</li>
}
</ul>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '1.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
</div>
</div> </div>
</div> </div>

5
apps/client/src/app/pages/api/interfaces/interfaces.ts

@ -0,0 +1,5 @@
export interface FetchFailure {
fetchError: string;
}
export type FetchResult<T> = T | FetchFailure;

2
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 { AdminUserResponse } from './responses/admin-user-response.interface';
import type { AdminUsersResponse } from './responses/admin-users-response.interface'; import type { AdminUsersResponse } from './responses/admin-users-response.interface';
import type { AiPromptResponse } from './responses/ai-prompt-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 { ApiKeyResponse } from './responses/api-key-response.interface';
import type { AssetResponse } from './responses/asset-response.interface'; import type { AssetResponse } from './responses/asset-response.interface';
import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface';
@ -117,6 +118,7 @@ export {
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
AiPromptResponse, AiPromptResponse,
AiServiceHealthResponse,
ApiKeyResponse, ApiKeyResponse,
AssertionCredentialJSON, AssertionCredentialJSON,
AssetClassSelectorOption, AssetClassSelectorOption,

3
libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts

@ -0,0 +1,3 @@
export interface AiServiceHealthResponse {
status: string;
}
Loading…
Cancel
Save