diff --git a/.codacy.yaml b/.codacy.yaml index 73b4b8b5e..bb39e1f6e 100644 --- a/.codacy.yaml +++ b/.codacy.yaml @@ -2,4 +2,4 @@ exclude_paths: - "**.spec.ts" - "**/test/**" - - "**/test-setup.ts" \ No newline at end of file + - "**/test-setup.ts" diff --git a/client/src/app/components/environments/environment-list/environment-list-view.component.html b/client/src/app/components/environments/environment-list/environment-list-view.component.html index bdf8aa63c..f9c8fbbb3 100644 --- a/client/src/app/components/environments/environment-list/environment-list-view.component.html +++ b/client/src/app/components/environments/environment-list/environment-list-view.component.html @@ -26,6 +26,10 @@ } + @if (environment.latestStatus; as status) { + + } +
@if (environment.locked) { @@ -61,9 +65,20 @@ - @if (environment.latestDeployment; as deployment) { - - } +
+ @if (environment.latestDeployment; as deployment) { + + } + + @if (environment.latestStatus; as status) { + + } +
diff --git a/client/src/app/components/environments/environment-list/environment-list-view.component.ts b/client/src/app/components/environments/environment-list/environment-list-view.component.ts index 3f2a4b533..c53153503 100644 --- a/client/src/app/components/environments/environment-list/environment-list-view.component.ts +++ b/client/src/app/components/environments/environment-list/environment-list-view.component.ts @@ -25,6 +25,8 @@ import { DeploymentStateTagComponent } from '../deployment-state-tag/deployment- import { LockTagComponent } from '../lock-tag/lock-tag.component'; import { LockTimeComponent } from '../lock-time/lock-time.component'; import { KeycloakService } from '@app/core/services/keycloak/keycloak.service'; +import { EnvironmentStatusInfoComponent } from '../environment-status-info/environment-status-info.component'; +import { EnvironmentStatusTagComponent } from '../environment-status-tag/environment-status-tag.component'; @Component({ selector: 'app-environment-list-view', @@ -37,7 +39,9 @@ import { KeycloakService } from '@app/core/services/keycloak/keycloak.service'; IconsModule, ButtonModule, DeploymentStateTagComponent, + EnvironmentStatusTagComponent, EnvironmentDeploymentInfoComponent, + EnvironmentStatusInfoComponent, LockTimeComponent, ConfirmDialogModule, CommonModule, diff --git a/client/src/app/components/environments/environment-status-info/environment-status-info.component.html b/client/src/app/components/environments/environment-status-info/environment-status-info.component.html new file mode 100644 index 000000000..3d5036da8 --- /dev/null +++ b/client/src/app/components/environments/environment-status-info/environment-status-info.component.html @@ -0,0 +1,26 @@ +
+ Latest status check + +
+ Last Checked: + + {{ status().checkedAt | timeAgo }} + +
+
+ Status Code: + + {{ status().httpStatusCode || 'N/A' }} + +
+ + @if (status().checkType === 'ARTEMIS_INFO') { + Artemis Build + @for (item of artemisBuildInfo(); track item.label) { +
+ {{ item.label }}: + {{ item.value || '-/-' }} +
+ } + } +
diff --git a/client/src/app/components/environments/environment-status-info/environment-status-info.component.ts b/client/src/app/components/environments/environment-status-info/environment-status-info.component.ts new file mode 100644 index 000000000..bd9f4d1c0 --- /dev/null +++ b/client/src/app/components/environments/environment-status-info/environment-status-info.component.ts @@ -0,0 +1,45 @@ +import { Component, computed, inject, input } from '@angular/core'; +import { EnvironmentStatusDto } from '@app/core/modules/openapi'; +import { DateService } from '@app/core/services/date.service'; +import { TimeAgoPipe } from '@app/pipes/time-ago.pipe'; + +@Component({ + selector: 'app-environment-status-info', + imports: [TimeAgoPipe], + templateUrl: './environment-status-info.component.html', +}) +export class EnvironmentStatusInfoComponent { + status = input.required(); + dateService = inject(DateService); + + artemisBuildInfo = computed<{ label: string; value?: string }[]>(() => { + const status = this.status(); + const metadata = status.metadata as + | { + name?: string; + group?: string; + version?: string; + buildTime?: number; + } + | undefined; + + return [ + { + label: 'Name', + value: metadata?.name, + }, + { + label: 'Group', + value: metadata?.group, + }, + { + label: 'Version', + value: metadata?.version, + }, + { + label: 'Build Time', + value: metadata?.buildTime ? this.dateService.formatDate(metadata.buildTime * 1000, 'yyyy-MM-dd HH:mm:ss') || '-/-' : undefined, + }, + ]; + }); +} diff --git a/client/src/app/components/environments/environment-status-tag/environment-status-tag.component.html b/client/src/app/components/environments/environment-status-tag/environment-status-tag.component.html new file mode 100644 index 000000000..e0f405af0 --- /dev/null +++ b/client/src/app/components/environments/environment-status-tag/environment-status-tag.component.html @@ -0,0 +1,16 @@ +@if (status(); as status) { + + + @if (status.success) { + + + Status check successful + + } @else { + + + + Status check failed + + } +} diff --git a/client/src/app/components/environments/environment-status-tag/environment-status-tag.component.ts b/client/src/app/components/environments/environment-status-tag/environment-status-tag.component.ts new file mode 100644 index 000000000..8a7fd6efa --- /dev/null +++ b/client/src/app/components/environments/environment-status-tag/environment-status-tag.component.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; +import { EnvironmentStatusDto } from '@app/core/modules/openapi'; +import { IconsModule } from 'icons.module'; +import { TagModule } from 'primeng/tag'; + +@Component({ + selector: 'app-environment-status-tag', + imports: [TagModule, IconsModule], + templateUrl: './environment-status-tag.component.html', +}) +export class EnvironmentStatusTagComponent { + status = input.required(); +} diff --git a/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.html b/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.html index 8968905db..47250d965 100644 --- a/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.html +++ b/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.html @@ -23,6 +23,23 @@
+
+ + When enabled, the status check will run periodically to check the health of the environment. + +
+ + @if (environmentForm.get('statusCheckType')?.value !== null) { +
+ + + This can be the URL of the Artemis management info endpoint (if set as the status check type) or any other URL that returns a 200 status code when the environment is + healthy. + + +
+ } +
diff --git a/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.ts b/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.ts index b5b213315..12caed5be 100644 --- a/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.ts +++ b/client/src/app/components/forms/environment-edit-form/environment-edit-form.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, effect, inject, input, OnInit } from '@angular/core'; +import { Component, computed, inject, input, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; import { InputSwitchModule } from 'primeng/inputswitch'; @@ -14,10 +14,12 @@ import { } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; import { MessageService } from 'primeng/api'; import { Checkbox } from 'primeng/checkbox'; +import { SelectModule } from 'primeng/select'; +import { toObservable } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-environment-edit-form', - imports: [AutoCompleteModule, ReactiveFormsModule, InputTextModule, InputSwitchModule, ButtonModule, Checkbox], + imports: [AutoCompleteModule, ReactiveFormsModule, InputTextModule, InputSwitchModule, ButtonModule, Checkbox, SelectModule], templateUrl: './environment-edit-form.component.html', }) export class EnvironmentEditFormComponent implements OnInit { @@ -27,15 +29,26 @@ export class EnvironmentEditFormComponent implements OnInit { private route = inject(ActivatedRoute); constructor(private messageService: MessageService) { - // This effect is needed, because the form is initialized before the data is fetched. + // This subscription is needed, because the form is initialized before the data is fetched. // As soon as the data is fetched, the form is updated with the fetched data. - effect(() => { - if (this.environment()) { - this.environmentForm.patchValue(this.environment() || {}); + // + // We used to call effect() here, but selecting an option within NgPrime Select + // for some reason triggers the effect to run and reset the form to the initial state. + toObservable(this.environment).subscribe(environment => { + if (!environment) { + return; } + + this.environmentForm.patchValue(environment); }); } + statusCheckTypes = [ + { label: 'Disabled', value: null }, + { label: 'HTTP Status', value: 'HTTP_STATUS' }, + { label: 'Artemis Info', value: 'ARTEMIS_INFO' }, + ]; + environmentId = input(0); // This is the environment id environmentForm!: FormGroup; @@ -64,12 +77,16 @@ export class EnvironmentEditFormComponent implements OnInit { environment = computed(() => this.environmentQuery.data()); ngOnInit(): void { + const environment = this.environment(); + this.environmentForm = this.formBuilder.group({ - name: [this.environment()?.name || '', Validators.required], - installedApps: [this.environment()?.installedApps || []], - description: [this.environment()?.description || ''], - serverUrl: [this.environment()?.serverUrl || ''], - enabled: [this.environment()?.enabled || false], + name: [environment?.name || '', Validators.required], + installedApps: [environment?.installedApps || []], + description: [environment?.description || ''], + serverUrl: [environment?.serverUrl || ''], + enabled: [environment?.enabled || false], + statusCheckType: [environment?.statusCheckType || null], + statusUrl: [environment?.statusUrl || ''], }); } @@ -79,7 +96,10 @@ export class EnvironmentEditFormComponent implements OnInit { submitForm = () => { if (this.environmentForm && this.environmentForm.valid) { - this.mutateEnvironment.mutate({ path: { id: this.environmentId() }, body: this.environmentForm.value }); + this.mutateEnvironment.mutate({ + path: { id: this.environmentId() }, + body: this.environmentForm.value, + }); } }; } diff --git a/client/src/app/core/modules/openapi/schemas.gen.ts b/client/src/app/core/modules/openapi/schemas.gen.ts index 787a94049..216b16e8c 100644 --- a/client/src/app/core/modules/openapi/schemas.gen.ts +++ b/client/src/app/core/modules/openapi/schemas.gen.ts @@ -122,9 +122,19 @@ export const EnvironmentDtoSchema = { serverUrl: { type: 'string', }, + statusCheckType: { + type: 'string', + enum: ['HTTP_STATUS', 'ARTEMIS_INFO'], + }, + statusUrl: { + type: 'string', + }, latestDeployment: { $ref: '#/components/schemas/EnvironmentDeployment', }, + latestStatus: { + $ref: '#/components/schemas/EnvironmentStatusDto', + }, lockedBy: { type: 'string', }, @@ -136,6 +146,36 @@ export const EnvironmentDtoSchema = { required: ['id', 'name'], } as const; +export const EnvironmentStatusDtoSchema = { + type: 'object', + properties: { + id: { + type: 'integer', + format: 'int64', + }, + success: { + type: 'boolean', + }, + httpStatusCode: { + type: 'integer', + format: 'int32', + }, + checkedAt: { + type: 'string', + format: 'date-time', + }, + checkType: { + type: 'string', + enum: ['HTTP_STATUS', 'ARTEMIS_INFO'], + }, + metadata: { + type: 'object', + additionalProperties: {}, + }, + }, + required: ['checkType', 'checkedAt', 'httpStatusCode', 'id', 'success'], +} as const; + export const RepositoryInfoDtoSchema = { type: 'object', properties: { diff --git a/client/src/app/core/modules/openapi/types.gen.ts b/client/src/app/core/modules/openapi/types.gen.ts index cb1b4083e..e5504b2a4 100644 --- a/client/src/app/core/modules/openapi/types.gen.ts +++ b/client/src/app/core/modules/openapi/types.gen.ts @@ -37,11 +37,23 @@ export type EnvironmentDto = { installedApps?: Array; description?: string; serverUrl?: string; + statusCheckType?: 'HTTP_STATUS' | 'ARTEMIS_INFO'; + statusUrl?: string; latestDeployment?: EnvironmentDeployment; + latestStatus?: EnvironmentStatusDto; lockedBy?: string; lockedAt?: string; }; +export type EnvironmentStatusDto = { + id: number; + success: boolean; + httpStatusCode: number; + checkedAt: string; + checkType: 'HTTP_STATUS' | 'ARTEMIS_INFO'; + metadata?: {}; +}; + export type RepositoryInfoDto = { id: number; name: string; diff --git a/client/src/app/core/services/date.service.ts b/client/src/app/core/services/date.service.ts index e3f6a9162..f235e0727 100644 --- a/client/src/app/core/services/date.service.ts +++ b/client/src/app/core/services/date.service.ts @@ -7,7 +7,7 @@ import { inject, Injectable } from '@angular/core'; export class DateService { private datePipe = inject(DatePipe); - formatDate = (date?: string, formatString: string = 'd. MMMM y'): string | null => { + formatDate = (date?: Date | string | number, formatString: string = 'd. MMMM y'): string | null => { return date ? this.datePipe.transform(date, formatString) : null; }; } diff --git a/client/src/main.ts b/client/src/main.ts index e1fb91d8b..0d2065767 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -8,7 +8,6 @@ Sentry.init({ enabled: environment.sentry.enabled, // The DSN (Data Source Name) tells the SDK where to send the events to. dsn: environment.sentry.dsn, - debug: true, // The browser tracing integration captures performance data // like throughput and latency integrations: [Sentry.browserTracingIntegration()], diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index dd554b554..57a7ebaa5 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -808,8 +808,17 @@ components: type: string serverUrl: type: string + statusCheckType: + type: string + enum: + - HTTP_STATUS + - ARTEMIS_INFO + statusUrl: + type: string latestDeployment: $ref: "#/components/schemas/EnvironmentDeployment" + latestStatus: + $ref: "#/components/schemas/EnvironmentStatusDto" lockedBy: type: string lockedAt: @@ -818,6 +827,34 @@ components: required: - id - name + EnvironmentStatusDto: + type: object + properties: + id: + type: integer + format: int64 + success: + type: boolean + httpStatusCode: + type: integer + format: int32 + checkedAt: + type: string + format: date-time + checkType: + type: string + enum: + - HTTP_STATUS + - ARTEMIS_INFO + metadata: + type: object + additionalProperties: {} + required: + - checkType + - checkedAt + - httpStatusCode + - id + - success RepositoryInfoDto: type: object properties: diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/Environment.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/Environment.java index c59da31e6..ad0399147 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/Environment.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/Environment.java @@ -1,11 +1,16 @@ package de.tum.cit.aet.helios.environment; import de.tum.cit.aet.helios.deployment.Deployment; +import de.tum.cit.aet.helios.environment.status.EnvironmentStatus; +import de.tum.cit.aet.helios.environment.status.StatusCheckType; import de.tum.cit.aet.helios.filters.RepositoryFilterEntity; +import jakarta.persistence.CascadeType; import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -85,6 +90,21 @@ public class Environment extends RepositoryFilterEntity { @OneToMany(mappedBy = "environment", fetch = FetchType.LAZY) private List lockHistory; + @Column(name = "status_url", nullable = true) + private String statusUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status_check_type", length = 20) + private StatusCheckType statusCheckType; + + @OneToMany(mappedBy = "environment", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("checkTimestamp DESC") + private List statusHistory; + + public Optional getLatestStatus() { + return statusHistory.stream().findFirst(); + } + public Optional getLatestDeployment() { return this.deployments.reversed().stream().findFirst(); } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentDto.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentDto.java index 8b4b4289d..8e477e6bc 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentDto.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentDto.java @@ -1,14 +1,16 @@ package de.tum.cit.aet.helios.environment; -import com.fasterxml.jackson.annotation.JsonInclude; import de.tum.cit.aet.helios.deployment.Deployment; +import de.tum.cit.aet.helios.environment.status.EnvironmentStatus; +import de.tum.cit.aet.helios.environment.status.StatusCheckType; import de.tum.cit.aet.helios.gitrepo.RepositoryInfoDto; +import java.time.Instant; import java.time.OffsetDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import org.springframework.lang.NonNull; -@JsonInclude(JsonInclude.Include.NON_EMPTY) public record EnvironmentDto( RepositoryInfoDto repository, @NonNull Long id, @@ -22,10 +24,31 @@ public record EnvironmentDto( List installedApps, String description, String serverUrl, + StatusCheckType statusCheckType, + String statusUrl, EnvironmentDeployment latestDeployment, + EnvironmentStatusDto latestStatus, String lockedBy, OffsetDateTime lockedAt) { + public static record EnvironmentStatusDto( + @NonNull Long id, + @NonNull Boolean success, + @NonNull Integer httpStatusCode, + @NonNull Instant checkedAt, + @NonNull StatusCheckType checkType, + Map metadata) { + public static EnvironmentStatusDto fromEnvironmentStatus(EnvironmentStatus environment) { + return new EnvironmentStatusDto( + environment.getId(), + environment.isSuccess(), + environment.getHttpStatusCode(), + environment.getCheckTimestamp(), + environment.getCheckType(), + environment.getMetadata()); + } + } + public static record EnvironmentDeployment( @NonNull Long id, @NonNull String url, @@ -52,7 +75,8 @@ public static EnvironmentDeployment fromDeployment(Deployment deployment) { } public static EnvironmentDto fromEnvironment( - Environment environment, Optional latestDeployment) { + Environment environment, Optional latestDeployment, + Optional latestStatus) { return new EnvironmentDto( RepositoryInfoDto.fromRepository(environment.getRepository()), environment.getId(), @@ -66,12 +90,15 @@ public static EnvironmentDto fromEnvironment( environment.getInstalledApps(), environment.getDescription(), environment.getServerUrl(), + environment.getStatusCheckType(), + environment.getStatusUrl(), latestDeployment.map(EnvironmentDeployment::fromDeployment).orElse(null), + latestStatus.map(EnvironmentStatusDto::fromEnvironmentStatus).orElse(null), environment.getLockedBy(), environment.getLockedAt()); } public static EnvironmentDto fromEnvironment(Environment environment) { - return EnvironmentDto.fromEnvironment(environment, Optional.empty()); + return EnvironmentDto.fromEnvironment(environment, Optional.empty(), Optional.empty()); } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentLockHistoryDto.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentLockHistoryDto.java index 5535f1859..8d82c68e7 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentLockHistoryDto.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentLockHistoryDto.java @@ -23,6 +23,7 @@ public static EnvironmentLockHistoryDto fromEnvironmentLockHistory( environmentLockHistory.getUnlockedAt(), EnvironmentDto.fromEnvironment( environment, - environment.getLatestDeployment())); + environment.getLatestDeployment(), + environment.getLatestStatus())); } } \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentRepository.java index 21d3c5f6c..324631bb6 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentRepository.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentRepository.java @@ -16,4 +16,6 @@ public interface EnvironmentRepository extends JpaRepository List findByRepositoryRepositoryIdOrderByCreatedAtDesc(Long repositoryId); List findByEnabledTrueOrderByNameAsc(); + + List findByStatusCheckTypeIsNotNull(); } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentService.java index ed2d00aaf..b01168810 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/EnvironmentService.java @@ -22,11 +22,10 @@ public class EnvironmentService { private final EnvironmentLockHistoryRepository lockHistoryRepository; private final HeliosDeploymentRepository heliosDeploymentRepository; - public EnvironmentService(EnvironmentRepository environmentRepository, - EnvironmentLockHistoryRepository lockHistoryRepository, - HeliosDeploymentRepository heliosDeploymentRepository, - AuthService authService) { + EnvironmentLockHistoryRepository lockHistoryRepository, + HeliosDeploymentRepository heliosDeploymentRepository, + AuthService authService) { this.environmentRepository = environmentRepository; this.lockHistoryRepository = lockHistoryRepository; this.heliosDeploymentRepository = heliosDeploymentRepository; @@ -42,7 +41,9 @@ public List getAllEnvironments() { .map( environment -> { return EnvironmentDto.fromEnvironment( - environment, environment.getLatestDeployment()); + environment, + environment.getLatestDeployment(), + environment.getLatestStatus()); }) .collect(Collectors.toList()); } @@ -52,7 +53,9 @@ public List getAllEnabledEnvironments() { .map( environment -> { return EnvironmentDto.fromEnvironment( - environment, environment.getLatestDeployment()); + environment, + environment.getLatestDeployment(), + environment.getLatestStatus()); }) .collect(Collectors.toList()); } @@ -67,8 +70,10 @@ public List getEnvironmentsByRepositoryId(Long repositoryId) { /** * Locks the environment with the specified ID. * - *

This method attempts to lock the environment by setting its locked status to true. If the - * environment is already locked, it returns an empty Optional. If the environment is successfully + *

This method attempts to lock the environment by setting its locked status to + * true. If the + * environment is already locked, it returns an empty Optional. If the + * environment is successfully * locked, it returns an Optional containing the locked environment. * *

This method is transactional and handles optimistic locking failures. @@ -82,10 +87,9 @@ public List getEnvironmentsByRepositoryId(Long repositoryId) { public Optional lockEnvironment(Long id) { final String currentUserName = authService.getPreferredUsername(); - Environment environment = - environmentRepository - .findById(id) - .orElseThrow(() -> new EntityNotFoundException("Environment not found with ID: " + id)); + Environment environment = environmentRepository + .findById(id) + .orElseThrow(() -> new EntityNotFoundException("Environment not found with ID: " + id)); if (!environment.isEnabled()) { throw new IllegalStateException("Environment is disabled"); @@ -132,10 +136,9 @@ public Optional lockEnvironment(Long id) { public EnvironmentDto unlockEnvironment(Long id) { final String currentUserName = authService.getPreferredUsername(); - Environment environment = - environmentRepository - .findById(id) - .orElseThrow(() -> new EntityNotFoundException("Environment not found with ID: " + id)); + Environment environment = environmentRepository + .findById(id) + .orElseThrow(() -> new EntityNotFoundException("Environment not found with ID: " + id)); if (!environment.isLocked()) { throw new IllegalStateException("Environment is not locked"); @@ -172,13 +175,16 @@ public EnvironmentDto unlockEnvironment(Long id) { /** * Updates the environment with the specified ID. * - *

This method updates the environment with the specified ID using the provided EnvironmentDto. + *

This method updates the environment with the specified ID using the provided + * EnvironmentDto. * * @param id the ID of the environment to update - * @param environmentDto the EnvironmentDto containing the updated environment information + * @param environmentDto the EnvironmentDto containing the updated environment + * information * @return an Optional containing the updated environment if successful, - * or an empty Optional if no environment is found with the specified ID - * @throws EnvironmentException if the environment is locked and cannot be disabled + * or an empty Optional if no environment is found with the specified ID + * @throws EnvironmentException if the environment is locked and cannot be + * disabled */ public Optional updateEnvironment(Long id, EnvironmentDto environmentDto) throws EnvironmentException { @@ -209,6 +215,13 @@ public Optional updateEnvironment(Long id, EnvironmentDto enviro if (environmentDto.serverUrl() != null) { environment.setServerUrl(environmentDto.serverUrl()); } + if (environmentDto.statusCheckType() != null) { + environment.setStatusCheckType(environmentDto.statusCheckType()); + environment.setStatusUrl(environmentDto.statusUrl()); + } else { + environment.setStatusCheckType(null); + environment.setStatusUrl(null); + } environmentRepository.save(environment); return EnvironmentDto.fromEnvironment(environment); @@ -217,8 +230,8 @@ public Optional updateEnvironment(Long id, EnvironmentDto enviro public EnvironmentLockHistoryDto getUsersCurrentLock() { final String currentUserName = authService.getPreferredUsername(); - Optional lockHistory = - lockHistoryRepository.findLatestLockForEnabledEnvironment(currentUserName); + Optional lockHistory = lockHistoryRepository + .findLatestLockForEnabledEnvironment(currentUserName); return lockHistory.map(EnvironmentLockHistoryDto::fromEnvironmentLockHistory).orElse(null); } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/ArtemisInfoCheck.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/ArtemisInfoCheck.java new file mode 100644 index 000000000..559376b5c --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/ArtemisInfoCheck.java @@ -0,0 +1,60 @@ +package de.tum.cit.aet.helios.environment.status; + +import com.fasterxml.jackson.annotation.JsonProperty; +import de.tum.cit.aet.helios.environment.Environment; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +public class ArtemisInfoCheck implements StatusCheckStrategy { + private final RestTemplate restTemplate; + + @Override + public StatusCheckResult check(Environment environment) { + final String url = environment.getStatusUrl(); + final Map metadata = new HashMap<>(); + + try { + ResponseEntity response = restTemplate.getForEntity( + url, + ArtemisInfo.class); + + ArtemisInfo artemisInfo = response.getBody(); + + if (artemisInfo != null) { + ArtemisInfo.BuildInfo build = artemisInfo.build(); + + metadata.put("artifact", build.artifact()); + metadata.put("name", build.name()); + metadata.put("version", build.version()); + metadata.put("group", build.group()); + metadata.put("buildTime", build.time()); + } + + return new StatusCheckResult( + response.getStatusCode().is2xxSuccessful(), + response.getStatusCode().value(), + metadata); + + } catch (Exception e) { + return new StatusCheckResult(false, 503, Map.of()); + } + } + + public record ArtemisInfo(@JsonProperty("build") BuildInfo build) { + public record BuildInfo( + String artifact, + String name, + @JsonProperty("time") Instant time, + String version, + String group) { + } + } + +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatus.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatus.java new file mode 100644 index 000000000..41d007a1a --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatus.java @@ -0,0 +1,50 @@ +package de.tum.cit.aet.helios.environment.status; + +import de.tum.cit.aet.helios.environment.Environment; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Getter +@Setter +public class EnvironmentStatus { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "environment_id") + private Environment environment; + + @Column(nullable = false) + private boolean success; + + @Column(name = "http_status_code", nullable = false) + private Integer httpStatusCode; + + @Enumerated(EnumType.STRING) + @Column(name = "check_type", length = 20, nullable = false) + private StatusCheckType checkType; + + @Column(name = "check_timestamp", nullable = false) + private Instant checkTimestamp; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map metadata = new HashMap<>(); +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatusConfig.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatusConfig.java new file mode 100644 index 000000000..3dccc6a80 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatusConfig.java @@ -0,0 +1,75 @@ +package de.tum.cit.aet.helios.environment.status; + +import java.time.Duration; +import java.util.Map; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.web.client.RestTemplate; + +@Configuration +@EnableAsync +@EnableScheduling +@EnableConfigurationProperties(EnvironmentStatusConfig.StatusCheckTaskExecutorConfig.class) +public class EnvironmentStatusConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .build(); + } + + @Bean + public Map checkStrategies( + HttpStatusCheck httpStatusCheck, + ArtemisInfoCheck artemisInfoCheck) { + return Map.of( + StatusCheckType.HTTP_STATUS, httpStatusCheck, + StatusCheckType.ARTEMIS_INFO, artemisInfoCheck); + } + + /** + * Creates a TaskExecutor bean named "statusCheckTaskExecutor". + * This executor is used to manage and execute status check tasks concurrently. + * The core and maximum pool sizes are configured based on the available processors + * to optimize performance but can be overridden in the application configuration. + * + * @param config the configuration for the StatusCheckTaskExecutor + * @return a configured ThreadPoolTaskExecutor instance + */ + @Bean("statusCheckTaskExecutor") + public TaskExecutor taskExecutor( + StatusCheckTaskExecutorConfig config) { + int availableProcessors = Runtime.getRuntime().availableProcessors(); + + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(config.getCorePoolSize(availableProcessors)); + executor.setMaxPoolSize(config.getMaxPoolSize(availableProcessors)); + executor.setThreadNamePrefix("status-check-"); + return executor; + } + + @ConfigurationProperties(prefix = "status-check.executor") + @Data + public static class StatusCheckTaskExecutorConfig { + private Integer corePoolSize; + private Integer maxPoolSize; + + // Dynamic defaults based on CPU cores + public int getCorePoolSize(int availableProcessors) { + return corePoolSize != null ? corePoolSize : availableProcessors * 2; + } + + public int getMaxPoolSize(int availableProcessors) { + return maxPoolSize != null ? maxPoolSize : availableProcessors * 4; + } + } +} \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatusRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatusRepository.java new file mode 100644 index 000000000..88518f10a --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/EnvironmentStatusRepository.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.helios.environment.status; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface EnvironmentStatusRepository extends JpaRepository { + @Modifying + @Query("DELETE FROM EnvironmentStatus es " + + "WHERE es.environment.id = :environmentId AND es.id NOT IN (" + + "SELECT es2.id FROM EnvironmentStatus es2 WHERE es2.environment.id = :environmentId " + + "ORDER BY es2.checkTimestamp DESC LIMIT :keepCount)") + void deleteAllButLatestByEnvironmentId(Long environmentId, int keepCount); +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/HttpStatusCheck.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/HttpStatusCheck.java new file mode 100644 index 000000000..e0a8e2880 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/HttpStatusCheck.java @@ -0,0 +1,31 @@ +package de.tum.cit.aet.helios.environment.status; + +import de.tum.cit.aet.helios.environment.Environment; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +public class HttpStatusCheck implements StatusCheckStrategy { + private final RestTemplate restTemplate; + + @Override + public StatusCheckResult check(Environment environment) { + try { + ResponseEntity response = restTemplate.getForEntity( + environment.getStatusUrl(), + Void.class); + + return new StatusCheckResult( + response.getStatusCode().is2xxSuccessful(), + response.getStatusCode().value(), + Map.of()); + } catch (Exception e) { + return new StatusCheckResult(false, 503, Map.of()); + } + } + +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckResult.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckResult.java new file mode 100644 index 000000000..1e683d798 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckResult.java @@ -0,0 +1,9 @@ +package de.tum.cit.aet.helios.environment.status; + +import java.util.Map; + +public record StatusCheckResult( + boolean success, + int httpStatusCode, + Map metadata) { +} \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckScheduler.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckScheduler.java new file mode 100644 index 000000000..d6aea8816 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckScheduler.java @@ -0,0 +1,43 @@ +package de.tum.cit.aet.helios.environment.status; + +import de.tum.cit.aet.helios.environment.Environment; +import de.tum.cit.aet.helios.environment.EnvironmentRepository; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class StatusCheckScheduler { + private final EnvironmentRepository environmentRepository; + private final StatusCheckService statusCheckService; + + /* + * Runs status checks for all environments with a status check type configured + * at a fixed interval. + * + * The interval is configurable via the status-check.interval property. + * Defaults to 120 seconds. + */ + @Scheduled(fixedRateString = "${status-check.interval:120s}") + public void runScheduledChecks() { + log.info("Starting scheduled status checks."); + + List environments = environmentRepository.findByStatusCheckTypeIsNotNull(); + + log.info("Found {} environments with status check type configured.", environments.size()); + + List> futures = environments.stream() + .map(env -> statusCheckService.performStatusCheck(env)) + .toList(); + + // Wait for all status checks to complete + futures.forEach(CompletableFuture::join); + + log.info("Scheduled status checks completed."); + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckService.java new file mode 100644 index 000000000..c726b9ee7 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckService.java @@ -0,0 +1,90 @@ +package de.tum.cit.aet.helios.environment.status; + +import de.tum.cit.aet.helios.environment.Environment; +import jakarta.transaction.Transactional; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Log4j2 +@Transactional +public class StatusCheckService { + private final Map checkStrategies; + private final EnvironmentStatusRepository statusRepository; + + /** + * The number of status entries to keep for each environment + * before deleting the oldest entries. + */ + @Value("${status-check.keep-count:10}") + private int keepCount; + + /** + * Performs a status check on the given environment asynchronously as the status + * check may take a while to complete. + * + *

The type of status check to be performed is determined by the environment's + * configuration. + * Saves the result of the status check after completion. + * + * @param environment the environment on which to perform the status check + */ + @Async("statusCheckTaskExecutor") + public CompletableFuture performStatusCheck(Environment environment) { + // We need to return a CompletableFuture to allow the caller to wait for the + // completion of the status check, even though we don't need to return any value. + final CompletableFuture returnFuture = CompletableFuture.completedFuture(null); + + final StatusCheckType checkType = environment.getStatusCheckType(); + + log.debug("Starting status check for environment {} (ID: {}) with type {}", + environment.getName(), environment.getId(), checkType); + + if (checkType == null) { + log.warn("Skipping environment {} - no check type configured", environment.getId()); + return returnFuture; + } + + final StatusCheckStrategy strategy = checkStrategies.get(checkType); + + if (strategy == null) { + log.error("No strategy found for check type {} in environment {}", + checkType, environment.getId()); + return returnFuture; + } + + final StatusCheckResult result = strategy.check(environment); + log.debug("Check completed for environment {} - success: {}, code: {}", + environment.getId(), result.success(), result.httpStatusCode()); + + saveStatusResult(environment, result); + + return returnFuture; + } + + private void saveStatusResult(Environment environment, StatusCheckResult result) { + EnvironmentStatus status = new EnvironmentStatus(); + + status.setEnvironment(environment); + status.setCheckType(environment.getStatusCheckType()); + status.setSuccess(result.success()); + status.setHttpStatusCode(result.httpStatusCode()); + status.setCheckTimestamp(Instant.now()); + status.setMetadata(result.metadata()); + + statusRepository.save(status); + + // To prevent the status table from growing indefinitely, delete all but the + // oldest keepCount entries for the environment + statusRepository.deleteAllButLatestByEnvironmentId(environment.getId(), this.keepCount); + + log.debug("Persisted status entry for environment {}", environment.getId()); + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckStrategy.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckStrategy.java new file mode 100644 index 000000000..4eb9b9447 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckStrategy.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.helios.environment.status; + +import de.tum.cit.aet.helios.environment.Environment; + +public interface StatusCheckStrategy { + StatusCheckResult check(Environment environment); +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckType.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckType.java new file mode 100644 index 000000000..f2993e1df --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/environment/status/StatusCheckType.java @@ -0,0 +1,6 @@ +package de.tum.cit.aet.helios.environment.status; + +public enum StatusCheckType { + HTTP_STATUS, // Simple HTTP status check + ARTEMIS_INFO, // Checks the /management/info endpoint of Artemis +} diff --git a/server/application-server/src/main/resources/db/migration/V2__status_checks.sql b/server/application-server/src/main/resources/db/migration/V2__status_checks.sql new file mode 100644 index 000000000..3e5465904 --- /dev/null +++ b/server/application-server/src/main/resources/db/migration/V2__status_checks.sql @@ -0,0 +1,20 @@ +-- Add new columns to environment table +ALTER TABLE public.environment + ADD COLUMN status_url VARCHAR(255), + ADD COLUMN status_check_type VARCHAR(20); + +-- Create environment_status table +CREATE TABLE public.environment_status ( + id BIGSERIAL PRIMARY KEY, + environment_id BIGINT NOT NULL REFERENCES public.environment(id) ON DELETE CASCADE, + success BOOLEAN NOT NULL, + http_status_code INT NOT NULL, + check_type VARCHAR(20) NOT NULL, + check_timestamp TIMESTAMP NOT NULL, + metadata JSONB +); + +-- Create indexes for common query patterns +CREATE INDEX idx_env_status_env ON public.environment_status(environment_id); +CREATE INDEX idx_env_status_order ON public.environment_status(environment_id, check_timestamp DESC); +CREATE INDEX idx_env_status_type ON public.environment_status(check_type); \ No newline at end of file