diff --git a/client/src/app/components/environments/deployment-state-tag/deployment-state-tag.component.ts b/client/src/app/components/environments/deployment-state-tag/deployment-state-tag.component.ts index 30b95a90..8f47255d 100644 --- a/client/src/app/components/environments/deployment-state-tag/deployment-state-tag.component.ts +++ b/client/src/app/components/environments/deployment-state-tag/deployment-state-tag.component.ts @@ -2,9 +2,9 @@ import { Component, computed, input } from '@angular/core'; import { TagModule } from 'primeng/tag'; import { IconsModule } from 'icons.module'; import { TooltipModule } from 'primeng/tooltip'; -import { DeploymentDto } from '@app/core/modules/openapi'; +import { EnvironmentDeployment } from '@app/core/modules/openapi'; -type BaseDeploymentState = NonNullable; +type BaseDeploymentState = NonNullable; type ExtendedDeploymentState = BaseDeploymentState | 'NEVER_DEPLOYED' | 'REPLACED'; @Component({ @@ -34,6 +34,7 @@ export class DeploymentStateTagComponent { UNKNOWN: 'secondary', NEVER_DEPLOYED: 'secondary', REPLACED: 'contrast', + REQUESTED: 'warn', }; return severityMap[this.internalState()]; }); @@ -51,6 +52,7 @@ export class DeploymentStateTagComponent { UNKNOWN: 'question-mark', NEVER_DEPLOYED: 'question-mark', REPLACED: 'repeat', + REQUESTED: 'progress', }; return iconMap[this.internalState()]; }); @@ -73,6 +75,7 @@ export class DeploymentStateTagComponent { UNKNOWN: 'unknown', NEVER_DEPLOYED: 'never deployed', REPLACED: 'replaced', + REQUESTED: 'requested', }; return valueMap[this.internalState()]; }); @@ -90,6 +93,7 @@ export class DeploymentStateTagComponent { UNKNOWN: 'Deployment state unknown', NEVER_DEPLOYED: 'Never deployed', REPLACED: 'Deployment was replaced', + REQUESTED: 'Deployment requested', }; return tooltipMap[this.internalState()]; }); diff --git a/client/src/app/components/environments/deployment-stepper/deployment-stepper.component.html b/client/src/app/components/environments/deployment-stepper/deployment-stepper.component.html new file mode 100644 index 00000000..bc9d56ce --- /dev/null +++ b/client/src/app/components/environments/deployment-stepper/deployment-stepper.component.html @@ -0,0 +1,119 @@ +
+ Latest Deployment Progress + + +
+ @if (deployment && deployment.state === 'SUCCESS') { +
+
+ + + {{ getDeploymentDuration() }} + +
+
(Deployment completed in)
+
+ } @else if (deployment && isErrorState()) { +
+
+ + + {{ getDeploymentDuration() }} + +
+
(Deployment failed in)
+
+ } @else if (deployment && isUnknownState()) { +
+
+ + 0m 0s +
+
(Unknown deployment duration)
+
+ } @else { +
+
+ + + {{ getTimeEstimate(2) }} + +
+
(Estimated time remaining)
+
+ } +
+ +
+ + +
+ + +
+ @for (step of steps; track step; let i = $index) { +
+ +
+ @switch (getStepStatus(i)) { + @case ('completed') { + + } + @case ('active') { + + } + @case ('error') { + + } + @case ('unknown') { + + } + @case ('inactive') { + + } + @case ('upcoming') { +
+ } + } +
+ + +
+ @if (getStepStatus(i) === 'error') { + {{ 'ERROR' }} +
+ {{ stepDescriptions['ERROR'] }} +
+ } @else if (isUnknownState()) { + {{ 'UNKNOWN' }} +
+ {{ stepDescriptions['UNKNOWN'] }} +
+ } @else { + + {{ getStepDisplayName(step) }} + +
+ {{ stepDescriptions[step] }} +
+
+ @if (getTimeEstimate(i)) { + {{ getTimeEstimate(i) }} remaining + } +
+ } +
+
+ } +
+ +
* All time estimates are approximate and based on historical data
+
diff --git a/client/src/app/components/environments/deployment-stepper/deployment-stepper.component.ts b/client/src/app/components/environments/deployment-stepper/deployment-stepper.component.ts new file mode 100644 index 00000000..e4c3fb9d --- /dev/null +++ b/client/src/app/components/environments/deployment-stepper/deployment-stepper.component.ts @@ -0,0 +1,207 @@ +import { Component, Input, OnInit, OnDestroy, computed, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IconsModule } from 'icons.module'; +import { ProgressBarModule } from 'primeng/progressbar'; +import { EnvironmentDeployment } from '@app/core/modules/openapi'; +import { TooltipModule } from 'primeng/tooltip'; + +interface EstimatedTimes { + REQUESTED: number; + PENDING: number; + IN_PROGRESS: number; +} + +@Component({ + selector: 'app-deployment-stepper', + imports: [CommonModule, IconsModule, ProgressBarModule, TooltipModule], + templateUrl: './deployment-stepper.component.html', +}) +export class DeploymentStepperComponent implements OnInit, OnDestroy { + // Create a private signal to hold the deployment value. + private _deployment = signal(undefined); + + @Input() + set deployment(value: EnvironmentDeployment | undefined) { + this._deployment.set(value); + } + get deployment(): EnvironmentDeployment | undefined { + return this._deployment(); + } + + // Define the four steps. (Note: estimatedTimes are defined only for the first three.) + steps: ('REQUESTED' | 'PENDING' | 'IN_PROGRESS' | 'SUCCESS')[] = ['REQUESTED', 'PENDING', 'IN_PROGRESS', 'SUCCESS']; + + // Mapping of state keys to their display descriptions. + stepDescriptions: { + [key in 'QUEUED' | 'REQUESTED' | 'PENDING' | 'IN_PROGRESS' | 'SUCCESS' | 'ERROR' | 'FAILURE' | 'UNKNOWN' | 'INACTIVE']: string; + } = { + QUEUED: 'Deployment Queued', + REQUESTED: 'Request sent to Github', + PENDING: 'Preparing deployment', + IN_PROGRESS: 'Deployment in progress', + SUCCESS: 'Deployment successful', + ERROR: 'Deployment error', + FAILURE: 'Deployment failed', + UNKNOWN: 'State unknown', + INACTIVE: 'Inactive deployment', + }; + + // Estimated times in minutes for each non-terminal step. + // Define estimatedTimes as a computed signal that depends on the reactive deployment signal. + estimatedTimes = computed(() => { + const deployment = this._deployment(); + return { + REQUESTED: 1, + PENDING: deployment?.prName != null ? 1 : 10, // if deployment started from PR then no build time it's 1 minute, if there is a build via branch then it's 4-7 minute in avg + IN_PROGRESS: 4, // deployment state takes around 1-3 mintues + }; + }); + + // A timer updated every second to drive the dynamic progress. + time: number = Date.now(); + intervalId: number | undefined; + + ngOnInit(): void { + this.intervalId = window.setInterval(() => { + this.time = Date.now(); + }, 1000); + } + + ngOnDestroy(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + } + } + + /** + * Determines the effective step index. + * If the deployment state is terminal (SUCCESS, ERROR, FAILURE), use that state. + * Otherwise, use the virtual (elapsed-time based) step. + */ + get currentEffectiveStepIndex(): number { + if (!this.deployment || !this.deployment.createdAt) return 0; + const index = this.steps.indexOf(this.deployment.state as 'REQUESTED' | 'PENDING' | 'IN_PROGRESS' | 'SUCCESS'); + return index !== -1 ? index : 3; + } + + /** + * Checks if the deployment is in an error state. + */ + isErrorState(): boolean { + return ['ERROR', 'FAILURE'].includes(this.deployment?.state || ''); + } + + /** + * Checks if the deployment is in an unknown state. + */ + isUnknownState(): boolean { + return ['UNKNOWN', 'INACTIVE'].includes(this.deployment?.state || ''); + } + + /** + * Returns a status string for a given step index. + * - "completed" if the step index is less than the current effective step. + * - "active" if it matches the current effective step (or "error" if in error state). + * - "upcoming" otherwise. + */ + getStepStatus(index: number): string { + const effectiveStep = this.currentEffectiveStepIndex; + if (this.isUnknownState()) return 'unknown'; + if (index < effectiveStep) return 'completed'; + if (index === effectiveStep) return this.isErrorState() ? 'error' : this.steps[index] === 'SUCCESS' ? 'completed' : 'active'; + return 'upcoming'; + } + + /** + * Computes overall progress in a piecewise manner. + * Each segment (REQUESTED, PENDING, IN_PROGRESS) is allotted 25% of the bar. + * For the current segment, progress is calculated as a ratio of its elapsed time; + * if a segment is already completed (i.e. virtualStepIndex > segment index), that segment is full. + * When the virtual step reaches 3, the progress returns 100%. + */ + get dynamicProgress(): number { + if (!this.deployment || !this.deployment.createdAt) return 0; + if (['UNKNOWN', 'INACTIVE'].includes(this.deployment.state || '')) { + return 0; + } else if (['SUCCESS', 'ERROR', 'FAILURE'].includes(this.deployment.state || '')) { + return 100; + } + const start = new Date(this.deployment.createdAt).getTime(); + const elapsedMs = this.time - start > 0 ? this.time - start : 0; + if (this.currentEffectiveStepIndex === 3) return 100; + + const segStart = this.currentEffectiveStepIndex * 33; + const estimatedMs = this.estimatedTimes()[this.steps[this.currentEffectiveStepIndex] as keyof EstimatedTimes] * 60000; + const elapsedForSegmentMs = elapsedMs > 0 ? elapsedMs : 0; + let ratio = elapsedForSegmentMs / estimatedMs; + if (ratio > 1) { + ratio = 1; + } + const progress = segStart + ratio * 33; + return Math.floor(progress); + } + + /** + * Returns the estimated time remaining (in minutes) for a given step. + * It subtracts the elapsed time from the cumulative estimated time up to that step. + */ + getTimeEstimate(index: number): string { + if (!this.deployment || !this.deployment.createdAt) return ''; + const start = new Date(this.deployment.createdAt).getTime(); + const elapsedMinutes = (this.time - start) / 60000; + let cumulative = 0; + for (let i = 0; i < 3; i++) { + const est = this.estimatedTimes()[this.steps[i] as keyof EstimatedTimes]; + if (this.getStepStatus(i) !== 'completed') { + cumulative += est; + } + if (i === index) { + if (this.getStepStatus(i) === 'completed') { + return ''; + } + const remaining = cumulative - elapsedMinutes; + const minutes = Math.floor(remaining); + const seconds = Math.floor((remaining - minutes) * 60); + return remaining > 0 ? `${minutes}m ${seconds}s` : ''; + } + } + return ''; + } + + /** + * Returns a display name for a given step. + */ + getStepDisplayName(step: string): string { + return ( + { + REQUESTED: 'REQUESTED', + PENDING: 'PRE-DEPLOYMENT', + IN_PROGRESS: 'DEPLOYING', + SUCCESS: 'SUCCESS', + }[step] || step + ); + } + + /** + * Computes the deployment duration by subtracting the deployment creation time + * from the finish time (or current time if finishedAt isn’t available). + */ + getDeploymentDuration(): string { + if (!this.deployment || !this.deployment.createdAt) return ''; + + let endTime: number; + if (['SUCCESS', 'ERROR', 'FAILURE'].includes(this.deployment.state || '')) { + endTime = this.deployment.updatedAt ? new Date(this.deployment.updatedAt).getTime() : this.time; + } else { + endTime = this.time; + } + + const startTime = new Date(this.deployment.createdAt).getTime(); + const elapsedMs = endTime - startTime; + if (elapsedMs < 0) return '0m 0s'; + + const minutes = Math.floor(elapsedMs / 60000); + const seconds = Math.floor((elapsedMs % 60000) / 1000); + return `${minutes}m ${seconds}s`; + } +} 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 de66f267..362a4f82 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 @@ -104,35 +104,53 @@ -
-
- @if (environment.latestDeployment; as deployment) { - - } -
-
-
- @if (environment.latestStatus; as status) { - - } +
+ +
+ Details + + Deployment
- -
-
- -
-
- @for (installedApp of environment.installedApps; track installedApp) { - {{ installedApp }} - } + + @if (showLatestDeployment) { +
+ +
+ } @else { +
+
+ @if (environment.latestDeployment; as deployment) { + + } +
+
+
+ @if (environment.latestStatus; as status) { + + } +
+
+ } + + +
+ +
+
+ @for (installedApp of environment.installedApps; track installedApp) { + {{ installedApp }} + } +
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 3e0df6fb..cbb2307d 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 @@ -30,6 +30,10 @@ import { TimeAgoPipe } from '@app/pipes/time-ago.pipe'; import { UserAvatarComponent } from '@app/components/user-avatar/user-avatar.component'; import { EnvironmentStatusInfoComponent } from '../environment-status-info/environment-status-info.component'; import { EnvironmentStatusTagComponent } from '../environment-status-tag/environment-status-tag.component'; +import { DeploymentStepperComponent } from '../deployment-stepper/deployment-stepper.component'; +import { ToggleButtonModule } from 'primeng/togglebutton'; +import { FormsModule } from '@angular/forms'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; @Component({ selector: 'app-environment-list-view', @@ -43,6 +47,7 @@ import { EnvironmentStatusTagComponent } from '../environment-status-tag/environ ButtonModule, TooltipModule, DeploymentStateTagComponent, + DeploymentStepperComponent, EnvironmentStatusTagComponent, EnvironmentDeploymentInfoComponent, EnvironmentStatusInfoComponent, @@ -51,6 +56,9 @@ import { EnvironmentStatusTagComponent } from '../environment-status-tag/environ CommonModule, TimeAgoPipe, UserAvatarComponent, + ToggleButtonModule, + FormsModule, + ToggleSwitchModule, ], providers: [DatePipe], templateUrl: './environment-list-view.component.html', @@ -64,6 +72,8 @@ export class EnvironmentListViewComponent implements OnDestroy { private currentTime = signal(Date.now()); private intervalId: number | undefined; + showLatestDeployment: boolean = true; + editable = input(); deployable = input(); hideLinkToList = input(); @@ -236,4 +246,13 @@ export class EnvironmentListViewComponent implements OnDestroy { formatEnvironmentType(type: string): string { return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(); } + + isDeploymentOngoing(environment: EnvironmentDto) { + if (!environment.latestDeployment) { + return false; + } else if (environment.latestDeployment.state && ['SUCCESS', 'FAILURE', 'ERROR', 'INACTIVE', 'UNKNOWN'].includes(environment.latestDeployment.state)) { + return false; + } + return true; + } } diff --git a/client/src/app/core/modules/openapi/schemas.gen.ts b/client/src/app/core/modules/openapi/schemas.gen.ts index 245db704..c4133d2f 100644 --- a/client/src/app/core/modules/openapi/schemas.gen.ts +++ b/client/src/app/core/modules/openapi/schemas.gen.ts @@ -69,7 +69,7 @@ export const EnvironmentDeploymentSchema = { }, state: { type: 'string', - enum: ['PENDING', 'WAITING', 'SUCCESS', 'ERROR', 'FAILURE', 'IN_PROGRESS', 'QUEUED', 'INACTIVE', 'UNKNOWN'], + enum: ['REQUESTED', 'PENDING', 'WAITING', 'SUCCESS', 'ERROR', 'FAILURE', 'IN_PROGRESS', 'QUEUED', 'INACTIVE', 'UNKNOWN'], }, statusesUrl: { type: 'string', @@ -89,6 +89,9 @@ export const EnvironmentDeploymentSchema = { releaseCandidateName: { type: 'string', }, + prName: { + type: 'string', + }, user: { $ref: '#/components/schemas/UserInfoDto', }, diff --git a/client/src/app/core/modules/openapi/types.gen.ts b/client/src/app/core/modules/openapi/types.gen.ts index 399da398..11f09a95 100644 --- a/client/src/app/core/modules/openapi/types.gen.ts +++ b/client/src/app/core/modules/openapi/types.gen.ts @@ -21,13 +21,14 @@ export type WorkflowMembershipDto = { export type EnvironmentDeployment = { id: number; url?: string; - state?: 'PENDING' | 'WAITING' | 'SUCCESS' | 'ERROR' | 'FAILURE' | 'IN_PROGRESS' | 'QUEUED' | 'INACTIVE' | 'UNKNOWN'; + state?: 'REQUESTED' | 'PENDING' | 'WAITING' | 'SUCCESS' | 'ERROR' | 'FAILURE' | 'IN_PROGRESS' | 'QUEUED' | 'INACTIVE' | 'UNKNOWN'; statusesUrl?: string; sha?: string; ref?: string; task?: string; workflowRunHtmlUrl?: string; releaseCandidateName?: string; + prName?: string; user?: UserInfoDto; createdAt?: string; updatedAt?: string; diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index 8f66204b..7de2be72 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -917,6 +917,7 @@ components: state: type: string enum: + - REQUESTED - PENDING - WAITING - SUCCESS @@ -938,6 +939,8 @@ components: type: string releaseCandidateName: type: string + prName: + type: string user: $ref: "#/components/schemas/UserInfoDto" createdAt: diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java index 8dfaefdd..417f8fdd 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java @@ -7,9 +7,12 @@ import de.tum.cit.aet.helios.environment.EnvironmentLockHistoryRepository; import de.tum.cit.aet.helios.environment.EnvironmentRepository; import de.tum.cit.aet.helios.environment.EnvironmentService; +import de.tum.cit.aet.helios.filters.RepositoryContext; import de.tum.cit.aet.helios.github.GitHubService; import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment; import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository; +import de.tum.cit.aet.helios.pullrequest.PullRequest; +import de.tum.cit.aet.helios.pullrequest.PullRequestRepository; import de.tum.cit.aet.helios.workflow.Workflow; import de.tum.cit.aet.helios.workflow.WorkflowService; import jakarta.transaction.Transactional; @@ -41,6 +44,7 @@ public class DeploymentService { private final EnvironmentLockHistoryRepository lockHistoryRepository; private final EnvironmentRepository environmentRepository; private final BranchService branchService; + private final PullRequestRepository pullRequestRepository; public Optional getDeploymentById(Long id) { return deploymentRepository.findById(id).map(DeploymentDto::fromDeployment); @@ -75,8 +79,13 @@ public void deployToEnvironment(DeployRequest deployRequest) { Workflow deploymentWorkflow = workflowService.getDeploymentWorkflowForEnv(deployRequest.environmentId()); + // Set the PR associated with the deployment + Optional optionalPullRequest = + pullRequestRepository.findByRepositoryRepositoryIdAndHeadRefNameOrHeadSha( + RepositoryContext.getRepositoryId(), deployRequest.branchName(), commitSha); + HeliosDeployment heliosDeployment = - createHeliosDeployment(environment, deployRequest, commitSha); + createHeliosDeployment(environment, deployRequest, commitSha, optionalPullRequest); Map workflowParams = createWorkflowParams(environmentType, deployRequest, environment); @@ -146,7 +155,10 @@ private Environment lockEnvironment(Long environmentId) { } private HeliosDeployment createHeliosDeployment( - Environment environment, DeployRequest deployRequest, String commitSha) { + Environment environment, + DeployRequest deployRequest, + String commitSha, + Optional optionalPullRequest) { HeliosDeployment heliosDeployment = new HeliosDeployment(); heliosDeployment.setEnvironment(environment); heliosDeployment.setUser(authService.getUserId()); @@ -154,6 +166,7 @@ private HeliosDeployment createHeliosDeployment( heliosDeployment.setBranchName(deployRequest.branchName()); heliosDeployment.setSha(commitSha); heliosDeployment.setCreator(authService.getUserFromGithubId()); + heliosDeployment.setPullRequest(optionalPullRequest.orElse(null)); return heliosDeploymentRepository.saveAndFlush(heliosDeployment); } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/LatestDeploymentUnion.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/LatestDeploymentUnion.java index 12706e63..072e031c 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/LatestDeploymentUnion.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/LatestDeploymentUnion.java @@ -16,6 +16,12 @@ private LatestDeploymentUnion(Deployment realDeployment, HeliosDeployment helios this.heliosDeployment = heliosDeployment; } + public static LatestDeploymentUnion realDeployment( + Deployment dep, OffsetDateTime heliosDeploymentCreatedAt) { + dep.setCreatedAt(heliosDeploymentCreatedAt); + return new LatestDeploymentUnion(dep, null); + } + public static LatestDeploymentUnion realDeployment(Deployment dep) { return new LatestDeploymentUnion(dep, null); } @@ -28,6 +34,7 @@ public static LatestDeploymentUnion realDeployment(Deployment dep) { * @return The LatestDeploymentUnion */ public static LatestDeploymentUnion realDeployment(Deployment dep, HeliosDeployment helios) { + dep.setCreatedAt(helios.getCreatedAt()); return new LatestDeploymentUnion(dep, helios); } @@ -104,18 +111,57 @@ public Environment getEnvironment() { } } - public Deployment.State getState() { + public State getState() { if (isRealDeployment()) { - return realDeployment.getState(); + return State.fromDeploymentState(realDeployment.getState()); } else if (isHeliosDeployment()) { - Deployment.State state = - HeliosDeployment.mapHeliosStatusToDeploymentState(heliosDeployment.getStatus()); - return state; + return State.fromHeliosStatus(heliosDeployment.getStatus()); } else { return null; } } + public static enum State { + REQUESTED, + + // Deployment.State + PENDING, + WAITING, + SUCCESS, + ERROR, + FAILURE, + IN_PROGRESS, + QUEUED, + INACTIVE, + UNKNOWN; + + public static State fromDeploymentState(Deployment.State state) { + return switch (state) { + case PENDING -> PENDING; + case WAITING -> WAITING; + case SUCCESS -> SUCCESS; + case ERROR -> ERROR; + case FAILURE -> FAILURE; + case IN_PROGRESS -> IN_PROGRESS; + case QUEUED -> QUEUED; + case INACTIVE -> INACTIVE; + case UNKNOWN -> UNKNOWN; + default -> throw new IllegalArgumentException("Invalid state: " + state); + }; + } + + public static State fromHeliosStatus(HeliosDeployment.Status status) { + return switch (status) { + case WAITING -> REQUESTED; + case QUEUED -> PENDING; + case IN_PROGRESS -> IN_PROGRESS; + case DEPLOYMENT_SUCCESS -> SUCCESS; + case FAILED -> FAILURE; + case IO_ERROR, UNKNOWN -> UNKNOWN; + }; + } + } + public String getStatusesUrl() { if (isRealDeployment()) { return realDeployment.getStatusesUrl(); @@ -184,6 +230,20 @@ public OffsetDateTime getUpdatedAt() { } } + public String getPullRequestName() { + if (isRealDeployment()) { + return realDeployment.getPullRequest() != null + ? realDeployment.getPullRequest().getTitle() + : null; + } else if (isHeliosDeployment()) { + return heliosDeployment.getPullRequest() != null + ? heliosDeployment.getPullRequest().getTitle() + : null; + } else { + return null; + } + } + public boolean isNone() { return !isRealDeployment() && !isHeliosDeployment(); } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentSyncService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentSyncService.java index f89fb211..79738a0a 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentSyncService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentSyncService.java @@ -32,9 +32,9 @@ public class GitHubDeploymentSyncService { * repository. * * @param deploymentSource the source (GHDeployment or GitHubDeploymentDto) wrapped as a - * DeploymentSource - * @param gitRepository the associated GitRepository entity - * @param environment the associated environment entity + * DeploymentSource + * @param gitRepository the associated GitRepository entity + * @param environment the associated environment entity */ @Transactional public void processDeployment( @@ -88,6 +88,12 @@ private void updateHeliosDeployment(Deployment deployment, Environment environme heliosDeployment.setDeploymentId(deployment.getId()); heliosDeployment.setStatus( HeliosDeployment.mapDeploymentStateToHeliosStatus(deployment.getState())); + if (deployment + .getUpdatedAt() + .toInstant() + .isAfter(heliosDeployment.getUpdatedAt().toInstant())) { + heliosDeployment.setUpdatedAt(deployment.getUpdatedAt()); + } heliosDeploymentRepository.save(heliosDeployment); log.info("Helios Deployment updated"); } 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 c7f7616f..38283c28 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,6 +1,5 @@ package de.tum.cit.aet.helios.environment; -import de.tum.cit.aet.helios.deployment.Deployment; import de.tum.cit.aet.helios.deployment.LatestDeploymentUnion; import de.tum.cit.aet.helios.deployment.LatestDeploymentUnion.DeploymentType; import de.tum.cit.aet.helios.environment.status.EnvironmentStatus; @@ -63,13 +62,14 @@ public static EnvironmentStatusDto fromEnvironmentStatus(EnvironmentStatus envir public static record EnvironmentDeployment( @NonNull Long id, String url, - Deployment.State state, + LatestDeploymentUnion.State state, String statusesUrl, String sha, String ref, String task, String workflowRunHtmlUrl, String releaseCandidateName, + String prName, UserInfoDto user, OffsetDateTime createdAt, OffsetDateTime updatedAt, @@ -90,6 +90,7 @@ public static EnvironmentDeployment fromUnion( .findByRepositoryRepositoryIdAndCommitSha(union.getRepository().id(), union.getSha()) .map(ReleaseCandidate::getName) .orElse(null), + union.getPullRequestName(), UserInfoDto.fromUser(union.getCreator()), union.getCreatedAt(), union.getUpdatedAt(), 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 7286b651..41a15b0e 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 @@ -126,9 +126,10 @@ public LatestDeploymentUnion findLatestDeployment(Environment env) { if (latestHeliosOpt.isPresent() && latestDeploymentOpt.isPresent()) { HeliosDeployment latestHelios = latestHeliosOpt.get(); Deployment latestDeployment = latestDeploymentOpt.get(); - + // TODO: add logs and check what's returned in ehre // Compare updatedAt timestamps to determine the latest - if (latestDeployment.getCreatedAt().isAfter(latestHelios.getCreatedAt())) { + if (latestDeployment.getCreatedAt().isAfter(latestHelios.getCreatedAt()) + || latestDeployment.getCreatedAt().isEqual(latestHelios.getCreatedAt())) { return LatestDeploymentUnion.realDeployment(latestDeployment, latestHelios); } else { return LatestDeploymentUnion.heliosDeployment(latestHelios); diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java index 7fa39404..ac6fd2c4 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java @@ -2,6 +2,7 @@ import de.tum.cit.aet.helios.deployment.Deployment; import de.tum.cit.aet.helios.environment.Environment; +import de.tum.cit.aet.helios.pullrequest.PullRequest; import de.tum.cit.aet.helios.user.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -14,7 +15,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import java.time.OffsetDateTime; import java.util.Map; @@ -89,10 +89,9 @@ protected void onCreate() { updatedAt = OffsetDateTime.now(); } - @PreUpdate - protected void onUpdate() { - updatedAt = OffsetDateTime.now(); - } + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pull_request_id") + private PullRequest pullRequest; // Enum to represent deployment status public enum Status { diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java index c1dbed4b..bca2b71e 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java @@ -152,36 +152,39 @@ private void processRunForHeliosDeployment(GHWorkflowRun workflowRun) throws IOE // triggered the workflow run // Then we can check whether it's triggered via Helios-App or a Github User via Github UI. // We only need to update heliosDeployment if it's triggered via Helios-App - heliosDeploymentRepository .findTopByBranchNameAndCreatedAtLessThanEqualOrderByCreatedAtDesc( workflowRun.getHeadBranch(), DateUtil.convertToOffsetDateTime(workflowRun.getRunStartedAt())) .ifPresent( heliosDeployment -> { - HeliosDeployment.Status mappedStatus = - mapWorkflowRunStatus(workflowRun.getStatus(), workflowRun.getConclusion()); - log.debug("Mapped status {} to {}", workflowRun.getStatus(), mappedStatus); - - // Update the deployment status - heliosDeployment.setStatus(mappedStatus); - - // Update the workflow run html url, so we can show the approval url - // to the user before the Github deployment is created try { - heliosDeployment.setWorkflowRunHtmlUrl(workflowRun.getHtmlUrl().toString()); + if (workflowRun + .getUpdatedAt() + .toInstant() + .isAfter(heliosDeployment.getUpdatedAt().toInstant())) { + heliosDeployment.setUpdatedAt( + DateUtil.convertToOffsetDateTime(workflowRun.getUpdatedAt())); + HeliosDeployment.Status mappedStatus = + mapWorkflowRunStatus(workflowRun.getStatus(), workflowRun.getConclusion()); + log.debug("Mapped status {} to {}", workflowRun.getStatus(), mappedStatus); + + // Update the deployment status + heliosDeployment.setStatus(mappedStatus); + + // Update the workflow run html url, so we can show the approval url + // to the user before the Github deployment is created + heliosDeployment.setWorkflowRunHtmlUrl(workflowRun.getHtmlUrl().toString()); + + log.info( + "Updated HeliosDeployment {} to status {}", + heliosDeployment.getId(), + mappedStatus); + heliosDeploymentRepository.save(heliosDeployment); + } } catch (IOException e) { - log.error( - "Failed to set workflow run html url for HeliosDeployment {}: {}", - heliosDeployment.getId(), - e.getMessage()); + e.printStackTrace(); } - - log.info( - "Updated HeliosDeployment {} to status {}", - heliosDeployment.getId(), - mappedStatus); - heliosDeploymentRepository.save(heliosDeployment); }); } } diff --git a/server/application-server/src/main/resources/db/migration/V13__add_pr_to_helios_deployment.sql b/server/application-server/src/main/resources/db/migration/V13__add_pr_to_helios_deployment.sql new file mode 100644 index 00000000..a261d537 --- /dev/null +++ b/server/application-server/src/main/resources/db/migration/V13__add_pr_to_helios_deployment.sql @@ -0,0 +1,8 @@ +-- Add pull_request_id column to the helios_deployment table +ALTER TABLE helios_deployment + ADD COLUMN pull_request_id BIGINT; + +-- Add foreign key constraint to ensure referential integrity +ALTER TABLE helios_deployment + ADD CONSTRAINT fk_helios_deployment_pull_request + FOREIGN KEY (pull_request_id) REFERENCES public.issue(id); diff --git a/server/application-server/src/main/resources/db/migration/V14__add_missing_on_delete_cascade.sql b/server/application-server/src/main/resources/db/migration/V14__add_missing_on_delete_cascade.sql new file mode 100644 index 00000000..64e26e6d --- /dev/null +++ b/server/application-server/src/main/resources/db/migration/V14__add_missing_on_delete_cascade.sql @@ -0,0 +1,10 @@ +------------------------------------------------------------------------------- +-- workflow_group_membership -> workflow +------------------------------------------------------------------------------- +-- Removing a workflow_group_membership row automatically removes any workflow rows that reference that release_candidate. +ALTER TABLE public.workflow_group_membership DROP CONSTRAINT fkixdlgaqu4hykyehs17gbvyvfj; +ALTER TABLE public.workflow_group_membership + ADD CONSTRAINT fkixdlgaqu4hykyehs17gbvyvfj + FOREIGN KEY (workflow_id) + REFERENCES public.workflow(id) + ON DELETE CASCADE; \ No newline at end of file