|
| 1 | +import { CommonModule, DatePipe } from '@angular/common'; |
| 2 | +import { Component, computed, effect, inject, input, signal } from '@angular/core'; |
| 3 | +import { EnvironmentDeployment, WorkflowJobDto } from '@app/core/modules/openapi'; |
| 4 | +import { getWorkflowJobStatusOptions, getWorkflowJobStatusQueryKey } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; |
| 5 | +import { PermissionService } from '@app/core/services/permission.service'; |
| 6 | +import { injectQuery } from '@tanstack/angular-query-experimental'; |
| 7 | +import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons'; |
| 8 | +import { IconBrandGithub, IconCircleCheck, IconCircleMinus, IconCircleX, IconClock, IconExternalLink, IconProgress } from 'angular-tabler-icons/icons'; |
| 9 | +import { Button } from 'primeng/button'; |
| 10 | + |
| 11 | +@Component({ |
| 12 | + selector: 'app-workflow-jobs-status', |
| 13 | + standalone: true, |
| 14 | + imports: [CommonModule, TablerIconComponent, Button], |
| 15 | + providers: [DatePipe, provideTablerIcons({ IconClock, IconProgress, IconCircleMinus, IconCircleCheck, IconCircleX, IconBrandGithub, IconExternalLink })], |
| 16 | + templateUrl: './workflow-jobs-status.component.html', |
| 17 | +}) |
| 18 | +export class WorkflowJobsStatusComponent { |
| 19 | + permissions = inject(PermissionService); |
| 20 | + latestDeployment = input.required<EnvironmentDeployment | undefined>(); |
| 21 | + |
| 22 | + workflowRunId = input.required<number>(); |
| 23 | + |
| 24 | + private datePipe = inject(DatePipe); |
| 25 | + |
| 26 | + private extraRefetchStarted = signal(false); |
| 27 | + private extraRefetchCompleted = signal(false); |
| 28 | + |
| 29 | + // Control when to poll for job status - during active deployment or limited extra fetches |
| 30 | + shouldPoll = computed(() => { |
| 31 | + if (!this.permissions.hasWritePermission()) { |
| 32 | + return false; |
| 33 | + } |
| 34 | + |
| 35 | + if (!!this.workflowRunId() && !!this.deploymentInProgress()) { |
| 36 | + // Always poll if the deployment is in progress |
| 37 | + return true; |
| 38 | + } |
| 39 | + |
| 40 | + // Poll for additional fetches after failure to display job status |
| 41 | + if (this.extraRefetchStarted() && !this.extraRefetchCompleted()) { |
| 42 | + return true; |
| 43 | + } |
| 44 | + |
| 45 | + return false; |
| 46 | + }); |
| 47 | + |
| 48 | + workflowJobsQuery = injectQuery(() => ({ |
| 49 | + ...getWorkflowJobStatusOptions({ path: { runId: this.workflowRunId() } }), |
| 50 | + queryKey: getWorkflowJobStatusQueryKey({ path: { runId: this.workflowRunId() } }), |
| 51 | + enabled: this.shouldPoll(), |
| 52 | + refetchInterval: this.extraRefetchStarted() ? 10000 : 5000, // Slower interval for extra fetches |
| 53 | + staleTime: 0, |
| 54 | + })); |
| 55 | + |
| 56 | + // Extract jobs data for the template - now properly typed |
| 57 | + jobs = computed<WorkflowJobDto[]>(() => { |
| 58 | + const response = this.workflowJobsQuery.data(); |
| 59 | + if (!response || !response.jobs) return []; |
| 60 | + return response.jobs; |
| 61 | + }); |
| 62 | + |
| 63 | + // Check if all jobs are completed |
| 64 | + allJobsCompleted = computed(() => { |
| 65 | + const jobs = this.jobs(); |
| 66 | + if (!jobs.length) return false; |
| 67 | + return jobs.every(job => job.status === 'completed'); |
| 68 | + }); |
| 69 | + |
| 70 | + deploymentInProgress = computed(() => { |
| 71 | + return ['IN_PROGRESS', 'WAITING', 'REQUESTED', 'PENDING', 'QUEUED'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; |
| 72 | + }); |
| 73 | + |
| 74 | + deploymentSuccessful = computed(() => { |
| 75 | + return ['SUCCESS'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; |
| 76 | + }); |
| 77 | + |
| 78 | + deploymentUnsuccessful = computed(() => { |
| 79 | + return ['ERROR', 'FAILURE'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; |
| 80 | + }); |
| 81 | + |
| 82 | + // Track if any jobs failed |
| 83 | + hasFailedJobs = computed(() => { |
| 84 | + return this.jobs().some(job => job.conclusion === 'failure'); |
| 85 | + }); |
| 86 | + |
| 87 | + constructor() { |
| 88 | + // Watch for changes to inputs and refresh when needed |
| 89 | + effect(() => { |
| 90 | + if (this.shouldPoll()) { |
| 91 | + this.workflowJobsQuery.refetch(); |
| 92 | + } |
| 93 | + }); |
| 94 | + effect(() => { |
| 95 | + if (this.deploymentUnsuccessful() && !this.extraRefetchStarted()) { |
| 96 | + // Deployment just completed, start extra fetches |
| 97 | + console.debug('Deployment completed, starting extra refetches'); |
| 98 | + this.extraRefetchStarted.set(true); |
| 99 | + |
| 100 | + // Schedule the end of extra refetches |
| 101 | + setTimeout(() => { |
| 102 | + console.debug('Extra refetches completed'); |
| 103 | + this.extraRefetchCompleted.set(true); |
| 104 | + }, 60 * 1000); // Stop after 1 minute |
| 105 | + } |
| 106 | + }); |
| 107 | + } |
| 108 | + // Get CSS class for job status |
| 109 | + getStatusClass(status: string | null | undefined, conclusion: string | null | undefined): string { |
| 110 | + if (conclusion === 'success') return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30'; |
| 111 | + if (conclusion === 'failure') return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30'; |
| 112 | + if (conclusion === 'skipped') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900'; |
| 113 | + if (conclusion === 'cancelled') return 'text-orange-600 bg-orange-50 dark:text-orange-400 dark:bg-orange-900/30'; |
| 114 | + |
| 115 | + if (status === 'in_progress') return 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30'; |
| 116 | + if (status === 'queued' || status === 'waiting') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900'; |
| 117 | + |
| 118 | + return 'text-gray-600 dark:text-gray-400'; |
| 119 | + } |
| 120 | + |
| 121 | + getStatusIndicatorClass(status: string | null | undefined, conclusion: string | null | undefined): string { |
| 122 | + if (conclusion === 'success') return 'bg-green-500 dark:bg-green-400'; |
| 123 | + if (conclusion === 'failure') return 'bg-red-500 dark:bg-red-400'; |
| 124 | + if (conclusion === 'skipped') return 'bg-gray-400 dark:bg-gray-500'; |
| 125 | + if (conclusion === 'cancelled') return 'bg-orange-500 dark:bg-orange-400'; |
| 126 | + |
| 127 | + if (status === 'in_progress') return 'bg-blue-500 dark:bg-blue-400'; |
| 128 | + if (status === 'queued' || status === 'waiting') return 'bg-gray-300 dark:bg-gray-600'; |
| 129 | + |
| 130 | + return 'bg-gray-300 dark:bg-gray-600'; |
| 131 | + } |
| 132 | + |
| 133 | + // Get icon for job status |
| 134 | + getStatusIcon(status: string | null | undefined, conclusion: string | null | undefined): string { |
| 135 | + if (conclusion === 'success') return 'circle-check'; |
| 136 | + if (conclusion === 'failure') return 'circle-x'; |
| 137 | + if (conclusion === 'skipped' || conclusion === 'cancelled') return 'circle-minus'; |
| 138 | + |
| 139 | + if (status === 'in_progress') return 'progress'; |
| 140 | + if (status === 'queued' || status === 'waiting') return 'clock'; |
| 141 | + |
| 142 | + return 'help'; |
| 143 | + } |
| 144 | + |
| 145 | + getIconColorClass(status: string | null | undefined, conclusion: string | null | undefined): string { |
| 146 | + if (conclusion === 'success') return 'text-green-600 dark:text-green-400'; |
| 147 | + if (conclusion === 'failure') return 'text-red-600 dark:text-red-400'; |
| 148 | + if (conclusion === 'skipped') return 'text-gray-600 dark:text-gray-400'; |
| 149 | + if (conclusion === 'cancelled') return 'text-orange-600 dark:text-orange-400'; |
| 150 | + |
| 151 | + if (status === 'in_progress') return 'text-blue-600 dark:text-blue-400 animate-spin'; |
| 152 | + if (status === 'queued' || status === 'waiting') return 'text-gray-600 dark:text-gray-400'; |
| 153 | + |
| 154 | + return 'text-gray-600 dark:text-gray-400'; |
| 155 | + } |
| 156 | + |
| 157 | + // Get status text for display |
| 158 | + getStatusText(status: string | null | undefined, conclusion: string | null | undefined): string { |
| 159 | + return conclusion || status || 'Unknown'; |
| 160 | + } |
| 161 | + |
| 162 | + // Format timestamp to readable format |
| 163 | + formatTime(timestamp: string | null | undefined): string { |
| 164 | + if (!timestamp) return ''; |
| 165 | + return this.datePipe.transform(timestamp, 'HH:mm:ss') || ''; |
| 166 | + } |
| 167 | + |
| 168 | + // Calculate duration between start and end time |
| 169 | + getDuration(startTime: string | undefined, endTime: string | undefined): string { |
| 170 | + if (!startTime) return ''; |
| 171 | + |
| 172 | + const start = new Date(startTime).getTime(); |
| 173 | + const end = endTime ? new Date(endTime).getTime() : Date.now(); |
| 174 | + |
| 175 | + const durationMs = end - start; |
| 176 | + const seconds = Math.floor(durationMs / 1000); |
| 177 | + |
| 178 | + if (seconds < 60) { |
| 179 | + return `${seconds}s`; |
| 180 | + } else if (seconds < 3600) { |
| 181 | + const minutes = Math.floor(seconds / 60); |
| 182 | + const remainingSeconds = seconds % 60; |
| 183 | + return `${minutes}m ${remainingSeconds}s`; |
| 184 | + } else { |
| 185 | + const hours = Math.floor(seconds / 3600); |
| 186 | + const minutes = Math.floor((seconds % 3600) / 60); |
| 187 | + return `${hours}h ${minutes}m`; |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + openLink(url: string | undefined) { |
| 192 | + if (url) { |
| 193 | + window.open(url, '_blank'); |
| 194 | + } |
| 195 | + } |
| 196 | +} |
0 commit comments