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. 178
      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({
controllers: [AiController],
exports: [AiService],
imports: [
ActivitiesModule,
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 { 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<string>(
PROPERTY_API_KEY_OPENROUTER
);
@ -55,7 +63,8 @@ export class AiService {
return generateText({
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 {
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<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')
public async getHealthOfDataEnhancer(
@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 { 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,

62
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<DataProviderGhostfolioAssetProfileResponse>;
public dividends$: Observable<DividendsResponse['dividends']>;
public historicalData$: Observable<HistoricalResponse['historicalData']>;
public isinLookupItems$: Observable<LookupResponse['items']>;
public lookupItems$: Observable<LookupResponse['items']>;
public quotes$: Observable<QuotesResponse['quotes']>;
public status$: Observable<DataProviderGhostfolioStatusResponse>;
public aiServiceHealth$: Observable<FetchResult<AiServiceHealthResponse>>;
public assetProfile$: Observable<
FetchResult<DataProviderGhostfolioAssetProfileResponse>
>;
public dividends$: Observable<FetchResult<DividendsResponse['dividends']>>;
public historicalData$: Observable<
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;
@ -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<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 }) {
return this.http
.get<DataProviderGhostfolioAssetProfileResponse>(
`/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() {

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

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

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 { 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,

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