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: Deployment Progress Bar #380

Merged
merged 17 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeploymentDto['state']>;
type BaseDeploymentState = NonNullable<EnvironmentDeployment['state']>;
type ExtendedDeploymentState = BaseDeploymentState | 'NEVER_DEPLOYED' | 'REPLACED';

@Component({
Expand Down Expand Up @@ -34,6 +34,7 @@ export class DeploymentStateTagComponent {
UNKNOWN: 'secondary',
NEVER_DEPLOYED: 'secondary',
REPLACED: 'contrast',
REQUESTED: 'warn',
};
return severityMap[this.internalState()];
});
Expand All @@ -51,6 +52,7 @@ export class DeploymentStateTagComponent {
UNKNOWN: 'question-mark',
NEVER_DEPLOYED: 'question-mark',
REPLACED: 'repeat',
REQUESTED: 'progress',
};
return iconMap[this.internalState()];
});
Expand All @@ -73,6 +75,7 @@ export class DeploymentStateTagComponent {
UNKNOWN: 'unknown',
NEVER_DEPLOYED: 'never deployed',
REPLACED: 'replaced',
REQUESTED: 'requested',
};
return valueMap[this.internalState()];
});
Expand All @@ -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()];
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<div class="relative items-center ml-10 mr-10">
<span class="text-m uppercase tracking-tighter font-bold text-gray-500 mb-8"> Latest Deployment Progress </span>

<!-- Top Time Display -->
<div class="text-center mb-4">
@if (deployment && deployment.state === 'SUCCESS') {
<div class="inline-flex flex-col items-center">
<div class="inline-flex items-center bg-green-50 px-4 py-2 rounded-full mb-1">
<i-tabler name="clock" class="w-5 h-5 text-green-600 mr-2"></i-tabler>
<span class="text-lg font-semibold text-green-600">
{{ getDeploymentDuration() }}
</span>
</div>
<div class="text-xs text-gray-500 italic">(Deployment completed in)</div>
</div>
} @else if (deployment && isErrorState()) {
<div class="inline-flex flex-col items-center">
<div class="inline-flex items-center bg-red-50 px-4 py-2 rounded-full mb-1">
<i-tabler name="clock" class="w-5 h-5 text-red-600 mr-2"></i-tabler>
<span class="text-lg font-semibold text-red-600">
{{ getDeploymentDuration() }}
</span>
</div>
<div class="text-xs text-red-500 italic">(Deployment failed in)</div>
</div>
} @else if (deployment && isUnknownState()) {
<div class="inline-flex flex-col items-center">
<div class="inline-flex items-center bg-gray-50 px-4 py-2 rounded-full mb-1">
<i-tabler name="clock" class="w-5 h-5 text-gray-600 mr-2"></i-tabler>
<span class="text-lg font-semibold text-gray-600"> 0m 0s </span>
</div>
<div class="text-xs text-gray-500 italic">(Unknown deployment duration)</div>
</div>
} @else {
<div class="inline-flex flex-col items-center">
<div class="inline-flex items-center bg-blue-50 px-4 py-2 rounded-full mb-1">
<i-tabler name="clock" class="w-5 h-5 text-blue-600 mr-2"></i-tabler>
<span class="text-lg font-semibold text-blue-600">
{{ getTimeEstimate(2) }}
</span>
</div>
<div class="text-xs text-gray-500 italic">(Estimated time remaining)</div>
</div>
}
</div>

<div class="w-[87%] mx-auto text-center">
<!-- Dynamic Progress Bar -->
<p-progressbar [value]="dynamicProgress" [showValue]="true" class="h-4"></p-progressbar>
</div>

<!-- Stepper Circles and Labels -->
<div class="flex justify-between mb-4 mt-2">
@for (step of steps; track step; let i = $index) {
<div class="flex flex-col items-center">
<!-- Step Circle -->
<div
[ngClass]="{
'bg-green-500': getStepStatus(i) === 'completed',
'bg-blue-500 animate-pulse': getStepStatus(i) === 'active',
'bg-red-500': getStepStatus(i) === 'error',
'bg-gray-200': getStepStatus(i) === 'upcoming' || getStepStatus(i) === 'unknown' || getStepStatus(i) === 'inactive',
}"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors duration-300 z-10"
>
@switch (getStepStatus(i)) {
@case ('completed') {
<i-tabler name="check" class="text-white w-5 h-5"></i-tabler>
}
@case ('active') {
<i-tabler name="progress" class="text-white w-5 h-5 animate-spin"></i-tabler>
}
@case ('error') {
<i-tabler name="x" class="text-white w-5 h-5"></i-tabler>
}
@case ('unknown') {
<i-tabler name="question-mark" class="w-5 h-5"></i-tabler>
}
@case ('inactive') {
<i-tabler name="exclamation-mark" class="w-5 h-5"></i-tabler>
}
@case ('upcoming') {
<div class="w-5 h-5"></div>
}
}
</div>

<!-- Step Label and Time Estimate -->
<div class="mt-2 text-center min-w-[150px] px-2">
@if (getStepStatus(i) === 'error') {
<span class="text-sm font-medium text-red-500">{{ 'ERROR' }}</span>
<div class="text-xs text-gray-500">
{{ stepDescriptions['ERROR'] }}
</div>
} @else if (isUnknownState()) {
<span class="text-sm font-medium text-gray-700">{{ 'UNKNOWN' }}</span>
<div class="text-xs text-gray-500">
{{ stepDescriptions['UNKNOWN'] }}
</div>
} @else {
<span class="text-sm font-medium text-gray-700">
{{ getStepDisplayName(step) }}
</span>
<div class="text-xs text-gray-500">
{{ stepDescriptions[step] }}
</div>
<div class="text-xs text-gray-500">
@if (getTimeEstimate(i)) {
{{ getTimeEstimate(i) }} remaining
}
</div>
}
</div>
</div>
}
</div>

<div class="text-right text-xs text-gray-500 italic mt-6">* All time estimates are approximate and based on historical data</div>
</div>
Original file line number Diff line number Diff line change
@@ -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<EnvironmentDeployment | undefined>(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<EstimatedTimes>(() => {
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`;
}
}
Loading
Loading