Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add dynamic status check intervals with cooldown period and timeout handling #360

Merged
merged 16 commits into from
Feb 14, 2025
Merged
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<div class="flex flex-col">
<span class="text-xs uppercase tracking-tighter font-bold text-gray-500 mb-2">Latest status check</span>

<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-1">
<span class="text-sm font-medium text-gray-700">Last Checked:</span>
<span class="text-sm text-gray-500">
{{ status().checkedAt | timeAgo }}
{{ timeSinceChecked() }}
</span>
</div>
<div class="flex items-center justify-between mt-1">
<div class="flex items-center justify-between mt-1 gap-1">
<span class="text-sm font-medium text-gray-700">Status Code:</span>
<span class="text-sm text-gray-500">
{{ status().httpStatusCode || 'N/A' }}
Expand All @@ -17,7 +17,7 @@
@if (status().checkType === 'ARTEMIS_INFO') {
<span class="text-xs uppercase tracking-tighter text-gray-500 mt-3">Artemis Build</span>
@for (item of artemisBuildInfo(); track item.label) {
<div class="flex items-center justify-between mt-1">
<div class="flex items-center justify-between mt-1 gap-1">
<span class="text-sm font-medium text-gray-700">{{ item.label }}:</span>
<span class="text-sm text-gray-500">{{ item.value || '-/-' }}</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import { Component, computed, inject, input } from '@angular/core';
import { Component, computed, inject, input, OnDestroy, OnInit, signal } 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],
providers: [TimeAgoPipe],
templateUrl: './environment-status-info.component.html',
})
export class EnvironmentStatusInfoComponent {
export class EnvironmentStatusInfoComponent implements OnInit, OnDestroy {
timeAgoPipe = inject(TimeAgoPipe);

status = input.required<EnvironmentStatusDto>();
dateService = inject(DateService);
checkedAt = computed(() => this.status().checkedAt);

// Using it as a pipe won't update the value
timeSinceChecked = computed(() => {
return this.timeAgoPipe.transform(this.checkedAt(), {
showSeconds: true,
referenceDate: this.timeNow(),
});
});

// track the current time in a signal that we update every second
timeNow = signal<Date>(new Date());

// store the interval ID so we can clear it later
private intervalId?: ReturnType<typeof setInterval>;

artemisBuildInfo = computed<{ label: string; value?: string }[]>(() => {
const status = this.status();
Expand All @@ -20,6 +37,7 @@ export class EnvironmentStatusInfoComponent {
group?: string;
version?: string;
buildTime?: number;
commitId?: string;
}
| undefined;

Expand All @@ -40,6 +58,24 @@ export class EnvironmentStatusInfoComponent {
label: 'Build Time',
value: metadata?.buildTime ? this.dateService.formatDate(metadata.buildTime * 1000, 'yyyy-MM-dd HH:mm:ss') || '-/-' : undefined,
},
{
label: 'Commit Hash',
value: metadata?.commitId ? metadata.commitId.slice(0, 7) : undefined,
},
];
});

ngOnInit() {
// Update timeNow every second
this.intervalId = setInterval(() => {
this.timeNow.set(new Date());
}, 1000);
}

ngOnDestroy() {
// Prevent memory leaks by clearing the interval
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
}
13 changes: 11 additions & 2 deletions client/src/app/pipes/time-ago.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core';

interface TimeAgoPipeOptions {
showSeconds?: boolean;
referenceDate?: Date;
}

@Pipe({
name: 'timeAgo',
})
export class TimeAgoPipe implements PipeTransform {
transform(value: string): string {
transform(value: string, options?: TimeAgoPipeOptions): string {
const date = new Date(value);
const now = new Date();
const now = options?.referenceDate || new Date();
const diff = Math.abs(now.getTime() - date.getTime());
let seconds = Math.round(diff / 1000);
let minutes = Math.round(seconds / 60);
Expand All @@ -16,6 +22,9 @@ export class TimeAgoPipe implements PipeTransform {
if (Number.isNaN(seconds)) {
return '';
} else if (seconds < 60) {
if (options?.showSeconds) {
return seconds + ` second${seconds === 1 ? '' : 's'} ago`;
}
return 'a few seconds ago';
} else if (seconds < 120) {
return 'a minute ago';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ public void deployToEnvironment(DeployRequest deployRequest) {
deploymentWorkflowFileName,
deployRequest.branchName(),
workflowParams);

this.environmentService.markStatusAsChanged(environment);
} catch (IOException e) {
// Don't need to unlock the environment, since user might want to re-deploy
heliosDeployment.setStatus(HeliosDeployment.Status.IO_ERROR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
Expand All @@ -35,7 +36,8 @@
@NoArgsConstructor
@ToString
public class Environment extends RepositoryFilterEntity {
@Id private Long id;
@Id
private Long id;

@Column(nullable = false)
private String name;
Expand All @@ -51,14 +53,16 @@ public class Environment extends RepositoryFilterEntity {
@Column(name = "updated_at")
private OffsetDateTime updatedAt;

@Version private Integer version;
@Version
private Integer version;

@OneToMany(fetch = FetchType.EAGER, mappedBy = "environment")
@OrderBy("createdAt ASC")
private List<Deployment> deployments;

/**
* Whether the environment is enabled or not. It is set to false by default. Needs to be set to
* Whether the environment is enabled or not. It is set to false by default.
* Needs to be set to
* true in Helios environment settings page.
*/
private boolean enabled = false;
Expand Down Expand Up @@ -98,6 +102,9 @@ public class Environment extends RepositoryFilterEntity {
@Column(name = "status_check_type", length = 20)
private StatusCheckType statusCheckType;

@Column(name = "status_changed_at", nullable = true)
private Instant statusChangedAt;

@OneToMany(mappedBy = "environment", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("checkTimestamp DESC")
private List<EnvironmentStatus> statusHistory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import de.tum.cit.aet.helios.gitrepo.GitRepository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

@Repository
Expand All @@ -17,7 +18,12 @@ public interface EnvironmentRepository extends JpaRepository<Environment, Long>

List<Environment> findByEnabledTrueOrderByNameAsc();

List<Environment> findByStatusCheckTypeIsNotNull();
@Query("SELECT DISTINCT e FROM Environment e "
+ "LEFT JOIN FETCH e.statusHistory es "
+ "WHERE (es is NULL OR es.checkTimestamp = "
+ "(SELECT MAX(es2.checkTimestamp) FROM EnvironmentStatus es2 WHERE es2.environment = e))"
+ "AND e.statusCheckType IS NOT NULL")
List<Environment> findByStatusCheckTypeIsNotNullWithLatestStatus();

List<Environment> findByLockedTrue();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import de.tum.cit.aet.helios.user.User;
import jakarta.persistence.EntityNotFoundException;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -235,6 +236,20 @@ private OffsetDateTime getLockReservationExpiresAt(Environment environment) {
}
}

/**
* Marks the provided environment as having its status changed by setting the
* current timestamp. It updates the environment's status change timestamp to the
* current instant and persists the changes in the repository.
* This will cause the status check for the environment to run more frequently
* for some time.
*
* @param environment the Environment instance whose status has been altered
*/
public void markStatusAsChanged(Environment environment) {
environment.setStatusChangedAt(Instant.now());
environmentRepository.save(environment);
}

/**
* Unlocks the environment with the specified ID.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ public StatusCheckResult check(Environment environment) {

if (artemisInfo != null) {
ArtemisInfo.BuildInfo build = artemisInfo.build();
ArtemisInfo.GitInfo git = artemisInfo.git();

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());
metadata.put("commitId", git.commit().id().full());
}

return new StatusCheckResult(
Expand All @@ -47,14 +49,31 @@ public StatusCheckResult check(Environment environment) {
}
}

public record ArtemisInfo(@JsonProperty("build") BuildInfo build) {
public record ArtemisInfo(
@JsonProperty("build") BuildInfo build,
@JsonProperty("git") GitInfo git) {
public record BuildInfo(
String artifact,
String name,
@JsonProperty("time") Instant time,
String version,
String group) {
}

public record GitInfo(
String branch,
@JsonProperty("commit") CommitInfo commit
) {
public record CommitInfo(
@JsonProperty("time") Instant time,
@JsonProperty("id") CommitIdInfo id
) {
public record CommitIdInfo(
String full
) {
}
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.time.Duration;
import java.util.Map;
import lombok.Data;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
Expand All @@ -19,11 +21,53 @@
@EnableScheduling
@EnableConfigurationProperties(EnvironmentStatusConfig.StatusCheckTaskExecutorConfig.class)
public class EnvironmentStatusConfig {

/**
* The interval at which to check the status of environments
* whose status has changed recently. Has to be lower or equal
* to the stable interval. Defaults to 10 seconds.
*/
@Getter
@Value("${status-check.recent-interval:10s}")
private Duration checkRecentInterval;

/**
* The interval at which to check the status of environments
* that have been stable for a while. Has to be higher or equal
* to the recent interval. Defaults to 120 seconds.
*/
@Getter
@Value("${status-check.stable-interval:120s}")
private Duration checkStableInterval;

/**
* The threshold after which an environment is considered stable.
* If the status of an environment has not changed for this duration,
* it is considered stable and will be checked less frequently.
* Defaults to 5 minutes.
*/
@Getter
@Value("${status-check.recent-threshold:5m}")
private Duration checkRecentThreshold;

// The recent interval will always be lower than the stable interval
// so that will be our general check interval
public Duration getCheckInterval() {
if (checkRecentInterval.compareTo(checkStableInterval) > 0) {
throw new IllegalArgumentException(
"Recent interval must be lower or equal to stable interval"
);
}
return checkRecentInterval;
}

@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder()
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(5))
// Make sure this is lower than our interval for the status check scheduler
// in total, we want to wait at most 8 seconds for a response
.connectTimeout(Duration.ofSeconds(3))
.readTimeout(Duration.ofSeconds(3))
.build();
}

Expand All @@ -39,8 +83,10 @@ public Map<StatusCheckType, StatusCheckStrategy> checkStrategies(
/**
* 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.
* 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
Expand Down
Loading
Loading