Skip to content

feat: workflow job status updates #774

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

Merged
merged 19 commits into from
Jun 1, 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 @@ -124,7 +124,13 @@
<div class="flex w-full">
@if (showLatestDeployment()) {
<!-- Direct use of deployment stepper -->
<app-deployment-stepper [deployment]="environment().latestDeployment" class="w-full" />
<div class="flex-grow">
<app-deployment-stepper [deployment]="environment().latestDeployment" class="w-full" />

@if (workflowRunId()) {
<app-workflow-jobs-status [workflowRunId]="workflowRunId()!" [latestDeployment]="environment().latestDeployment" />
}
</div>
} @else {
<app-environment-details [environment]="environment()" class="w-full" />
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, inject, input, output } from '@angular/core';
import { Component, computed, inject, input, output } from '@angular/core';
import { EnvironmentDeployment, EnvironmentDto } from '@app/core/modules/openapi';
import { DeploymentStepperComponent } from '../deployment-stepper/deployment-stepper.component';
import { EnvironmentActionsComponent } from '../environment-actions/environment-actions.component';
Expand All @@ -19,6 +19,7 @@ import { signal } from '@angular/core';
import { ButtonModule } from 'primeng/button';
import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons';
import { IconExternalLink, IconGitPullRequest, IconHistory, IconTag, IconBrandGithub } from 'angular-tabler-icons/icons';
import { WorkflowJobsStatusComponent } from '../workflow-job-status/workflow-jobs-status.component';

@Component({
selector: 'app-environment-accordion',
Expand All @@ -40,6 +41,7 @@ import { IconExternalLink, IconGitPullRequest, IconHistory, IconTag, IconBrandGi
FormsModule,
RouterLink,
EnvironmentStatusTagComponent,
WorkflowJobsStatusComponent,
],
providers: [
provideTablerIcons({
Expand Down Expand Up @@ -128,4 +130,15 @@ export class EnvironmentAccordionComponent {
window.open(url, '_blank');
}
}

workflowRunId = computed(() => {
const deployment = this.environment()?.latestDeployment;
if (!deployment?.workflowRunHtmlUrl) return undefined;

const matches = deployment.workflowRunHtmlUrl.match(/\/runs\/(\d+)$/);
if (matches && matches[1]) {
return parseInt(matches[1], 10);
}
return undefined;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<div class="workflow-jobs-status w-full mb-4">
@if (jobs().length > 0 && !deploymentSuccessful()) {
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-medium">Job Status</h3>
</div>

<div class="grid">
@for (job of jobs(); track job.id) {
<div
class="workflow-job mb-4 border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-800"
[ngClass]="{
'border-green-300 bg-green-50 dark:border-green-800 dark:bg-green-900/30': job.conclusion === 'success',
'border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-900/30': job.conclusion === 'failure',
}"
>
<div class="job-header flex justify-between items-center mb-2">
<div class="flex items-center gap-2">
<i-tabler [name]="getStatusIcon(job.status, job.conclusion)" [ngClass]="getIconColorClass(job.status, job.conclusion)" class="!size-5"></i-tabler>
<span class="font-semibold">{{ job.name }}</span>
<span class="status-badge px-2 py-1 rounded-full text-xs font-medium" [ngClass]="getStatusClass(job.status, job.conclusion)">
{{ getStatusText(job.status, job.conclusion) }}
</span>
@if (!!job.htmlUrl) {
<p-button [link]="true" label="View Logs on Github" size="small" (click)="openLink(job.htmlUrl)">
<i-tabler name="external-link" class="!size-4"></i-tabler>
</p-button>
}
</div>
<div class="text-sm text-gray-500 flex items-center gap-2">
@if (job.startedAt) {
<span>Started: {{ formatTime(job.startedAt) }}</span>

@if (job.completedAt) {
<span>• Completed: {{ formatTime(job.completedAt) }}</span>
<span class="px-2 py-0.5 bg-gray-100 rounded-full">
{{ getDuration(job.startedAt, job.completedAt) }}
</span>
} @else {
<span class="px-2 py-0.5 bg-blue-50 text-blue-600 rounded-full"> Running </span>
}
}
</div>
</div>

@if (job.status !== 'completed') {
<!-- Runner Information -->
@if (job.runnerId) {
<div class="runner-info text-sm mb-2 flex flex-wrap items-center gap-2">
<span class="text-gray-600">Runner:</span>
<span class="font-medium">{{ job.runnerName || 'Unknown' }}</span>

@if (job.labels && job.labels.length > 0) {
<div class="runner-labels flex flex-wrap gap-1 ml-2">
@for (label of job.labels; track label) {
<span class="px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-700">
{{ label }}
</span>
}
</div>
}
</div>
}

<!-- Waiting for Runner Message -->
@if (job.status === 'queued' && !job.runnerId) {
<div class="waiting-for-runner text-sm mb-2">
<span class="text-yellow-600 flex items-center">
<i-tabler name="clock" class="mr-1"></i-tabler>
Waiting for available runner...
</span>
</div>
}

<!-- Waiting for Runner Message -->
@if (job.status === 'waiting') {
<div class="waiting-for-runner text-sm mb-2">
<span class="text-yellow-600 flex items-center">
<i-tabler name="clock" class="mr-1"></i-tabler>
Waiting for approval...
</span>
</div>
}

@if (job.steps?.length) {
<div class="job-steps pl-4 border-l-2 border-gray-200 mt-3">
@for (step of job.steps; track step.number) {
<div class="step py-1 flex items-center gap-2">
<span class="status-indicator size-2 rounded-full" [ngClass]="getStatusIndicatorClass(step.status, step.conclusion)"></span>
<span class="step-name text-sm flex-grow">{{ step.name }}</span>

<div class="flex items-center gap-2">
@if (step.startedAt && step.completedAt) {
<span class="text-xs text-gray-500">
{{ getDuration(step.startedAt, step.completedAt) }}
</span>
}
<span class="status-badge px-2 py-0.5 rounded-full text-xs" [ngClass]="getStatusClass(step.status, step.conclusion)">
{{ getStatusText(step.status, step.conclusion) }}
</span>
</div>
</div>
}
</div>
}
}
</div>
}
</div>

@if (deploymentUnsuccessful() && !!latestDeployment()?.workflowRunHtmlUrl) {
<div class="flex-col w-full justify-items-center">
<div class="text-red-600 text-sm mb-2">Some jobs failed. Please check the details below.</div>
<p-button label="Open Workflow Run on GitHub" (click)="openLink(latestDeployment()?.workflowRunHtmlUrl)">
<i-tabler name="brand-github" class="!size-4" />
</p-button>
</div>
}
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { CommonModule, DatePipe } from '@angular/common';
import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { EnvironmentDeployment, WorkflowJobDto } from '@app/core/modules/openapi';
import { getWorkflowJobStatusOptions, getWorkflowJobStatusQueryKey } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen';
import { PermissionService } from '@app/core/services/permission.service';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons';
import { IconBrandGithub, IconCircleCheck, IconCircleMinus, IconCircleX, IconClock, IconExternalLink, IconProgress } from 'angular-tabler-icons/icons';
import { Button } from 'primeng/button';

@Component({
selector: 'app-workflow-jobs-status',
standalone: true,
imports: [CommonModule, TablerIconComponent, Button],
providers: [DatePipe, provideTablerIcons({ IconClock, IconProgress, IconCircleMinus, IconCircleCheck, IconCircleX, IconBrandGithub, IconExternalLink })],
templateUrl: './workflow-jobs-status.component.html',
})
export class WorkflowJobsStatusComponent {
permissions = inject(PermissionService);
latestDeployment = input.required<EnvironmentDeployment | undefined>();

workflowRunId = input.required<number>();

private datePipe = inject(DatePipe);

private extraRefetchStarted = signal(false);
private extraRefetchCompleted = signal(false);

// Control when to poll for job status - during active deployment or limited extra fetches
shouldPoll = computed(() => {
if (!this.permissions.hasWritePermission()) {
return false;
}

if (!!this.workflowRunId() && !!this.deploymentInProgress()) {
// Always poll if the deployment is in progress
return true;
}

// Poll for additional fetches after failure to display job status
if (this.extraRefetchStarted() && !this.extraRefetchCompleted()) {
return true;
}

return false;
});

workflowJobsQuery = injectQuery(() => ({
...getWorkflowJobStatusOptions({ path: { runId: this.workflowRunId() } }),
queryKey: getWorkflowJobStatusQueryKey({ path: { runId: this.workflowRunId() } }),
enabled: this.shouldPoll(),
refetchInterval: this.extraRefetchStarted() ? 10000 : 5000, // Slower interval for extra fetches
staleTime: 0,
}));

// Extract jobs data for the template - now properly typed
jobs = computed<WorkflowJobDto[]>(() => {
const response = this.workflowJobsQuery.data();
if (!response || !response.jobs) return [];
return response.jobs;
});

// Check if all jobs are completed
allJobsCompleted = computed(() => {
const jobs = this.jobs();
if (!jobs.length) return false;
return jobs.every(job => job.status === 'completed');
});

deploymentInProgress = computed(() => {
return ['IN_PROGRESS', 'WAITING', 'REQUESTED', 'PENDING', 'QUEUED'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl;
});

deploymentSuccessful = computed(() => {
return ['SUCCESS'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl;
});

deploymentUnsuccessful = computed(() => {
return ['ERROR', 'FAILURE'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl;
});

// Track if any jobs failed
hasFailedJobs = computed(() => {
return this.jobs().some(job => job.conclusion === 'failure');
});

constructor() {
// Watch for changes to inputs and refresh when needed
effect(() => {
if (this.shouldPoll()) {
this.workflowJobsQuery.refetch();
}
});
effect(() => {
if (this.deploymentUnsuccessful() && !this.extraRefetchStarted()) {
// Deployment just completed, start extra fetches
console.debug('Deployment completed, starting extra refetches');
this.extraRefetchStarted.set(true);

// Schedule the end of extra refetches
setTimeout(() => {
console.debug('Extra refetches completed');
this.extraRefetchCompleted.set(true);
}, 60 * 1000); // Stop after 1 minute
}
});
}
// Get CSS class for job status
getStatusClass(status: string | null | undefined, conclusion: string | null | undefined): string {
if (conclusion === 'success') return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30';
if (conclusion === 'failure') return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30';
if (conclusion === 'skipped') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900';
if (conclusion === 'cancelled') return 'text-orange-600 bg-orange-50 dark:text-orange-400 dark:bg-orange-900/30';

if (status === 'in_progress') return 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30';
if (status === 'queued' || status === 'waiting') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900';

return 'text-gray-600 dark:text-gray-400';
}

getStatusIndicatorClass(status: string | null | undefined, conclusion: string | null | undefined): string {
if (conclusion === 'success') return 'bg-green-500 dark:bg-green-400';
if (conclusion === 'failure') return 'bg-red-500 dark:bg-red-400';
if (conclusion === 'skipped') return 'bg-gray-400 dark:bg-gray-500';
if (conclusion === 'cancelled') return 'bg-orange-500 dark:bg-orange-400';

if (status === 'in_progress') return 'bg-blue-500 dark:bg-blue-400';
if (status === 'queued' || status === 'waiting') return 'bg-gray-300 dark:bg-gray-600';

return 'bg-gray-300 dark:bg-gray-600';
}

// Get icon for job status
getStatusIcon(status: string | null | undefined, conclusion: string | null | undefined): string {
if (conclusion === 'success') return 'circle-check';
if (conclusion === 'failure') return 'circle-x';
if (conclusion === 'skipped' || conclusion === 'cancelled') return 'circle-minus';

if (status === 'in_progress') return 'progress';
if (status === 'queued' || status === 'waiting') return 'clock';

return 'help';
}

getIconColorClass(status: string | null | undefined, conclusion: string | null | undefined): string {
if (conclusion === 'success') return 'text-green-600 dark:text-green-400';
if (conclusion === 'failure') return 'text-red-600 dark:text-red-400';
if (conclusion === 'skipped') return 'text-gray-600 dark:text-gray-400';
if (conclusion === 'cancelled') return 'text-orange-600 dark:text-orange-400';

if (status === 'in_progress') return 'text-blue-600 dark:text-blue-400 animate-spin';
if (status === 'queued' || status === 'waiting') return 'text-gray-600 dark:text-gray-400';

return 'text-gray-600 dark:text-gray-400';
}

// Get status text for display
getStatusText(status: string | null | undefined, conclusion: string | null | undefined): string {
return conclusion || status || 'Unknown';
}

// Format timestamp to readable format
formatTime(timestamp: string | null | undefined): string {
if (!timestamp) return '';
return this.datePipe.transform(timestamp, 'HH:mm:ss') || '';
}

// Calculate duration between start and end time
getDuration(startTime: string | undefined, endTime: string | undefined): string {
if (!startTime) return '';

const start = new Date(startTime).getTime();
const end = endTime ? new Date(endTime).getTime() : Date.now();

const durationMs = end - start;
const seconds = Math.floor(durationMs / 1000);

if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
}

openLink(url: string | undefined) {
if (url) {
window.open(url, '_blank');
}
}
}
Loading
Loading