diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java index 27cd50a6e4b9..07c7579751ca 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java @@ -195,7 +195,7 @@ public ResponseEntity getBuildJobStatistics(@RequestPara } /** - * {@code PUT /api/admin/agent/{agentName}/pause} : Pause the specified build agent. + * {@code PUT /api/admin/agents/{agentName}/pause} : Pause the specified build agent. * This endpoint allows administrators to pause a specific build agent by its name. * Pausing a build agent will prevent it from picking up any new build jobs until it is resumed. * @@ -207,7 +207,7 @@ public ResponseEntity getBuildJobStatistics(@RequestPara * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully paused * or an appropriate error response if something went wrong */ - @PutMapping("agent/{agentName}/pause") + @PutMapping("agents/{agentName}/pause") public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { log.debug("REST request to pause agent {}", agentName); localCIBuildJobQueueService.pauseBuildAgent(agentName); @@ -215,7 +215,26 @@ public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { } /** - * {@code PUT /api/admin/agent/{agentName}/resume} : Resume the specified build agent. + * {@code PUT /api/admin/agents/pause-all} : Pause all build agents. + * This endpoint allows administrators to pause all build agents. + * Pausing all build agents will prevent them from picking up any new build jobs until they are resumed. + * + *

+ * Authorization: This operation requires admin privileges, enforced by {@code @EnforceAdmin}. + *

+ * + * @return {@link ResponseEntity} with status code 204 (No Content) if all agents were successfully paused + * or an appropriate error response if something went wrong + */ + @PutMapping("agents/pause-all") + public ResponseEntity pauseAllBuildAgents() { + log.debug("REST request to pause all agents"); + localCIBuildJobQueueService.pauseAllBuildAgents(); + return ResponseEntity.noContent().build(); + } + + /** + * {@code PUT /api/admin/agents/{agentName}/resume} : Resume the specified build agent. * This endpoint allows administrators to resume a specific build agent by its name. * Resuming a build agent will allow it to pick up new build jobs again. * @@ -227,10 +246,29 @@ public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully resumed * or an appropriate error response if something went wrong */ - @PutMapping("agent/{agentName}/resume") + @PutMapping("agents/{agentName}/resume") public ResponseEntity resumeBuildAgent(@PathVariable String agentName) { log.debug("REST request to resume agent {}", agentName); localCIBuildJobQueueService.resumeBuildAgent(agentName); return ResponseEntity.noContent().build(); } + + /** + * {@code PUT /api/admin/agents/resume-all} : Resume all build agents. + * This endpoint allows administrators to resume all build agents. + * Resuming all build agents will allow them to pick up new build jobs again. + * + *

+ * Authorization: This operation requires admin privileges, enforced by {@code @EnforceAdmin}. + *

+ * + * @return {@link ResponseEntity} with status code 204 (No Content) if all agents were successfully resumed + * or an appropriate error response if something went wrong + */ + @PutMapping("agents/resume-all") + public ResponseEntity resumeAllBuildAgents() { + log.debug("REST request to resume all agents"); + localCIBuildJobQueueService.resumeAllBuildAgents(); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index 059df76379da..7ada412499b1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java @@ -154,10 +154,18 @@ public void pauseBuildAgent(String agent) { pauseBuildAgentTopic.publish(agent); } + public void pauseAllBuildAgents() { + getBuildAgentInformation().forEach(agent -> pauseBuildAgent(agent.buildAgent().name())); + } + public void resumeBuildAgent(String agent) { resumeBuildAgentTopic.publish(agent); } + public void resumeAllBuildAgents() { + getBuildAgentInformation().forEach(agent -> resumeBuildAgent(agent.buildAgent().name())); + } + /** * Cancel a build job by removing it from the queue or stopping the build process. * diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html index 7021b3f79c4d..9f75be4268e7 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html @@ -1,11 +1,24 @@
-

@if (buildAgents) { -

- {{ buildAgents.length }} : {{ currentBuilds }} - {{ buildCapacity }} -

-
+
+
+

+

+ {{ buildAgents.length }} : {{ currentBuilds }} + {{ buildCapacity }} +

+
+
+ + +
+
}
+ + + + + + + diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts index 711fa4a40dce..5e79ceac3c75 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts @@ -1,12 +1,14 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { BuildAgentInformation } from 'app/entities/programming/build-agent-information.model'; +import { BuildAgentInformation, BuildAgentStatus } from 'app/entities/programming/build-agent-information.model'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { Subscription } from 'rxjs'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faPause, faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { Router } from '@angular/router'; import { BuildAgent } from 'app/entities/programming/build-agent.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AlertService, AlertType } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-build-agents', @@ -23,13 +25,17 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { routerLink: string; //icons - faTimes = faTimes; + protected readonly faTimes = faTimes; + protected readonly faPause = faPause; + protected readonly faPlay = faPlay; constructor( private websocketService: JhiWebsocketService, private buildAgentsService: BuildAgentsService, private buildQueueService: BuildQueueService, private router: Router, + private modalService: NgbModal, + private alertService: AlertService, ) {} ngOnInit() { @@ -59,7 +65,9 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { private updateBuildAgents(buildAgents: BuildAgentInformation[]) { this.buildAgents = buildAgents; - this.buildCapacity = this.buildAgents.reduce((sum, agent) => sum + (agent.maxNumberOfConcurrentBuildJobs || 0), 0); + this.buildCapacity = this.buildAgents + .filter((agent) => agent.status !== BuildAgentStatus.PAUSED) + .reduce((sum, agent) => sum + (agent.maxNumberOfConcurrentBuildJobs || 0), 0); this.currentBuilds = this.buildAgents.reduce((sum, agent) => sum + (agent.numberOfCurrentBuildJobs || 0), 0); } @@ -86,4 +94,47 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { this.buildQueueService.cancelAllRunningBuildJobsForAgent(buildAgentToCancel.buildAgent?.name).subscribe(); } } + + displayPauseBuildAgentModal(modal: any) { + this.modalService.open(modal); + } + + pauseAllBuildAgents(modal?: any) { + this.buildAgentsService.pauseAllBuildAgents().subscribe({ + next: () => { + this.load(); + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentsPaused', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentPauseFailed', + }); + }, + }); + if (modal) { + modal.close(); + } + } + + resumeAllBuildAgents() { + this.buildAgentsService.resumeAllBuildAgents().subscribe({ + next: () => { + this.load(); + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentsResumed', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentResumeFailed', + }); + }, + }); + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agents.service.ts b/src/main/webapp/app/localci/build-agents/build-agents.service.ts index 6aac582940d3..c3b388586bcf 100644 --- a/src/main/webapp/app/localci/build-agents/build-agents.service.ts +++ b/src/main/webapp/app/localci/build-agents/build-agents.service.ts @@ -33,22 +33,44 @@ export class BuildAgentsService { */ pauseBuildAgent(agentName: string): Observable { const encodedAgentName = encodeURIComponent(agentName); - return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/pause`, null).pipe( + return this.http.put(`${this.adminResourceUrl}/agents/${encodedAgentName}/pause`, null).pipe( catchError((err) => { return throwError(() => new Error(`Failed to pause build agent ${agentName}\n${err.message}`)); }), ); } + /** + * Pause All Build Agents + */ + pauseAllBuildAgents(): Observable { + return this.http.put(`${this.adminResourceUrl}/agents/pause-all`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to pause build agents\n${err.message}`)); + }), + ); + } + /** * Resume Build Agent */ resumeBuildAgent(agentName: string): Observable { const encodedAgentName = encodeURIComponent(agentName); - return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/resume`, null).pipe( + return this.http.put(`${this.adminResourceUrl}/agents/${encodedAgentName}/resume`, null).pipe( catchError((err) => { return throwError(() => new Error(`Failed to resume build agent ${agentName}\n${err.message}`)); }), ); } + + /** + * Resume all Build Agents + */ + resumeAllBuildAgents(): Observable { + return this.http.put(`${this.adminResourceUrl}/agents/resume-all`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to resume build agents\n${err.message}`)); + }), + ); + } } diff --git a/src/main/webapp/i18n/de/buildAgents.json b/src/main/webapp/i18n/de/buildAgents.json index 186bd1432540..bad9a78469d3 100644 --- a/src/main/webapp/i18n/de/buildAgents.json +++ b/src/main/webapp/i18n/de/buildAgents.json @@ -22,10 +22,15 @@ "buildAgentResumed": "Anfrage zum Fortsetzen des BuildJobs erfolgreich gesendet. Der Agent wird wieder neue BuildJobs annehmen.", "buildAgentPauseFailed": "Anhalten des Build-Agenten fehlgeschlagen.", "buildAgentResumeFailed": "Fortsetzen des Build-Agenten fehlgeschlagen.", - "buildAgentWithoutName": "Der Name des Build-Agenten ist erforderlich." + "buildAgentWithoutName": "Der Name des Build-Agenten ist erforderlich.", + "buildAgentsPaused": "Anfrage zum Anhalten aller Build-Agenten erfolgreich gesendet. Die Agenten akzeptieren keine neuen Build-Jobs und werden die aktuellen Build-Jobs entweder ordnungsgemäß beenden oder nach einer konfigurierbaren Anzahl von Sekunden abbrechen.", + "buildAgentsResumed": "Anfrage zum Fortsetzen aller Build-Agenten erfolgreich gesendet. Die Agenten werden wieder neue Build-Jobs annehmen." }, "pause": "Anhalten", - "resume": "Fortsetzen" + "resume": "Fortsetzen", + "pauseAll": "Alle Agenten Anhalten", + "resumeAll": "Alle Agenten fortsetzen", + "pauseAllWarning": "Du bist dabei, alle Build-Agenten anzuhalten. Dies wird verhindern, dass sie neue Build-Jobs verarbeiten.\nBist du sicher, dass du fortfahren möchtest?" } } } diff --git a/src/main/webapp/i18n/en/buildAgents.json b/src/main/webapp/i18n/en/buildAgents.json index 974086655128..ce0cf7620104 100644 --- a/src/main/webapp/i18n/en/buildAgents.json +++ b/src/main/webapp/i18n/en/buildAgents.json @@ -20,12 +20,17 @@ "alerts": { "buildAgentPaused": "Build agent pause request sent successfully. The agent will not accept new build jobs and will gracefully finish the current build jobs or will cancel them after a configurable seconds.", "buildAgentResumed": "Build agent resume request sent successfully. The agent will start accepting new build jobs.", - "buildAgentPauseFailed": "Failed to pause the build agent.", - "buildAgentResumeFailed": "Failed to resume the build agent.", - "buildAgentWithoutName": "Build agent name is required." + "buildAgentPauseFailed": "Failed to pause build agent.", + "buildAgentResumeFailed": "Failed to resume build agent.", + "buildAgentWithoutName": "Build agent name is required.", + "buildAgentsPaused": "Pause request sent to all build agents. The agents will not accept new build jobs and will gracefully finish the current build jobs or will cancel them after a configurable seconds.", + "buildAgentsResumed": "Resume request sent to all build agents. The agents will start accepting new build jobs." }, "pause": "Pause", - "resume": "Resume" + "resume": "Resume", + "pauseAll": "Pause All Agents", + "resumeAll": "Resume All Agents", + "pauseAllWarning": "You are about to pause all build agents. This will prevent them from processing any new build jobs.\nAre you sure you want to proceed?" } } } diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts index fe898f69474a..f71ad2c8d920 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts @@ -2,18 +2,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { BuildAgentSummaryComponent } from 'app/localci/build-agents/build-agent-summary/build-agent-summary.component'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { BuildJob } from 'app/entities/programming/build-job.model'; import dayjs from 'dayjs/esm'; import { ArtemisTestModule } from '../../../test.module'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { DataTableComponent } from 'app/shared/data-table/data-table.component'; -import { MockComponent, MockPipe } from 'ng-mocks'; +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; import { NgxDatatableModule } from '@siemens/ngx-datatable'; import { BuildAgentInformation, BuildAgentStatus } from '../../../../../../main/webapp/app/entities/programming/build-agent-information.model'; import { RepositoryInfo, TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { JobTimingInfo } from 'app/entities/job-timing-info.model'; import { BuildConfig } from 'app/entities/programming/build-config.model'; +import { AlertService, AlertType } from '../../../../../../main/webapp/app/core/util/alert.service'; describe('BuildAgentSummaryComponent', () => { let component: BuildAgentSummaryComponent; @@ -27,6 +28,8 @@ describe('BuildAgentSummaryComponent', () => { const mockBuildAgentsService = { getBuildAgentSummary: jest.fn().mockReturnValue(of([])), + pauseAllBuildAgents: jest.fn().mockReturnValue(of({})), + resumeAllBuildAgents: jest.fn().mockReturnValue(of({})), }; const repositoryInfo: RepositoryInfo = { @@ -135,6 +138,8 @@ describe('BuildAgentSummaryComponent', () => { status: BuildAgentStatus.ACTIVE, }, ]; + let alertService: AlertService; + let alertServiceAddAlertStub: jest.SpyInstance; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -144,11 +149,14 @@ describe('BuildAgentSummaryComponent', () => { { provide: JhiWebsocketService, useValue: mockWebsocketService }, { provide: BuildAgentsService, useValue: mockBuildAgentsService }, { provide: DataTableComponent, useClass: DataTableComponent }, + MockProvider(AlertService), ], }).compileComponents(); fixture = TestBed.createComponent(BuildAgentSummaryComponent); component = fixture.componentInstance; + alertService = TestBed.inject(AlertService); + alertServiceAddAlertStub = jest.spyOn(alertService, 'addAlert'); })); beforeEach(() => { @@ -218,4 +226,36 @@ describe('BuildAgentSummaryComponent', () => { expect(component.buildCapacity).toBe(0); expect(component.currentBuilds).toBe(0); }); + + it('should call correct service method when pausing and resuming build agents', () => { + component.pauseAllBuildAgents(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentsPaused', + }); + + component.resumeAllBuildAgents(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentsResumed', + }); + }); + + it('should show alert when error in pausing or resuming build agents', () => { + mockBuildAgentsService.pauseAllBuildAgents.mockReturnValue(throwError(() => new Error())); + + component.pauseAllBuildAgents(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentPauseFailed', + }); + + mockBuildAgentsService.resumeAllBuildAgents.mockReturnValue(throwError(() => new Error())); + + component.resumeAllBuildAgents(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentResumeFailed', + }); + }); }); diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts index 261acf610ff2..18b9f0135b83 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts @@ -134,7 +134,7 @@ describe('BuildAgentsService', () => { it('should pause build agent', () => { service.pauseBuildAgent('buildAgent1').subscribe(); - const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/pause`); + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/buildAgent1/pause`); expect(req.request.method).toBe('PUT'); req.flush({}); }); @@ -142,7 +142,23 @@ describe('BuildAgentsService', () => { it('should resume build agent', () => { service.resumeBuildAgent('buildAgent1').subscribe(); - const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/resume`); + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/buildAgent1/resume`); + expect(req.request.method).toBe('PUT'); + req.flush({}); + }); + + it('should pause all build agents', () => { + service.pauseAllBuildAgents().subscribe(); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/pause-all`); + expect(req.request.method).toBe('PUT'); + req.flush({}); + }); + + it('should resume all build agents', () => { + service.resumeAllBuildAgents().subscribe(); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/resume-all`); expect(req.request.method).toBe('PUT'); req.flush({}); }); @@ -152,7 +168,7 @@ describe('BuildAgentsService', () => { const observable = lastValueFrom(service.pauseBuildAgent('buildAgent1')); - const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/pause`); + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/buildAgent1/pause`); expect(req.request.method).toBe('PUT'); req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' }); @@ -170,7 +186,42 @@ describe('BuildAgentsService', () => { const observable = lastValueFrom(service.resumeBuildAgent('buildAgent1')); // Set up the expected HTTP request and flush the response with an error. - const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/resume`); + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/buildAgent1/resume`); + expect(req.request.method).toBe('PUT'); + req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' }); + + try { + await observable; + throw new Error('expected an error, but got a success'); + } catch (error) { + expect(error.message).toContain(errorMessage); + } + }); + + it('should handle pause all build agents error', async () => { + const errorMessage = 'Failed to pause build agents'; + + const observable = lastValueFrom(service.pauseAllBuildAgents()); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/pause-all`); + expect(req.request.method).toBe('PUT'); + req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' }); + + try { + await observable; + throw new Error('expected an error, but got a success'); + } catch (error) { + expect(error.message).toContain(errorMessage); + } + }); + + it('should handle resume all build agents error', async () => { + const errorMessage = 'Failed to resume build agents'; + + const observable = lastValueFrom(service.resumeAllBuildAgents()); + + // Set up the expected HTTP request and flush the response with an error. + const req = httpMock.expectOne(`${service.adminResourceUrl}/agents/resume-all`); expect(req.request.method).toBe('PUT'); req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' });