diff --git a/backend/capellacollab/config/models.py b/backend/capellacollab/config/models.py index 983b9756e..057c54b2e 100644 --- a/backend/capellacollab/config/models.py +++ b/backend/capellacollab/config/models.py @@ -291,6 +291,14 @@ class PipelineConfig(BaseConfig): ) +class SessionsConfig(BaseConfig): + timeout: int = pydantic.Field( + default=90, + description="The timeout (in minutes) for unused and idle sessions.", + examples=[60, 90], + ) + + class DatabaseConfig(BaseConfig): url: str = pydantic.Field( default="postgresql://dev:dev@localhost:5432/dev", @@ -396,4 +404,5 @@ class AppConfig(BaseConfig): logging: LoggingConfig = LoggingConfig() requests: RequestsConfig = RequestsConfig() pipelines: PipelineConfig = PipelineConfig() + sessions: SessionsConfig = SessionsConfig() smtp: SMTPConfig | None = SMTPConfig() diff --git a/backend/capellacollab/sessions/injection.py b/backend/capellacollab/sessions/injection.py index 87741d421..a89e26bd1 100644 --- a/backend/capellacollab/sessions/injection.py +++ b/backend/capellacollab/sessions/injection.py @@ -7,38 +7,47 @@ from capellacollab import core from capellacollab.config import config +from capellacollab.sessions import models2 as sessions_models2 log = logging.getLogger(__name__) -def get_last_seen(sid: str) -> str: - """Return project session last seen activity""" +def get_idle_state(sid: str) -> sessions_models2.IdleState: if core.LOCAL_DEVELOPMENT_MODE: - return "Disabled in development mode" + return sessions_models2.IdleState( + available=False, + unavailable_reason="Unavailable in local development mode", + terminate_after_minutes=config.sessions.timeout, + ) - url = f"{config.prometheus.url}/api/v1/query?query=idletime_minutes" try: response = requests.get( - url, + f'{config.prometheus.url}/api/v1/query?query=idletime_minutes{{session_id="{sid}"}}', timeout=config.requests.timeout, ) response.raise_for_status() - - for session in response.json()["data"]["result"]: - if sid == session["metric"]["session_id"]: - return _get_last_seen(float(session["value"][1])) - - log.debug("Couldn't find Prometheus metrics for session %s.", sid) except Exception: - log.exception("Exception during fetching of last seen.") - return "UNKNOWN" - - -def _get_last_seen(idletime: int | float) -> str: - if idletime == -1: - return "Never connected" + log.exception("Exception during fetching of idle state.") + return sessions_models2.IdleState( + available=False, + unavailable_reason="Exception during fetching of idle state", + terminate_after_minutes=config.sessions.timeout, + ) - if (idlehours := idletime / 60) > 1: - return f"{round(idlehours, 2)} hrs ago" + if len(response.json()["data"]["result"]) > 0: + idle_for_minutes = int( + response.json()["data"]["result"][0]["value"][1] + ) + return sessions_models2.IdleState( + available=True, + idle_for_minutes=idle_for_minutes, + terminate_after_minutes=config.sessions.timeout, + ) + else: + log.debug("Couldn't find Prometheus metrics for session %s.", sid) - return f"{idletime:.0f} mins ago" + return sessions_models2.IdleState( + available=False, + unavailable_reason="Unknown session", + terminate_after_minutes=config.sessions.timeout, + ) diff --git a/backend/capellacollab/sessions/models.py b/backend/capellacollab/sessions/models.py index 3330a3c8f..bf9f36f38 100644 --- a/backend/capellacollab/sessions/models.py +++ b/backend/capellacollab/sessions/models.py @@ -12,9 +12,11 @@ import sqlalchemy as sa from sqlalchemy import orm +from capellacollab.config import config from capellacollab.core import database from capellacollab.core import models as core_models from capellacollab.core import pydantic as core_pydantic +from capellacollab.sessions import models2 as sessions_models2 from capellacollab.sessions import operators from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models @@ -101,7 +103,13 @@ class Session(core_pydantic.BaseModel): ) state: SessionState = pydantic.Field(default=SessionState.UNKNOWN) warnings: list[core_models.Message] = pydantic.Field(default=[]) - last_seen: str = pydantic.Field(default="UNKNOWN") + idle_state: sessions_models2.IdleState = pydantic.Field( + default=sessions_models2.IdleState( + available=False, + terminate_after_minutes=config.sessions.timeout, + unavailable_reason="Uninitialized", + ) + ) connection_method_id: str connection_method: tools_models.ToolSessionConnectionMethod | None = None @@ -126,7 +134,7 @@ def resolve_connection_method(self) -> t.Any: @pydantic.model_validator(mode="after") def add_warnings_and_last_seen(self) -> t.Any: - self.last_seen = injection.get_last_seen(self.id) + self.idle_state = injection.get_idle_state(self.id) self.preparation_state, self.state = ( operators.get_operator().get_session_state(self.id) ) diff --git a/backend/capellacollab/sessions/models2.py b/backend/capellacollab/sessions/models2.py new file mode 100644 index 000000000..718f67424 --- /dev/null +++ b/backend/capellacollab/sessions/models2.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import pydantic + +from capellacollab.core import pydantic as core_pydantic + + +class IdleState(core_pydantic.BaseModel): + available: bool + idle_for_minutes: int | None = pydantic.Field( + default=None, + description="The number of minutes the session has been idle. Value is -1 if the session has never been connected to.", + ) + terminate_after_minutes: int + unavailable_reason: str | None = pydantic.Field(default=None) diff --git a/backend/tests/sessions/fixtures.py b/backend/tests/sessions/fixtures.py index 582b3c723..359ad88b3 100644 --- a/backend/tests/sessions/fixtures.py +++ b/backend/tests/sessions/fixtures.py @@ -7,10 +7,10 @@ import pytest from sqlalchemy import orm -from capellacollab import __main__ from capellacollab.sessions import crud as sessions_crud from capellacollab.sessions import injection as sessions_injection from capellacollab.sessions import models as sessions_models +from capellacollab.sessions import models2 as sessions_models2 from capellacollab.sessions.operators import k8s as k8s_operator from capellacollab.tools import models as tools_models from capellacollab.users import models as users_models @@ -59,7 +59,13 @@ def fixture_test_session( @pytest.fixture(name="mock_session_injection") def fixture_mock_session_injection(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - sessions_injection, "get_last_seen", lambda _: "UNKNOWN" + sessions_injection, + "get_idle_state", + lambda _: sessions_models2.IdleState( + available=False, + unavailable_reason="Unavailable during testing", + terminate_after_minutes=90, + ), ) monkeypatch.setattr( k8s_operator.KubernetesOperator, diff --git a/backend/tests/sessions/test_session_injection.py b/backend/tests/sessions/test_session_injection.py index 88e61fed3..3c326eca0 100644 --- a/backend/tests/sessions/test_session_injection.py +++ b/backend/tests/sessions/test_session_injection.py @@ -2,13 +2,72 @@ # SPDX-License-Identifier: Apache-2.0 import pytest +import responses +from fastapi import status from capellacollab import core +from capellacollab.config import config from capellacollab.sessions import injection +from capellacollab.sessions import models2 as models2_sessions -def test_get_last_seen_disabled_in_development_mode( +def test_get_idle_status_disabled_in_development_mode( monkeypatch: pytest.MonkeyPatch, ): monkeypatch.setattr(core, "LOCAL_DEVELOPMENT_MODE", True) - assert injection.get_last_seen("test") == "Disabled in development mode" + assert injection.get_idle_state("test") == models2_sessions.IdleState( + available=False, + terminate_after_minutes=90, + unavailable_reason="Unavailable in local development mode", + ) + + +def test_get_idle_status_exception(): + assert injection.get_idle_state("test") == models2_sessions.IdleState( + available=False, + unavailable_reason="Exception during fetching of idle state", + terminate_after_minutes=90, + ) + + +def test_get_idle_status_unknown_session(): + with responses.RequestsMock() as rsps: + rsps.get( + f'{config.prometheus.url}/api/v1/query?query=idletime_minutes{{session_id="test"}}', + status=status.HTTP_200_OK, + json={ + "status": "success", + "data": {"resultType": "vector", "result": []}, + }, + ) + + assert injection.get_idle_state("test") == models2_sessions.IdleState( + available=False, + unavailable_reason="Unknown session", + terminate_after_minutes=90, + ) + + +def test_get_idle_status(): + with responses.RequestsMock() as rsps: + rsps.get( + f'{config.prometheus.url}/api/v1/query?query=idletime_minutes{{session_id="test"}}', + status=status.HTTP_200_OK, + json={ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "value": [1731683497.386, "12"], + } + ], + }, + }, + ) + + assert injection.get_idle_state("test") == models2_sessions.IdleState( + available=True, + idle_for_minutes=12, + terminate_after_minutes=90, + ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef3b00112..aae986a07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@fontsource/roboto": "^5.1.0", "@ngneat/until-destroy": "^10.0.0", "@panzoom/panzoom": "^4.5.1", + "date-fns": "^4.1.0", "file-saver": "^2.0.5", "highlight.js": "^11.10.0", "http-status-codes": "^2.3.0", @@ -65,6 +66,7 @@ "eslint-plugin-storybook": "0.11.0", "eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-unused-imports": "^4.1.4", + "mockdate": "^3.0.5", "npm-check-updates": "^17.1.11", "postcss": "^8.4.49", "prettier": "^3.3.3", @@ -9459,6 +9461,16 @@ "node": ">= 14" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -9850,9 +9862,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.62", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.62.tgz", - "integrity": "sha512-t8c+zLmJHa9dJy96yBZRXGQYoiCEnHYgFwn1asvSPZSUdVxnB62A4RASd7k41ytG3ErFBA0TpHlKg9D9SQBmLg==", + "version": "1.5.63", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz", + "integrity": "sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA==", "dev": true, "license": "ISC" }, @@ -14108,6 +14120,13 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f207924b9..5a2a462ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@fontsource/roboto": "^5.1.0", "@ngneat/until-destroy": "^10.0.0", "@panzoom/panzoom": "^4.5.1", + "date-fns": "^4.1.0", "file-saver": "^2.0.5", "highlight.js": "^11.10.0", "http-status-codes": "^2.3.0", @@ -72,6 +73,7 @@ "eslint-plugin-storybook": "0.11.0", "eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-unused-imports": "^4.1.4", + "mockdate": "^3.0.5", "npm-check-updates": "^17.1.11", "postcss": "^8.4.49", "prettier": "^3.3.3", diff --git a/frontend/src/app/general/relative-time/relative-time.component.html b/frontend/src/app/general/relative-time/relative-time.component.html new file mode 100644 index 000000000..747bcb729 --- /dev/null +++ b/frontend/src/app/general/relative-time/relative-time.component.html @@ -0,0 +1,8 @@ + + + + {{ relativeTime }} + diff --git a/frontend/src/app/general/relative-time/relative-time.component.ts b/frontend/src/app/general/relative-time/relative-time.component.ts new file mode 100644 index 000000000..1d1292b53 --- /dev/null +++ b/frontend/src/app/general/relative-time/relative-time.component.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Component, Input } from '@angular/core'; +import { MatTooltip } from '@angular/material/tooltip'; +import { formatDistanceToNow, format } from 'date-fns'; +import { DateArg } from 'date-fns/types'; + +@Component({ + selector: 'app-relative-time', + standalone: true, + imports: [MatTooltip], + templateUrl: './relative-time.component.html', +}) +export class RelativeTimeComponent { + @Input() date?: DateArg; + @Input() suffix = true; + + get relativeTime(): string { + if (!this.date) return ''; + return formatDistanceToNow(this.date, { addSuffix: this.suffix }); + } + + get absoluteTime(): string { + if (!this.date) return ''; + return format(this.date, 'PPpp'); + } +} diff --git a/frontend/src/app/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index e09b20586..f929e2f76 100644 --- a/frontend/src/app/openapi/.openapi-generator/FILES +++ b/frontend/src/app/openapi/.openapi-generator/FILES @@ -82,6 +82,7 @@ model/http-connection-method-output.ts model/http-ports-input.ts model/http-ports-output.ts model/http-validation-error.ts +model/idle-state.ts model/logging-configuration-input.ts model/logging-configuration-output.ts model/memory-resources-input.ts diff --git a/frontend/src/app/openapi/model/idle-state.ts b/frontend/src/app/openapi/model/idle-state.ts new file mode 100644 index 000000000..de188bc54 --- /dev/null +++ b/frontend/src/app/openapi/model/idle-state.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface IdleState { + available: boolean; + idle_for_minutes: number | null; + terminate_after_minutes: number; + unavailable_reason: string | null; +} + diff --git a/frontend/src/app/openapi/model/models.ts b/frontend/src/app/openapi/model/models.ts index 45ab795ce..68b9f42e1 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -61,6 +61,7 @@ export * from './http-ports-input'; export * from './http-ports-output'; export * from './http-validation-error'; export * from './history-event'; +export * from './idle-state'; export * from './logging-configuration-input'; export * from './logging-configuration-output'; export * from './memory-resources-input'; diff --git a/frontend/src/app/openapi/model/session.ts b/frontend/src/app/openapi/model/session.ts index 4754e9e9c..cbf0c2769 100644 --- a/frontend/src/app/openapi/model/session.ts +++ b/frontend/src/app/openapi/model/session.ts @@ -14,6 +14,7 @@ import { BaseUser } from './base-user'; import { SessionType } from './session-type'; import { Message } from './message'; import { ToolVersionWithTool } from './tool-version-with-tool'; +import { IdleState } from './idle-state'; import { SessionPreparationState } from './session-preparation-state'; import { ToolSessionConnectionMethod } from './tool-session-connection-method'; import { SessionSharing } from './session-sharing'; @@ -28,7 +29,7 @@ export interface Session { preparation_state: SessionPreparationState; state: SessionState; warnings: Array; - last_seen: string; + idle_state: IdleState; connection_method_id: string; connection_method: ToolSessionConnectionMethod | null; shared_with: Array; diff --git a/frontend/src/app/sessions/session-overview/session-overview.component.html b/frontend/src/app/sessions/session-overview/session-overview.component.html index 1f9a04002..c7993c8e6 100644 --- a/frontend/src/app/sessions/session-overview/session-overview.component.html +++ b/frontend/src/app/sessions/session-overview/session-overview.component.html @@ -53,7 +53,21 @@ Last seen - {{ element.last_seen }} + + @if (element.idle_state.available) { + @if (element.idle_state.idle_for_minutes! === -1) { + Never connected + } @else { + + } + } @else { + {{ element.idle_state.unavailable_reason }} + } + diff --git a/frontend/src/app/sessions/session-overview/session-overview.component.ts b/frontend/src/app/sessions/session-overview/session-overview.component.ts index 31dca7ab6..f94ce1a89 100644 --- a/frontend/src/app/sessions/session-overview/session-overview.component.ts +++ b/frontend/src/app/sessions/session-overview/session-overview.component.ts @@ -25,8 +25,10 @@ import { MatRowDef, MatRow, } from '@angular/material/table'; +import { subMinutes } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Session, SessionsService } from 'src/app/openapi'; +import { RelativeTimeComponent } from '../../general/relative-time/relative-time.component'; import { DeleteSessionDialogComponent } from '../delete-session-dialog/delete-session-dialog.component'; @Component({ @@ -50,6 +52,7 @@ import { DeleteSessionDialogComponent } from '../delete-session-dialog/delete-se MatButton, DatePipe, NgxSkeletonLoaderModule, + RelativeTimeComponent, ], }) export class SessionOverviewComponent implements OnInit { @@ -129,4 +132,7 @@ export class SessionOverviewComponent implements OnInit { return false; } + + protected readonly subMinutes = subMinutes; + protected readonly Date = Date; } diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html index b88ab8c21..52b81e4ff 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html @@ -46,19 +46,30 @@

No active sessions

} @else if ((sessions | async)?.length !== 0) { @for (session of sessions | async; track session.id) {
-
+
- - @if (isPersistentSession(session)) { -

Persistent workspace session

- } @else if (isReadonlySession(session)) { -

Read-Only session

- } -
+
+

+ {{ session.version.tool.name }} ({{ session.version.name }}) + + via + {{ session.connection_method?.name }} + +

+
+ @if (isPersistentSession(session)) { + Persistent workspace session created + + } @else if (isReadonlySession(session)) { + Read-Only session created + + } +
+
+
@if ((feedbackService.feedbackConfig$ | async)?.on_session_card) {
-
+

- {{ - sessionService.beautifyState( - session.preparation_state, - session.state - ).icon - }} + {{ + sessionService.beautifyState( + session.preparation_state, + session.state + ).icon + }} + {{ sessionService.beautifyState( session.preparation_state, @@ -105,39 +118,55 @@

Read-Only session

).text }} -

- The session was created - {{ beautifyService.beatifyDate(session.created_at) }} -

-
- Tool: {{ session.version.tool.name }} ({{ session.version.name }}) -
- Connection Method: {{ session.connection_method?.name }} -
+ @let remainingMinutes = minutesUntilSessionTermination(session); + @if (remainingMinutes !== null && remainingMinutes < 30) { +
+ warning +
+ This session will automatically terminate + + if you do not interact with it. +
+
+ } + @if (session.shared_with.length > 0 && !isSessionShared(session)) { -
- Shared with: - @for ( - sharedSession of session.shared_with; - track sharedSession.user.id - ) { - - {{ sharedSession.user.name }} - - @if (!$last) { - + +
+ share +
+ You're sharing this session with + @for ( + sharedSession of session.shared_with; + track sharedSession.user.id + ) { + + {{ sharedSession.user.name }} + + @if (!$last) { + + + } } - } +
} + @if (isSessionShared(session)) { -
- share +
+ share diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts index 9c6024371..d93382900 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts @@ -6,20 +6,19 @@ import { NgClass, AsyncPipe } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatAnchor, MatButton, MatIconButton } from '@angular/material/button'; -import { MatCardContent } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; -import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; +import { addMinutes, differenceInMinutes } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { BehaviorSubject, map } from 'rxjs'; import { Session } from 'src/app/openapi'; -import { BeautifyService } from 'src/app/services/beatify/beautify.service'; import { OwnUserWrapperService } from 'src/app/services/user/user.service'; import { ConnectionDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component'; import { SessionSharingDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component'; +import { RelativeTimeComponent } from '../../../general/relative-time/relative-time.component'; import { DeleteSessionDialogComponent } from '../../delete-session-dialog/delete-session-dialog.component'; import { FeedbackWrapperService } from '../../feedback/feedback.service'; import { @@ -41,14 +40,13 @@ import { FileBrowserDialogComponent } from './file-browser-dialog/file-browser-d RouterLink, MatIcon, NgClass, - MatCardContent, - MatProgressBar, MatButton, AsyncPipe, MatIconButton, MatTooltip, MatCheckboxModule, FormsModule, + RelativeTimeComponent, ], }) export class ActiveSessionsComponent implements OnInit { @@ -57,7 +55,6 @@ export class ActiveSessionsComponent implements OnInit { constructor( public sessionService: SessionService, - public beautifyService: BeautifyService, public userSessionService: UserSessionService, public feedbackService: FeedbackWrapperService, private userWrapperService: OwnUserWrapperService, @@ -129,6 +126,29 @@ export class ActiveSessionsComponent implements OnInit { isSessionShared(session: Session): boolean { return session.owner.id != this.userWrapperService.user?.id; } + + minutesUntilSessionTermination(session: Session): number | null { + if (session.idle_state.available) { + if (session.idle_state.idle_for_minutes === -1) { + // session was never connected to, use creation time + return ( + session.idle_state.terminate_after_minutes - + differenceInMinutes(session.created_at, Date.now()) + ); + } else { + return ( + session.idle_state.terminate_after_minutes - + session.idle_state.idle_for_minutes! + ); + } + } else { + return null; + } + } + + protected readonly Date = Date; + protected readonly addMinutes = addMinutes; + protected readonly differenceInMinutes = differenceInMinutes; } type SessionWithSelection = Session & { selected: boolean }; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts index 15766968b..d53b54bb3 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts @@ -8,6 +8,7 @@ import { componentWrapperDecorator, moduleMetadata, } from '@storybook/angular'; +import MockDate from 'mockdate'; import { Observable, of } from 'rxjs'; import { Session, @@ -54,6 +55,9 @@ const meta: Meta = { (story) => `
${story}
`, ), ], + beforeEach: () => { + MockDate.set(new Date('2024-05-01')); + }, }; export default meta; @@ -167,6 +171,32 @@ export const SessionRunningState: Story = { ], }; +export const SessionTerminatingSoon: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: UserSessionService, + useFactory: () => + new MockUserSessionService({ + ...createPersistentSessionWithState( + SessionPreparationState.Completed, + SessionState.Running, + ), + idle_state: { + available: true, + terminate_after_minutes: 90, + idle_for_minutes: 80, + unavailable_reason: null, + }, + }), + }, + ], + }), + ], +}; + export const SessionTerminatedState: Story = { args: {}, decorators: [ @@ -341,6 +371,56 @@ export const SessionSharedWithUser: Story = { }), ], }; +export const SessionSharedWithUserTerminatingSoon: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: UserSessionService, + useFactory: () => + new MockUserSessionService({ + ...mockPersistentSession, + connection_method: { + ...mockHttpConnectionMethod, + sharing: { enabled: true }, + }, + idle_state: { + available: true, + terminate_after_minutes: 90, + idle_for_minutes: 80, + unavailable_reason: null, + }, + shared_with: [ + { + user: { + id: 1, + name: 'user_1', + role: 'administrator', + email: null, + idp_identifier: 'user_1', + beta_tester: false, + }, + created_at: '2024-04-29T15:00:00Z', + }, + { + user: { + id: 2, + name: 'user_2', + role: 'user', + email: null, + idp_identifier: 'user_2', + beta_tester: false, + }, + created_at: '2024-04-29T15:00:00Z', + }, + ], + }), + }, + ], + }), + ], +}; export const SharedSession: Story = { args: {}, diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html index 85011069e..cd3902c56 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.html @@ -17,38 +17,28 @@ } @for (session of sortedResolvedHistory; track $index) { }
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts index 1514b6d79..c43b9f1b5 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts @@ -17,11 +17,17 @@ import { ConnectionMethod, ToolWrapperService, } from 'src/app/settings/core/tools-settings/tool.service'; +import { RelativeTimeComponent } from '../../../../general/relative-time/relative-time.component'; @Component({ selector: 'app-create-session-history', standalone: true, - imports: [CommonModule, MatIconModule, MatProgressSpinnerModule], + imports: [ + CommonModule, + MatIconModule, + MatProgressSpinnerModule, + RelativeTimeComponent, + ], templateUrl: './create-session-history.component.html', styles: ``, }) diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.stories.ts index 138687be2..57adbe4d0 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.stories.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { Meta, StoryObj } from '@storybook/angular'; +import MockDate from 'mockdate'; import { mockHttpConnectionMethod, mockCapellaTool, @@ -22,6 +23,9 @@ const meta: Meta = { viewports: [], }, }, + beforeEach: () => { + MockDate.set(new Date('2024-04-01')); + }, }; export default meta; diff --git a/frontend/src/storybook/session.ts b/frontend/src/storybook/session.ts index 7fc274e72..78f9d3f7a 100644 --- a/frontend/src/storybook/session.ts +++ b/frontend/src/storybook/session.ts @@ -27,7 +27,12 @@ export function createPersistentSessionWithState( return { id: 'vfurvsrldxfwwsqdiqvnufonh', created_at: '2024-04-29T15:00:00Z', - last_seen: '2024-04-29T15:30:00Z', + idle_state: { + available: true, + terminate_after_minutes: 90, + idle_for_minutes: 30, + unavailable_reason: null, + }, type: 'persistent', version: mockToolVersionWithTool, preparation_state: preparationState, diff --git a/helm/config/backend.yaml b/helm/config/backend.yaml index 67002da3e..9b5559f5b 100644 --- a/helm/config/backend.yaml +++ b/helm/config/backend.yaml @@ -74,6 +74,9 @@ valkey: pipelines: timeout: {{ .Values.pipelines.timeout }} +sessions: + timeout: {{ .Values.sessions.timeout }} + initial: admin: "{{ .Values.database.backend.initialAdmin }}"