From 34bb4f302fdec20215d698b5b6f4c7c0f0953d5c Mon Sep 17 00:00:00 2001 From: Tobias Messner Date: Wed, 9 Oct 2024 17:15:39 +0200 Subject: [PATCH] feat: Add indicator to warn when most t4c licenses are being used Closes #1784 --- .../modelsources/t4c/license_server/models.py | 9 ++ .../modelsources/t4c/license_server/routes.py | 23 +++- .../settings/modelsources/t4c/routes.py | 5 +- ...delsources-t4-c-license-servers.service.ts | 32 ++--- .../license-indicator.component.html | 32 +++++ .../license-indicator.component.ts | 47 +++++++ .../license-indicator.stories.ts | 129 ++++++++++++++++++ .../license-usage.service.ts | 36 +++++ .../create-persistent-session.component.html | 2 + .../create-persistent-session.component.ts | 12 +- .../create-persistent-session.stories.ts | 80 +++++++++++ 11 files changed, 378 insertions(+), 29 deletions(-) create mode 100644 frontend/src/app/sessions/license-indicator/license-indicator.component.html create mode 100644 frontend/src/app/sessions/license-indicator/license-indicator.component.ts create mode 100644 frontend/src/app/sessions/license-indicator/license-indicator.stories.ts create mode 100644 frontend/src/app/sessions/license-indicator/license-usage.service.ts create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts diff --git a/backend/capellacollab/settings/modelsources/t4c/license_server/models.py b/backend/capellacollab/settings/modelsources/t4c/license_server/models.py index 96b6ac73a1..0d86987e5d 100644 --- a/backend/capellacollab/settings/modelsources/t4c/license_server/models.py +++ b/backend/capellacollab/settings/modelsources/t4c/license_server/models.py @@ -65,9 +65,18 @@ class T4CLicenseServer(T4CLicenseServerBase): usage: interface.T4CLicenseServerUsage | None = None warnings: list[core_models.Message] = [] instances: list[t4c_instance_models2.SimpleT4CInstance] = [] + anonymized: bool = pydantic.Field(default=False, exclude=True) + + def anonymize(self): + self.usage_api = "" + self.license_key = "" + self.anonymized = True @pydantic.model_validator(mode="after") def add_from_api(self) -> t.Any: + if self.anonymized: + return self + self.license_server_version = interface.get_t4c_license_server_version( self.usage_api ) diff --git a/backend/capellacollab/settings/modelsources/t4c/license_server/routes.py b/backend/capellacollab/settings/modelsources/t4c/license_server/routes.py index 8a07dc837e..c860bf1c5a 100644 --- a/backend/capellacollab/settings/modelsources/t4c/license_server/routes.py +++ b/backend/capellacollab/settings/modelsources/t4c/license_server/routes.py @@ -1,14 +1,13 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 -from collections import abc - import fastapi from sqlalchemy import orm from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables from capellacollab.users import models as users_models +from capellacollab.users import injectables as users_injectables from . import crud, exceptions, injectables, interface, models @@ -25,11 +24,23 @@ router = fastapi.APIRouter() -@admin_router.get("", response_model=list[models.T4CLicenseServer]) +@router.get("") def get_t4c_license_servers( db: orm.Session = fastapi.Depends(database.get_db), -) -> abc.Sequence[models.DatabaseT4CLicenseServer]: - return crud.get_t4c_license_servers(db) + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_own_user + ), +) -> list[models.T4CLicenseServer]: + license_servers = [ + models.T4CLicenseServer.model_validate(license_server) + for license_server in crud.get_t4c_license_servers(db) + ] + + if user.role != users_models.Role.ADMIN: + for license_server in license_servers: + license_server.anonymize() + + return license_servers @admin_router.get( @@ -99,7 +110,7 @@ def delete_t4c_license_server( "/{t4c_license_server_id}/usage", response_model=interface.T4CLicenseServerUsage, ) -def fetch_t4c_license_server_licenses( +def get_t4c_license_server_usage( license_server: models.DatabaseT4CLicenseServer = fastapi.Depends( injectables.get_existing_license_server ), diff --git a/backend/capellacollab/settings/modelsources/t4c/routes.py b/backend/capellacollab/settings/modelsources/t4c/routes.py index 93e82797ef..20bb36cd86 100644 --- a/backend/capellacollab/settings/modelsources/t4c/routes.py +++ b/backend/capellacollab/settings/modelsources/t4c/routes.py @@ -19,13 +19,14 @@ ) router.include_router( - settings_t4c_license_server_routes.admin_router, + settings_t4c_license_server_routes.router, prefix="/license-servers", tags=["Settings - Modelsources - T4C - License Servers"], ) + router.include_router( - settings_t4c_license_server_routes.router, + settings_t4c_license_server_routes.admin_router, prefix="/license-servers", tags=["Settings - Modelsources - T4C - License Servers"], ) diff --git a/frontend/src/app/openapi/api/settings-modelsources-t4-c-license-servers.service.ts b/frontend/src/app/openapi/api/settings-modelsources-t4-c-license-servers.service.ts index f5d472cb3c..f1feab5f8f 100644 --- a/frontend/src/app/openapi/api/settings-modelsources-t4-c-license-servers.service.ts +++ b/frontend/src/app/openapi/api/settings-modelsources-t4-c-license-servers.service.ts @@ -338,17 +338,17 @@ export class SettingsModelsourcesT4CLicenseServersService { } /** - * Fetch T4C License Server Licenses + * Get T4C License Server * @param t4cLicenseServerId * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public fetchT4cLicenseServerLicenses(t4cLicenseServerId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public fetchT4cLicenseServerLicenses(t4cLicenseServerId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public fetchT4cLicenseServerLicenses(t4cLicenseServerId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public fetchT4cLicenseServerLicenses(t4cLicenseServerId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + public getT4cLicenseServer(t4cLicenseServerId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getT4cLicenseServer(t4cLicenseServerId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getT4cLicenseServer(t4cLicenseServerId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getT4cLicenseServer(t4cLicenseServerId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (t4cLicenseServerId === null || t4cLicenseServerId === undefined) { - throw new Error('Required parameter t4cLicenseServerId was null or undefined when calling fetchT4cLicenseServerLicenses.'); + throw new Error('Required parameter t4cLicenseServerId was null or undefined when calling getT4cLicenseServer.'); } let localVarHeaders = this.defaultHeaders; @@ -394,8 +394,8 @@ export class SettingsModelsourcesT4CLicenseServersService { } } - let localVarPath = `/api/v1/settings/modelsources/t4c/license-servers/${this.configuration.encodeParam({name: "t4cLicenseServerId", value: t4cLicenseServerId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: undefined})}/usage`; - return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + let localVarPath = `/api/v1/settings/modelsources/t4c/license-servers/${this.configuration.encodeParam({name: "t4cLicenseServerId", value: t4cLicenseServerId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: undefined})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, responseType: responseType_, @@ -409,17 +409,17 @@ export class SettingsModelsourcesT4CLicenseServersService { } /** - * Get T4C License Server + * Get T4C License Server Usage * @param t4cLicenseServerId * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getT4cLicenseServer(t4cLicenseServerId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public getT4cLicenseServer(t4cLicenseServerId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getT4cLicenseServer(t4cLicenseServerId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getT4cLicenseServer(t4cLicenseServerId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + public getT4cLicenseServerUsage(t4cLicenseServerId: number, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getT4cLicenseServerUsage(t4cLicenseServerId: number, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getT4cLicenseServerUsage(t4cLicenseServerId: number, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getT4cLicenseServerUsage(t4cLicenseServerId: number, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (t4cLicenseServerId === null || t4cLicenseServerId === undefined) { - throw new Error('Required parameter t4cLicenseServerId was null or undefined when calling getT4cLicenseServer.'); + throw new Error('Required parameter t4cLicenseServerId was null or undefined when calling getT4cLicenseServerUsage.'); } let localVarHeaders = this.defaultHeaders; @@ -465,8 +465,8 @@ export class SettingsModelsourcesT4CLicenseServersService { } } - let localVarPath = `/api/v1/settings/modelsources/t4c/license-servers/${this.configuration.encodeParam({name: "t4cLicenseServerId", value: t4cLicenseServerId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: undefined})}`; - return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + let localVarPath = `/api/v1/settings/modelsources/t4c/license-servers/${this.configuration.encodeParam({name: "t4cLicenseServerId", value: t4cLicenseServerId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: undefined})}/usage`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, responseType: responseType_, diff --git a/frontend/src/app/sessions/license-indicator/license-indicator.component.html b/frontend/src/app/sessions/license-indicator/license-indicator.component.html new file mode 100644 index 0000000000..d240955af4 --- /dev/null +++ b/frontend/src/app/sessions/license-indicator/license-indicator.component.html @@ -0,0 +1,32 @@ + + +
+ @if ((licenseUsageWrapperService.licenseServerUsage$ | async) !== undefined) { + @for ( + licenseServer of licenseUsageWrapperService.licenseServerUsage$ | async; + track licenseServer.id + ) { + @if (licenseServer.usage) { +
+ {{ + getLevel(licenseServer.usage)?.icon + }} + {{ getLevel(licenseServer.usage)?.text }} +
+ } + } + } +
diff --git a/frontend/src/app/sessions/license-indicator/license-indicator.component.ts b/frontend/src/app/sessions/license-indicator/license-indicator.component.ts new file mode 100644 index 0000000000..0e327b18d9 --- /dev/null +++ b/frontend/src/app/sessions/license-indicator/license-indicator.component.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { AsyncPipe, NgClass } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { T4CLicenseServerUsage } from '../../openapi'; +import { LicenseUsageWrapperService } from './license-usage.service'; + +@Component({ + selector: 'app-license-indicator', + standalone: true, + imports: [MatIcon, AsyncPipe, NgClass], + templateUrl: './license-indicator.component.html', +}) +export class LicenseIndicatorComponent { + constructor(public licenseUsageWrapperService: LicenseUsageWrapperService) {} + + getLevel(usage: T4CLicenseServerUsage) { + const usagePercentage = (usage.total - usage.free) / usage.total; + return this.levels.find( + (level) => usagePercentage * 100 >= level.percentage, + ); + } + + levels = [ + { + percentage: 100, + text: 'All TeamForCapella licenses are currently in use. You can start new sessions, but may encounter the error "Invalid license" when trying to use TeamForCapella. Please make sure to terminate your sessions after use.', + icon: 'error', + classes: 'bg-red-500 text-white', + }, + { + percentage: 75, + text: 'Most TeamForCapella licenses are currently in use. Please make sure to terminate your sessions after use.', + icon: 'warning', + classes: 'bg-yellow-300', + }, + { + percentage: 0, + text: 'TeamForCapella licenses are available. You can start new sessions without any issues.', + icon: 'check_circle', + classes: '', + }, + ]; +} diff --git a/frontend/src/app/sessions/license-indicator/license-indicator.stories.ts b/frontend/src/app/sessions/license-indicator/license-indicator.stories.ts new file mode 100644 index 0000000000..2f4b91c587 --- /dev/null +++ b/frontend/src/app/sessions/license-indicator/license-indicator.stories.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from '@storybook/angular'; +import { BehaviorSubject } from 'rxjs'; +import { T4CLicenseServer } from '../../openapi'; +import { LicenseIndicatorComponent } from './license-indicator.component'; +import { LicenseUsageWrapperService } from './license-usage.service'; + +const meta: Meta = { + title: 'Session Components/License Indicator', + component: LicenseIndicatorComponent, + decorators: [ + componentWrapperDecorator( + (story) => + `
+ ${story} +
`, + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export class MockLicenseUsageWrapperService + implements Partial +{ + private _licenseServerUsage = new BehaviorSubject< + T4CLicenseServer[] | undefined + >(undefined); + readonly licenseServerUsage$ = this._licenseServerUsage.asObservable(); + constructor(licenseServerUsage: T4CLicenseServer[]) { + this._licenseServerUsage.next(licenseServerUsage); + } +} + +export const AllUsed: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: LicenseUsageWrapperService, + useFactory: () => + new MockLicenseUsageWrapperService([ + { + id: 1, + name: 'Test', + usage: { + free: 0, + total: 30, + }, + license_server_version: '', + license_key: '', + usage_api: '', + instances: [], + warnings: [], + }, + ]), + }, + ], + }), + ], +}; + +export const SomeUsed: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: LicenseUsageWrapperService, + useFactory: () => + new MockLicenseUsageWrapperService([ + { + id: 1, + name: 'Test', + usage: { + free: 5, + total: 30, + }, + license_server_version: '', + license_key: '', + usage_api: '', + instances: [], + warnings: [], + }, + ]), + }, + ], + }), + ], +}; + +export const FewUsed: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: LicenseUsageWrapperService, + useFactory: () => + new MockLicenseUsageWrapperService([ + { + id: 1, + name: 'Test', + usage: { + free: 25, + total: 30, + }, + license_server_version: '', + license_key: '', + usage_api: '', + instances: [], + warnings: [], + }, + ]), + }, + ], + }), + ], +}; diff --git a/frontend/src/app/sessions/license-indicator/license-usage.service.ts b/frontend/src/app/sessions/license-indicator/license-usage.service.ts new file mode 100644 index 0000000000..8ff62b1ce1 --- /dev/null +++ b/frontend/src/app/sessions/license-indicator/license-usage.service.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, tap } from 'rxjs'; +import { SKIP_ERROR_HANDLING_CONTEXT } from '../../general/error-handling/error-handling.interceptor'; +import { + T4CLicenseServer, + SettingsModelsourcesT4CLicenseServersService, +} from '../../openapi'; + +@Injectable({ + providedIn: 'root', +}) +export class LicenseUsageWrapperService { + private _licenseServerUsage = new BehaviorSubject< + T4CLicenseServer[] | undefined + >(undefined); + + readonly licenseServerUsage$ = this._licenseServerUsage.asObservable(); + + constructor( + private licenseServerService: SettingsModelsourcesT4CLicenseServersService, + ) { + this.loadLicenseServerUsage().subscribe(); + } + + loadLicenseServerUsage(): Observable { + return this.licenseServerService + .getT4cLicenseServers(undefined, undefined, { + context: SKIP_ERROR_HANDLING_CONTEXT, + }) + .pipe(tap((usage) => this._licenseServerUsage.next(usage))); + } +} diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html index 5eb8274b01..8d80805df7 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.html @@ -91,6 +91,8 @@

Persistent Workspace Session

+ +
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts index 0756915cac..bd8e7dac97 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts @@ -2,23 +2,23 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { NgFor, NgIf, AsyncPipe } from '@angular/common'; +import { AsyncPipe, NgFor, NgIf } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup, - Validators, FormsModule, ReactiveFormsModule, + Validators, } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatOption } from '@angular/material/core'; -import { MatFormField, MatLabel, MatError } from '@angular/material/form-field'; +import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; import { MatIcon } from '@angular/material/icon'; -import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { MatSelect } from '@angular/material/select'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { Observable, map } from 'rxjs'; +import { map, Observable } from 'rxjs'; import { Session, Tool, ToolVersion } from 'src/app/openapi'; import { SessionService } from 'src/app/sessions/service/session.service'; import { UserSessionService } from 'src/app/sessions/service/user-session.service'; @@ -26,6 +26,7 @@ import { ConnectionMethod, ToolWrapperService, } from 'src/app/settings/core/tools-settings/tool.service'; +import { LicenseIndicatorComponent } from '../../../license-indicator/license-indicator.component'; import { CreateSessionHistoryComponent } from '../create-session-history/create-session-history.component'; @UntilDestroy() @@ -50,6 +51,7 @@ import { CreateSessionHistoryComponent } from '../create-session-history/create- MatIcon, CreateSessionHistoryComponent, AsyncPipe, + LicenseIndicatorComponent, ], }) export class CreatePersistentSessionComponent implements OnInit { diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts new file mode 100644 index 0000000000..564fc3c7ae --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.stories.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from '@storybook/angular'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { mockTool } from '../../../../../storybook/tool'; +import { Tool } from '../../../../openapi'; +import { ToolWrapperService } from '../../../../settings/core/tools-settings/tool.service'; +import { MockLicenseUsageWrapperService } from '../../../license-indicator/license-indicator.stories'; +import { LicenseUsageWrapperService } from '../../../license-indicator/license-usage.service'; +import { CreatePersistentSessionComponent } from './create-persistent-session.component'; + +const meta: Meta = { + title: 'Session Components/Create Persistent Session', + component: CreatePersistentSessionComponent, + decorators: [ + componentWrapperDecorator( + (story) => + `
+ ${story} +
`, + ), + ], +}; + +export default meta; +type Story = StoryObj; + +class MockToolWrapperService implements Partial { + _tools = new BehaviorSubject(undefined); + get tools(): Tool[] | undefined { + return this._tools.getValue(); + } + get tools$(): Observable { + return this._tools.asObservable(); + } + + constructor(tools: Tool[]) { + this._tools.next(tools); + } +} + +export const Default: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: LicenseUsageWrapperService, + useFactory: () => + new MockLicenseUsageWrapperService([ + { + id: 1, + name: 'Test', + usage: { + free: 0, + total: 30, + }, + license_server_version: '', + license_key: '', + usage_api: '', + instances: [], + warnings: [], + }, + ]), + }, + { + provide: ToolWrapperService, + useFactory: () => new MockToolWrapperService([mockTool]), + }, + ], + }), + ], +};