Skip to content

Commit 01da266

Browse files
authored
feat: workflow job status updates (#774)
1 parent e92c382 commit 01da266

File tree

17 files changed

+777
-139
lines changed

17 files changed

+777
-139
lines changed

client/src/app/components/environments/environment-accordion/environment-accordion.component.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,13 @@
124124
<div class="flex w-full">
125125
@if (showLatestDeployment()) {
126126
<!-- Direct use of deployment stepper -->
127-
<app-deployment-stepper [deployment]="environment().latestDeployment" class="w-full" />
127+
<div class="flex-grow">
128+
<app-deployment-stepper [deployment]="environment().latestDeployment" class="w-full" />
129+
130+
@if (workflowRunId()) {
131+
<app-workflow-jobs-status [workflowRunId]="workflowRunId()!" [latestDeployment]="environment().latestDeployment" />
132+
}
133+
</div>
128134
} @else {
129135
<app-environment-details [environment]="environment()" class="w-full" />
130136
}

client/src/app/components/environments/environment-accordion/environment-accordion.component.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, inject, input, output } from '@angular/core';
1+
import { Component, computed, inject, input, output } from '@angular/core';
22
import { EnvironmentDeployment, EnvironmentDto } from '@app/core/modules/openapi';
33
import { DeploymentStepperComponent } from '../deployment-stepper/deployment-stepper.component';
44
import { EnvironmentActionsComponent } from '../environment-actions/environment-actions.component';
@@ -19,6 +19,7 @@ import { signal } from '@angular/core';
1919
import { ButtonModule } from 'primeng/button';
2020
import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons';
2121
import { IconExternalLink, IconGitPullRequest, IconHistory, IconTag, IconBrandGithub } from 'angular-tabler-icons/icons';
22+
import { WorkflowJobsStatusComponent } from '../workflow-job-status/workflow-jobs-status.component';
2223

2324
@Component({
2425
selector: 'app-environment-accordion',
@@ -40,6 +41,7 @@ import { IconExternalLink, IconGitPullRequest, IconHistory, IconTag, IconBrandGi
4041
FormsModule,
4142
RouterLink,
4243
EnvironmentStatusTagComponent,
44+
WorkflowJobsStatusComponent,
4345
],
4446
providers: [
4547
provideTablerIcons({
@@ -128,4 +130,15 @@ export class EnvironmentAccordionComponent {
128130
window.open(url, '_blank');
129131
}
130132
}
133+
134+
workflowRunId = computed(() => {
135+
const deployment = this.environment()?.latestDeployment;
136+
if (!deployment?.workflowRunHtmlUrl) return undefined;
137+
138+
const matches = deployment.workflowRunHtmlUrl.match(/\/runs\/(\d+)$/);
139+
if (matches && matches[1]) {
140+
return parseInt(matches[1], 10);
141+
}
142+
return undefined;
143+
});
131144
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<div class="workflow-jobs-status w-full mb-4">
2+
@if (jobs().length > 0 && !deploymentSuccessful()) {
3+
<div class="flex justify-between items-center mb-3">
4+
<h3 class="text-lg font-medium">Job Status</h3>
5+
</div>
6+
7+
<div class="grid">
8+
@for (job of jobs(); track job.id) {
9+
<div
10+
class="workflow-job mb-4 border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-800"
11+
[ngClass]="{
12+
'border-green-300 bg-green-50 dark:border-green-800 dark:bg-green-900/30': job.conclusion === 'success',
13+
'border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-900/30': job.conclusion === 'failure',
14+
}"
15+
>
16+
<div class="job-header flex justify-between items-center mb-2">
17+
<div class="flex items-center gap-2">
18+
<i-tabler [name]="getStatusIcon(job.status, job.conclusion)" [ngClass]="getIconColorClass(job.status, job.conclusion)" class="!size-5"></i-tabler>
19+
<span class="font-semibold">{{ job.name }}</span>
20+
<span class="status-badge px-2 py-1 rounded-full text-xs font-medium" [ngClass]="getStatusClass(job.status, job.conclusion)">
21+
{{ getStatusText(job.status, job.conclusion) }}
22+
</span>
23+
@if (!!job.htmlUrl) {
24+
<p-button [link]="true" label="View Logs on Github" size="small" (click)="openLink(job.htmlUrl)">
25+
<i-tabler name="external-link" class="!size-4"></i-tabler>
26+
</p-button>
27+
}
28+
</div>
29+
<div class="text-sm text-gray-500 flex items-center gap-2">
30+
@if (job.startedAt) {
31+
<span>Started: {{ formatTime(job.startedAt) }}</span>
32+
33+
@if (job.completedAt) {
34+
<span>• Completed: {{ formatTime(job.completedAt) }}</span>
35+
<span class="px-2 py-0.5 bg-gray-100 rounded-full">
36+
{{ getDuration(job.startedAt, job.completedAt) }}
37+
</span>
38+
} @else {
39+
<span class="px-2 py-0.5 bg-blue-50 text-blue-600 rounded-full"> Running </span>
40+
}
41+
}
42+
</div>
43+
</div>
44+
45+
@if (job.status !== 'completed') {
46+
<!-- Runner Information -->
47+
@if (job.runnerId) {
48+
<div class="runner-info text-sm mb-2 flex flex-wrap items-center gap-2">
49+
<span class="text-gray-600">Runner:</span>
50+
<span class="font-medium">{{ job.runnerName || 'Unknown' }}</span>
51+
52+
@if (job.labels && job.labels.length > 0) {
53+
<div class="runner-labels flex flex-wrap gap-1 ml-2">
54+
@for (label of job.labels; track label) {
55+
<span class="px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-700">
56+
{{ label }}
57+
</span>
58+
}
59+
</div>
60+
}
61+
</div>
62+
}
63+
64+
<!-- Waiting for Runner Message -->
65+
@if (job.status === 'queued' && !job.runnerId) {
66+
<div class="waiting-for-runner text-sm mb-2">
67+
<span class="text-yellow-600 flex items-center">
68+
<i-tabler name="clock" class="mr-1"></i-tabler>
69+
Waiting for available runner...
70+
</span>
71+
</div>
72+
}
73+
74+
<!-- Waiting for Runner Message -->
75+
@if (job.status === 'waiting') {
76+
<div class="waiting-for-runner text-sm mb-2">
77+
<span class="text-yellow-600 flex items-center">
78+
<i-tabler name="clock" class="mr-1"></i-tabler>
79+
Waiting for approval...
80+
</span>
81+
</div>
82+
}
83+
84+
@if (job.steps?.length) {
85+
<div class="job-steps pl-4 border-l-2 border-gray-200 mt-3">
86+
@for (step of job.steps; track step.number) {
87+
<div class="step py-1 flex items-center gap-2">
88+
<span class="status-indicator size-2 rounded-full" [ngClass]="getStatusIndicatorClass(step.status, step.conclusion)"></span>
89+
<span class="step-name text-sm flex-grow">{{ step.name }}</span>
90+
91+
<div class="flex items-center gap-2">
92+
@if (step.startedAt && step.completedAt) {
93+
<span class="text-xs text-gray-500">
94+
{{ getDuration(step.startedAt, step.completedAt) }}
95+
</span>
96+
}
97+
<span class="status-badge px-2 py-0.5 rounded-full text-xs" [ngClass]="getStatusClass(step.status, step.conclusion)">
98+
{{ getStatusText(step.status, step.conclusion) }}
99+
</span>
100+
</div>
101+
</div>
102+
}
103+
</div>
104+
}
105+
}
106+
</div>
107+
}
108+
</div>
109+
110+
@if (deploymentUnsuccessful() && !!latestDeployment()?.workflowRunHtmlUrl) {
111+
<div class="flex-col w-full justify-items-center">
112+
<div class="text-red-600 text-sm mb-2">Some jobs failed. Please check the details below.</div>
113+
<p-button label="Open Workflow Run on GitHub" (click)="openLink(latestDeployment()?.workflowRunHtmlUrl)">
114+
<i-tabler name="brand-github" class="!size-4" />
115+
</p-button>
116+
</div>
117+
}
118+
}
119+
</div>
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)