diff --git a/src/main/java/de/tum/in/www1/artemis/repository/BuildJobRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/BuildJobRepository.java index 9f09aa022330..52bbbe0b90da 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/BuildJobRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/BuildJobRepository.java @@ -2,6 +2,8 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; +import java.time.Duration; +import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import java.util.Set; @@ -17,6 +19,7 @@ import de.tum.in.www1.artemis.domain.BuildJob; import de.tum.in.www1.artemis.domain.Result; +import de.tum.in.www1.artemis.domain.enumeration.BuildStatus; import de.tum.in.www1.artemis.repository.base.ArtemisJpaRepository; import de.tum.in.www1.artemis.service.connectors.localci.dto.DockerImageBuild; import de.tum.in.www1.artemis.service.connectors.localci.dto.ResultBuildJob; @@ -33,6 +36,36 @@ public interface BuildJobRepository extends ArtemisJpaRepository @EntityGraph(attributePaths = { "result", "result.participation", "result.participation.exercise", "result.submission" }) Page findAll(Pageable pageable); + // Cast to string is necessary. Otherwise, the query will fail on PostgreSQL. + @Query(""" + SELECT b.id + FROM BuildJob b + LEFT JOIN Course c ON b.courseId = c.id + WHERE (:buildStatus IS NULL OR b.buildStatus = :buildStatus) + AND (:buildAgentAddress IS NULL OR b.buildAgentAddress = :buildAgentAddress) + AND (CAST(:startDate AS string) IS NULL OR b.buildStartDate >= :startDate) + AND (CAST(:endDate AS string) IS NULL OR b.buildStartDate <= :endDate) + AND (:searchTerm IS NULL OR (b.repositoryName LIKE %:searchTerm% OR c.title LIKE %:searchTerm%)) + AND (:courseId IS NULL OR b.courseId = :courseId) + AND (:durationLower IS NULL OR (b.buildCompletionDate - b.buildStartDate) >= :durationLower) + AND (:durationUpper IS NULL OR (b.buildCompletionDate - b.buildStartDate) <= :durationUpper) + + """) + Page findAllByFilterCriteria(@Param("buildStatus") BuildStatus buildStatus, @Param("buildAgentAddress") String buildAgentAddress, + @Param("startDate") ZonedDateTime startDate, @Param("endDate") ZonedDateTime endDate, @Param("searchTerm") String searchTerm, @Param("courseId") Long courseId, + @Param("durationLower") Duration durationLower, @Param("durationUpper") Duration durationUpper, Pageable pageable); + + @Query(""" + SELECT b + FROM BuildJob b + LEFT JOIN FETCH b.result r + LEFT JOIN FETCH r.participation p + LEFT JOIN FETCH p.exercise + LEFT JOIN FETCH r.submission + WHERE b.id IN :buildJobIds + """) + List findAllByIdWithResults(@Param("buildJobIds") List buildJobIds); + @Query(""" SELECT new de.tum.in.www1.artemis.service.connectors.localci.dto.DockerImageBuild( b.dockerImage, diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java index 620e7b4c40e3..00b2a4a8f2a2 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java @@ -2,6 +2,7 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_LOCALCI; +import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -14,6 +15,8 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -23,11 +26,14 @@ import com.hazelcast.map.IMap; import com.hazelcast.topic.ITopic; +import de.tum.in.www1.artemis.domain.BuildJob; import de.tum.in.www1.artemis.repository.BuildJobRepository; import de.tum.in.www1.artemis.service.ProfileService; import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildAgentInformation; import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildJobQueueItem; import de.tum.in.www1.artemis.service.connectors.localci.dto.DockerImageBuild; +import de.tum.in.www1.artemis.web.rest.dto.pageablesearch.FinishedBuildJobPageableSearchDTO; +import de.tum.in.www1.artemis.web.rest.util.PageUtil; /** * Includes methods for managing and retrieving the shared build job queue and build agent information. Also contains methods for cancelling build jobs. @@ -279,4 +285,24 @@ public void cancelAllJobsForParticipation(long participationId) { } } + /** + * Get all finished build jobs that match the search criteria. + * + * @param search the search criteria + * @param courseId the id of the course + * @return the page of build jobs + */ + public Page getFilteredFinishedBuildJobs(FinishedBuildJobPageableSearchDTO search, Long courseId) { + Duration buildDurationLower = search.buildDurationLower() == null ? null : Duration.ofSeconds(search.buildDurationLower()); + Duration buildDurationUpper = search.buildDurationUpper() == null ? null : Duration.ofSeconds(search.buildDurationUpper()); + + Page buildJobIdsPage = buildJobRepository.findAllByFilterCriteria(search.buildStatus(), search.buildAgentAddress(), search.startDate(), search.endDate(), + search.pageable().getSearchTerm(), courseId, buildDurationLower, buildDurationUpper, + PageUtil.createDefaultPageRequest(search.pageable(), PageUtil.ColumnMapping.BUILD_JOB)); + + List buildJobs = buildJobRepository.findAllByIdWithResults(buildJobIdsPage.toList()); + + return new PageImpl<>(buildJobs, buildJobIdsPage.getPageable(), buildJobIdsPage.getTotalElements()); + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminBuildJobQueueResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminBuildJobQueueResource.java index 80f4caf87915..56f92d6a7cc4 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminBuildJobQueueResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/admin/AdminBuildJobQueueResource.java @@ -20,14 +20,12 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import de.tum.in.www1.artemis.domain.BuildJob; -import de.tum.in.www1.artemis.repository.BuildJobRepository; import de.tum.in.www1.artemis.security.annotations.EnforceAdmin; import de.tum.in.www1.artemis.service.connectors.localci.SharedQueueManagementService; import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildAgentInformation; import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildJobQueueItem; import de.tum.in.www1.artemis.service.dto.FinishedBuildJobDTO; -import de.tum.in.www1.artemis.web.rest.dto.pageablesearch.PageableSearchDTO; -import de.tum.in.www1.artemis.web.rest.util.PageUtil; +import de.tum.in.www1.artemis.web.rest.dto.pageablesearch.FinishedBuildJobPageableSearchDTO; import tech.jhipster.web.util.PaginationUtil; @Profile(PROFILE_LOCALCI) @@ -37,13 +35,10 @@ public class AdminBuildJobQueueResource { private final SharedQueueManagementService localCIBuildJobQueueService; - private final BuildJobRepository buildJobRepository; - private static final Logger log = LoggerFactory.getLogger(AdminBuildJobQueueResource.class); - public AdminBuildJobQueueResource(SharedQueueManagementService localCIBuildJobQueueService, BuildJobRepository buildJobRepository) { + public AdminBuildJobQueueResource(SharedQueueManagementService localCIBuildJobQueueService) { this.localCIBuildJobQueueService = localCIBuildJobQueueService; - this.buildJobRepository = buildJobRepository; } /** @@ -170,11 +165,14 @@ public ResponseEntity cancelAllRunningBuildJobsForAgent(@RequestParam Stri */ @GetMapping("finished-jobs") @EnforceAdmin - public ResponseEntity> getFinishedBuildJobs(PageableSearchDTO search) { - log.debug("REST request to get a page of finished build jobs"); - final Page page = buildJobRepository.findAll(PageUtil.createDefaultPageRequest(search, PageUtil.ColumnMapping.BUILD_JOB)); - Page finishedBuildJobDTOs = FinishedBuildJobDTO.fromBuildJobsPage(page); - HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); + public ResponseEntity> getFinishedBuildJobs(FinishedBuildJobPageableSearchDTO search) { + log.debug("REST request to get a page of finished build jobs with build status {}, build agent address {}, start date {} and end date {}", search.buildStatus(), + search.buildAgentAddress(), search.startDate(), search.endDate()); + + Page buildJobPage = localCIBuildJobQueueService.getFilteredFinishedBuildJobs(search, null); + + Page finishedBuildJobDTOs = FinishedBuildJobDTO.fromBuildJobsPage(buildJobPage); + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), buildJobPage); return new ResponseEntity<>(finishedBuildJobDTOs.getContent(), headers, HttpStatus.OK); } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/pageablesearch/FinishedBuildJobPageableSearchDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/pageablesearch/FinishedBuildJobPageableSearchDTO.java new file mode 100644 index 000000000000..284130b99f3e --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/pageablesearch/FinishedBuildJobPageableSearchDTO.java @@ -0,0 +1,12 @@ +package de.tum.in.www1.artemis.web.rest.dto.pageablesearch; + +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.enumeration.BuildStatus; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record FinishedBuildJobPageableSearchDTO(BuildStatus buildStatus, String buildAgentAddress, ZonedDateTime startDate, ZonedDateTime endDate, Integer buildDurationLower, + Integer buildDurationUpper, SearchTermPageableSearchDTO pageable) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildJobQueueResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildJobQueueResource.java index 922f58cf14f4..f8f73ac66901 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildJobQueueResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildJobQueueResource.java @@ -20,7 +20,6 @@ import de.tum.in.www1.artemis.domain.BuildJob; import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.repository.BuildJobRepository; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; import de.tum.in.www1.artemis.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; @@ -28,9 +27,8 @@ import de.tum.in.www1.artemis.service.connectors.localci.SharedQueueManagementService; import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildJobQueueItem; import de.tum.in.www1.artemis.service.dto.FinishedBuildJobDTO; -import de.tum.in.www1.artemis.web.rest.dto.pageablesearch.PageableSearchDTO; +import de.tum.in.www1.artemis.web.rest.dto.pageablesearch.FinishedBuildJobPageableSearchDTO; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; -import de.tum.in.www1.artemis.web.rest.util.PageUtil; import tech.jhipster.web.util.PaginationUtil; @Profile(PROFILE_LOCALCI) @@ -46,14 +44,10 @@ public class BuildJobQueueResource { private final CourseRepository courseRepository; - private final BuildJobRepository buildJobRepository; - - public BuildJobQueueResource(SharedQueueManagementService localCIBuildJobQueueService, AuthorizationCheckService authorizationCheckService, CourseRepository courseRepository, - BuildJobRepository buildJobRepository) { + public BuildJobQueueResource(SharedQueueManagementService localCIBuildJobQueueService, AuthorizationCheckService authorizationCheckService, CourseRepository courseRepository) { this.localCIBuildJobQueueService = localCIBuildJobQueueService; this.authorizationCheckService = authorizationCheckService; this.courseRepository = courseRepository; - this.buildJobRepository = buildJobRepository; } /** @@ -163,11 +157,11 @@ public ResponseEntity cancelAllRunningBuildJobs(@PathVariable long courseI */ @GetMapping("courses/{courseId}/finished-jobs") @EnforceAtLeastInstructorInCourse - public ResponseEntity> getFinishedBuildJobsForCourse(@PathVariable long courseId, PageableSearchDTO search) { + public ResponseEntity> getFinishedBuildJobsForCourse(@PathVariable long courseId, FinishedBuildJobPageableSearchDTO search) { log.debug("REST request to get the finished build jobs for course {}", courseId); - final Page page = buildJobRepository.findAllByCourseId(courseId, PageUtil.createDefaultPageRequest(search, PageUtil.ColumnMapping.BUILD_JOB)); - Page finishedBuildJobDTOs = FinishedBuildJobDTO.fromBuildJobsPage(page); - HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); + Page buildJobPage = localCIBuildJobQueueService.getFilteredFinishedBuildJobs(search, courseId); + Page finishedBuildJobDTOs = FinishedBuildJobDTO.fromBuildJobsPage(buildJobPage); + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), buildJobPage); return new ResponseEntity<>(finishedBuildJobDTOs.getContent(), headers, HttpStatus.OK); } diff --git a/src/main/webapp/app/localci/build-queue/build-queue.component.html b/src/main/webapp/app/localci/build-queue/build-queue.component.html index 2a2c10a333c5..658c33d8f61a 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.component.html +++ b/src/main/webapp/app/localci/build-queue/build-queue.component.html @@ -412,12 +412,36 @@

-

- +
+
+

+
+ + + +
+ + @if (this.isLoading) { + + } +
+
+ +
@if (finishedBuildJobs) { @@ -660,3 +684,141 @@

+

+ + + + + diff --git a/src/main/webapp/app/localci/build-queue/build-queue.component.scss b/src/main/webapp/app/localci/build-queue/build-queue.component.scss index 7a83a282e9dd..956ed795dae4 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.component.scss +++ b/src/main/webapp/app/localci/build-queue/build-queue.component.scss @@ -1,3 +1,15 @@ .wrap-long-text { word-break: break-all; /* This will break the text at any character */ } + +.finished-build-jobs-filter-border-bottom { + border-bottom: 2px solid var(--overview-blue-border-color); +} + +.finished-build-jobs-filter-background-accent { + background: var(--user-management-background-color); +} + +.form-item-prepend { + width: 13%; +} diff --git a/src/main/webapp/app/localci/build-queue/build-queue.component.ts b/src/main/webapp/app/localci/build-queue/build-queue.component.ts index c12361352b29..11b4cc802ccf 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.component.ts +++ b/src/main/webapp/app/localci/build-queue/build-queue.component.ts @@ -1,7 +1,7 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { BuildJob, FinishedBuildJob } from 'app/entities/build-job.model'; -import { faCircleCheck, faExclamationCircle, faExclamationTriangle, faSort, faSync, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faCircleCheck, faExclamationCircle, faExclamationTriangle, faFilter, faSort, faSync, faTimes } from '@fortawesome/free-solid-svg-icons'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { take } from 'rxjs/operators'; @@ -12,6 +12,92 @@ import { onError } from 'app/shared/util/global.utils'; import { HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; import dayjs from 'dayjs/esm'; +import { NgbModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; +import { HttpParams } from '@angular/common/http'; +import { LocalStorageService } from 'ngx-webstorage'; +import { Observable, OperatorFunction, Subject, Subscription, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; +import { UI_RELOAD_TIME } from 'app/shared/constants/exercise-exam-constants'; + +export class FinishedBuildJobFilter { + status?: string = undefined; + buildAgentAddress?: string = undefined; + buildStartDateFilterFrom?: dayjs.Dayjs = undefined; + buildStartDateFilterTo?: dayjs.Dayjs = undefined; + buildDurationFilterLowerBound?: number = undefined; + buildDurationFilterUpperBound?: number = undefined; + numberOfAppliedFilters = 0; + appliedFilters = new Map(); + areDurationFiltersValid: boolean = true; + areDatesValid: boolean = true; + + /** + * Adds the http param options + * @param options request options + */ + addHttpParams(options: HttpParams): HttpParams { + if (this.status) { + options = options.append('buildStatus', this.status.toUpperCase()); + } + if (this.buildAgentAddress) { + options = options.append('buildAgentAddress', this.buildAgentAddress); + } + if (this.buildStartDateFilterFrom) { + options = options.append('startDate', this.buildStartDateFilterFrom.toISOString()); + } + if (this.buildStartDateFilterTo) { + options = options.append('endDate', this.buildStartDateFilterTo.toISOString()); + } + if (this.buildDurationFilterLowerBound) { + options = options.append('buildDurationLower', this.buildDurationFilterLowerBound.toString()); + } + if (this.buildDurationFilterUpperBound) { + options = options.append('buildDurationUpper', this.buildDurationFilterUpperBound.toString()); + } + + return options; + } + + /** + * Method to add the filter to the filter map. + * This is used to avoid calling functions from the template. + * @param filterKey The key of the filter + */ + addFilterToFilterMap(filterKey: string) { + if (!this.appliedFilters.get(filterKey)) { + this.appliedFilters.set(filterKey, true); + this.numberOfAppliedFilters++; + } + } + + /** + * Method to remove the filter from the filter map. + * This is used to avoid calling functions from the template. + * @param filterKey The key of the filter + */ + removeFilterFromFilterMap(filterKey: string) { + if (this.appliedFilters.get(filterKey)) { + this.appliedFilters.delete(filterKey); + this.numberOfAppliedFilters--; + } + } +} + +enum BuildJobStatusFilter { + SUCCESSFUL = 'successful', + FAILED = 'failed', + ERROR = 'error', + CANCELLED = 'cancelled', +} + +export enum FinishedBuildJobFilterStorageKey { + status = 'artemis.buildQueue.finishedBuildJobFilterStatus', + buildAgentAddress = 'artemis.buildQueue.finishedBuildJobFilterBuildAgentAddress', + buildStartDateFilterFrom = 'artemis.buildQueue.finishedBuildJobFilterBuildStartDateFilterFrom', + buildStartDateFilterTo = 'artemis.buildQueue.finishedBuildJobFilterBuildStartDateFilterTo', + buildDurationFilterLowerBound = 'artemis.buildQueue.finishedBuildJobFilterBuildDurationFilterLowerBound', + buildDurationFilterUpperBound = 'artemis.buildQueue.finishedBuildJobFilterBuildDurationFilterUpperBound', +} @Component({ selector: 'jhi-build-queue', @@ -41,20 +127,52 @@ export class BuildQueueComponent implements OnInit, OnDestroy { ascending = false; interval: ReturnType; + // Filter + @ViewChild('addressTypeahead', { static: true }) addressTypeahead: NgbTypeahead; + finishedBuildJobFilter = new FinishedBuildJobFilter(); + buildStatusFilterValues?: string[]; + faFilter = faFilter; + focus$ = new Subject(); + click$ = new Subject(); + isLoading = false; + search = new Subject(); + searchSubscription: Subscription; + searchTerm?: string = undefined; + constructor( private route: ActivatedRoute, private websocketService: JhiWebsocketService, private buildQueueService: BuildQueueService, private alertService: AlertService, + private modalService: NgbModal, + private localStorage: LocalStorageService, ) {} ngOnInit() { + this.buildStatusFilterValues = Object.values(BuildJobStatusFilter); this.loadQueue(); this.interval = setInterval(() => { this.updateBuildJobDuration(); }, 1000); - this.loadFinishedBuildJobs(); this.initWebsocketSubscription(); + this.loadFilterFromLocalStorage(); + this.loadFinishedBuildJobs(); + this.searchSubscription = this.search + .pipe( + debounceTime(UI_RELOAD_TIME), + tap(() => (this.isLoading = true)), + switchMap(() => this.fetchFinishedBuildJobs()), + ) + .subscribe({ + next: (res: HttpResponse) => { + this.onSuccess(res.body || [], res.headers); + this.isLoading = false; + }, + error: (res: HttpErrorResponse) => { + onError(this.alertService, res); + this.isLoading = false; + }, + }); } /** @@ -67,6 +185,9 @@ export class BuildQueueComponent implements OnInit, OnDestroy { this.websocketService.unsubscribe(channel); }); clearInterval(this.interval); + if (this.searchSubscription) { + this.searchSubscription.unsubscribe(); + } } /** @@ -169,47 +290,67 @@ export class BuildQueueComponent implements OnInit, OnDestroy { } /** - * Load the finished build jobs from the server + * fetch the finished build jobs from the server by creating observable */ - loadFinishedBuildJobs() { - this.route.paramMap.pipe(take(1)).subscribe((params) => { - const courseId = Number(params.get('courseId')); - if (courseId) { - this.buildQueueService - .getFinishedBuildJobsByCourseId(courseId, { - page: this.page, - pageSize: this.itemsPerPage, - sortingOrder: this.ascending ? SortingOrder.ASCENDING : SortingOrder.DESCENDING, - sortedColumn: this.predicate, - }) - .subscribe({ - next: (res: HttpResponse) => { - this.onSuccess(res.body || [], res.headers); - }, - error: (res: HttpErrorResponse) => { - onError(this.alertService, res); - }, - }); - } else { - this.buildQueueService - .getFinishedBuildJobs({ - page: this.page, - pageSize: this.itemsPerPage, - sortingOrder: this.ascending ? SortingOrder.ASCENDING : SortingOrder.DESCENDING, - sortedColumn: this.predicate, - }) - .subscribe({ - next: (res: HttpResponse) => { - this.onSuccess(res.body || [], res.headers); + fetchFinishedBuildJobs() { + return this.route.paramMap.pipe( + take(1), + tap(() => (this.isLoading = true)), + switchMap((params) => { + const courseId = Number(params.get('courseId')); + if (courseId) { + return this.buildQueueService.getFinishedBuildJobsByCourseId( + courseId, + { + page: this.page, + pageSize: this.itemsPerPage, + sortingOrder: this.ascending ? SortingOrder.ASCENDING : SortingOrder.DESCENDING, + sortedColumn: this.predicate, + searchTerm: this.searchTerm || '', }, - error: (res: HttpErrorResponse) => { - onError(this.alertService, res); + this.finishedBuildJobFilter, + ); + } else { + return this.buildQueueService.getFinishedBuildJobs( + { + page: this.page, + pageSize: this.itemsPerPage, + sortingOrder: this.ascending ? SortingOrder.ASCENDING : SortingOrder.DESCENDING, + sortedColumn: this.predicate, + searchTerm: this.searchTerm || '', }, - }); - } + this.finishedBuildJobFilter, + ); + } + }), + ); + } + + /** + * subscribe to the finished build jobs observable + */ + loadFinishedBuildJobs() { + this.fetchFinishedBuildJobs().subscribe({ + next: (res: HttpResponse) => { + this.onSuccess(res.body || [], res.headers); + this.isLoading = false; + }, + error: (res: HttpErrorResponse) => { + onError(this.alertService, res); + this.isLoading = false; + }, }); } + /** + * Method to trigger the loading of the finished build jobs by pushing a new value to the search observable + */ + triggerLoadFinishedJobs() { + if (!this.searchTerm || this.searchTerm.length >= 3) { + this.search.next(); + } + } + /** * Callback function when the finished build jobs are successfully loaded * @param finishedBuildJobs The list of finished build jobs @@ -258,13 +399,6 @@ export class BuildQueueComponent implements OnInit, OnDestroy { } } - /** - * Callback function to refresh the finished build jobs - */ - refresh() { - this.loadFinishedBuildJobs(); - } - /** * Update the build jobs duration */ @@ -283,4 +417,144 @@ export class BuildQueueComponent implements OnInit, OnDestroy { // This is necessary to update the view when the build job duration is updated this.runningBuildJobs = JSON.parse(JSON.stringify(this.runningBuildJobs)); } + + /** + * Opens the modal. + */ + open(content: any) { + this.modalService.open(content); + } + + /** + * Get all build agents' addresses from the finished build jobs. + */ + get buildAgentAddresses(): string[] { + return Array.from(new Set(this.finishedBuildJobs.map((buildJob) => buildJob.buildAgentAddress ?? '').filter((address) => address !== ''))); + } + + // Workaround for the NgbTypeahead issue: https://github.com/ng-bootstrap/ng-bootstrap/issues/2400 + clickEvents($event: Event, typeaheadInstance: NgbTypeahead) { + if (typeaheadInstance.isPopupOpen()) { + this.click$.next(($event.target as HTMLInputElement).value); + } + } + + /** + * Method to build the agent addresses for the typeahead search. + * @param text$ + */ + typeaheadSearch: OperatorFunction = (text$: Observable) => { + const buildAgentAddresses = this.buildAgentAddresses; + const debouncedText$ = text$.pipe(debounceTime(200), distinctUntilChanged()); + const clicksWithClosedPopup$ = this.click$; + const inputFocus$ = this.focus$; + + return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$).pipe( + map((term) => (term === '' ? buildAgentAddresses : buildAgentAddresses.filter((v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1)).slice(0, 10)), + ); + }; + + /** + * Method to reset the filter. + */ + applyFilter() { + this.loadFinishedBuildJobs(); + this.modalService.dismissAll(); + } + + /** + * Method to load the filter values from the local storage if they exist. + */ + loadFilterFromLocalStorage() { + this.finishedBuildJobFilter.numberOfAppliedFilters = 0; + // Iterate over all keys of the filter and load the values from the local storage if they exist. + for (const key in FinishedBuildJobFilterStorageKey) { + const value = this.localStorage.retrieve(FinishedBuildJobFilterStorageKey[key]); + if (value) { + this.finishedBuildJobFilter[key] = key.includes('Date') ? dayjs(value) : value; + this.finishedBuildJobFilter.addFilterToFilterMap(FinishedBuildJobFilterStorageKey[key]); + } + } + } + + /** + * Method to add or remove a status filter and store the selected status filters in the local store if required. + */ + toggleBuildStatusFilter(value?: string) { + if (value) { + this.finishedBuildJobFilter.status = value; + this.localStorage.store(FinishedBuildJobFilterStorageKey.status, value); + this.finishedBuildJobFilter.addFilterToFilterMap(FinishedBuildJobFilterStorageKey.status); + } else { + this.finishedBuildJobFilter.status = undefined; + this.localStorage.clear(FinishedBuildJobFilterStorageKey.status); + this.finishedBuildJobFilter.removeFilterFromFilterMap(FinishedBuildJobFilterStorageKey.status); + } + } + + /** + * Method to remove the build agent address filter and store the selected build agent address in the local store if required. + */ + filterBuildAgentAddressChanged() { + if (this.finishedBuildJobFilter.buildAgentAddress) { + this.localStorage.store(FinishedBuildJobFilterStorageKey.buildAgentAddress, this.finishedBuildJobFilter.buildAgentAddress); + this.finishedBuildJobFilter.addFilterToFilterMap(FinishedBuildJobFilterStorageKey.buildAgentAddress); + } else { + this.localStorage.clear(FinishedBuildJobFilterStorageKey.buildAgentAddress); + this.finishedBuildJobFilter.removeFilterFromFilterMap(FinishedBuildJobFilterStorageKey.buildAgentAddress); + } + } + + /** + * Method to remove the build start date filter and store the selected build start date in the local store if required. + */ + filterDateChanged() { + if (!this.finishedBuildJobFilter.buildStartDateFilterFrom?.isValid()) { + this.finishedBuildJobFilter.buildStartDateFilterFrom = undefined; + this.localStorage.clear(FinishedBuildJobFilterStorageKey.buildStartDateFilterFrom); + this.finishedBuildJobFilter.removeFilterFromFilterMap(FinishedBuildJobFilterStorageKey.buildStartDateFilterFrom); + } else { + this.localStorage.store(FinishedBuildJobFilterStorageKey.buildStartDateFilterFrom, this.finishedBuildJobFilter.buildStartDateFilterFrom); + this.finishedBuildJobFilter.addFilterToFilterMap(FinishedBuildJobFilterStorageKey.buildStartDateFilterFrom); + } + if (!this.finishedBuildJobFilter.buildStartDateFilterTo?.isValid()) { + this.finishedBuildJobFilter.buildStartDateFilterTo = undefined; + this.localStorage.clear(FinishedBuildJobFilterStorageKey.buildStartDateFilterTo); + this.finishedBuildJobFilter.removeFilterFromFilterMap(FinishedBuildJobFilterStorageKey.buildStartDateFilterTo); + } else { + this.localStorage.store(FinishedBuildJobFilterStorageKey.buildStartDateFilterTo, this.finishedBuildJobFilter.buildStartDateFilterTo); + this.finishedBuildJobFilter.addFilterToFilterMap(FinishedBuildJobFilterStorageKey.buildStartDateFilterTo); + } + if (this.finishedBuildJobFilter.buildStartDateFilterFrom && this.finishedBuildJobFilter.buildStartDateFilterTo) { + this.finishedBuildJobFilter.areDatesValid = this.finishedBuildJobFilter.buildStartDateFilterFrom.isBefore(this.finishedBuildJobFilter.buildStartDateFilterTo); + } else { + this.finishedBuildJobFilter.areDatesValid = true; + } + } + + /** + * Method to remove the build duration filter and store the selected build duration in the local store if required. + */ + filterDurationChanged() { + if (this.finishedBuildJobFilter.buildDurationFilterLowerBound) { + this.localStorage.store(FinishedBuildJobFilterStorageKey.buildDurationFilterLowerBound, this.finishedBuildJobFilter.buildDurationFilterLowerBound); + this.finishedBuildJobFilter.addFilterToFilterMap(FinishedBuildJobFilterStorageKey.buildDurationFilterLowerBound); + } else { + this.localStorage.clear(FinishedBuildJobFilterStorageKey.buildDurationFilterLowerBound); + this.finishedBuildJobFilter.removeFilterFromFilterMap(FinishedBuildJobFilterStorageKey.buildDurationFilterLowerBound); + } + if (this.finishedBuildJobFilter.buildDurationFilterUpperBound) { + this.localStorage.store(FinishedBuildJobFilterStorageKey.buildDurationFilterUpperBound, this.finishedBuildJobFilter.buildDurationFilterUpperBound); + this.finishedBuildJobFilter.addFilterToFilterMap(FinishedBuildJobFilterStorageKey.buildDurationFilterUpperBound); + } else { + this.localStorage.clear(FinishedBuildJobFilterStorageKey.buildDurationFilterUpperBound); + this.finishedBuildJobFilter.removeFilterFromFilterMap(FinishedBuildJobFilterStorageKey.buildDurationFilterUpperBound); + } + if (this.finishedBuildJobFilter.buildDurationFilterLowerBound && this.finishedBuildJobFilter.buildDurationFilterUpperBound) { + this.finishedBuildJobFilter.areDurationFiltersValid = + this.finishedBuildJobFilter.buildDurationFilterLowerBound <= this.finishedBuildJobFilter.buildDurationFilterUpperBound; + } else { + this.finishedBuildJobFilter.areDurationFiltersValid = true; + } + } } diff --git a/src/main/webapp/app/localci/build-queue/build-queue.service.ts b/src/main/webapp/app/localci/build-queue/build-queue.service.ts index 8cba94e2d44a..6f1b558897cc 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.service.ts +++ b/src/main/webapp/app/localci/build-queue/build-queue.service.ts @@ -4,13 +4,15 @@ import { Observable } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { throwError } from 'rxjs'; import { BuildJob, FinishedBuildJob } from 'app/entities/build-job.model'; -import { createRequestOption } from 'app/shared/util/request.util'; +import { createNestedRequestOption } from 'app/shared/util/request.util'; import { HttpResponse } from '@angular/common/http'; +import { FinishedBuildJobFilter } from 'app/localci/build-queue/build-queue.component'; @Injectable({ providedIn: 'root' }) export class BuildQueueService { public resourceUrl = 'api'; public adminResourceUrl = 'api/admin'; + nestedDtoKey = 'pageable'; constructor(private http: HttpClient) {} /** @@ -127,9 +129,13 @@ export class BuildQueueService { /** * Get all finished build jobs * @param req The query request + * @param filter The filter to apply */ - getFinishedBuildJobs(req?: any): Observable> { - const options = createRequestOption(req); + getFinishedBuildJobs(req?: any, filter?: FinishedBuildJobFilter): Observable> { + let options = createNestedRequestOption(req, this.nestedDtoKey); + if (filter) { + options = filter.addHttpParams(options); + } return this.http.get(`${this.adminResourceUrl}/finished-jobs`, { params: options, observe: 'response' }).pipe( catchError((err) => { return throwError(() => new Error(`Failed to get all finished build jobs\n${err.message}`)); @@ -141,9 +147,13 @@ export class BuildQueueService { * Get all finished build jobs associated with a course * @param courseId the id of the course * @param req The query request + * @param filter The filter to apply */ - getFinishedBuildJobsByCourseId(courseId: number, req?: any): Observable> { - const options = createRequestOption(req); + getFinishedBuildJobsByCourseId(courseId: number, req?: any, filter?: FinishedBuildJobFilter): Observable> { + let options = createNestedRequestOption(req, this.nestedDtoKey); + if (filter) { + options = filter.addHttpParams(options); + } return this.http.get(`${this.resourceUrl}/courses/${courseId}/finished-jobs`, { params: options, observe: 'response' }).pipe( catchError((err) => { return throwError(() => new Error(`Failed to get all finished build jobs in course ${courseId}\n${err.message}`)); diff --git a/src/main/webapp/app/shared/util/request.util.ts b/src/main/webapp/app/shared/util/request.util.ts index 934cbf601f3c..b8acb480a28d 100644 --- a/src/main/webapp/app/shared/util/request.util.ts +++ b/src/main/webapp/app/shared/util/request.util.ts @@ -16,3 +16,22 @@ export const createRequestOption = (req?: any): HttpParams => { } return options; }; + +export const createNestedRequestOption = (req?: any, parentKey?: string): HttpParams => { + let options: HttpParams = new HttpParams(); + if (req) { + Object.keys(req).forEach((key) => { + if (key !== 'sort') { + const optionKey = parentKey ? `${parentKey}.${key}` : key; + options = options.set(optionKey, req[key]); + } + }); + if (req.sort) { + req.sort.forEach((val: any) => { + const optionKey = parentKey ? `${parentKey}.sort` : 'sort'; + options = options.append(optionKey, val); + }); + } + } + return options; +}; diff --git a/src/main/webapp/i18n/de/buildQueue.json b/src/main/webapp/i18n/de/buildQueue.json index 9b1d8d08eb67..83c0bec59a12 100644 --- a/src/main/webapp/i18n/de/buildQueue.json +++ b/src/main/webapp/i18n/de/buildQueue.json @@ -26,7 +26,40 @@ "status": "Status", "buildLogs": "Build Logs" }, - "cancelAll": "Alle abbrechen" + "cancelAll": "Alle abbrechen", + "filter": { + "title": "BuildJobs Filter", + "buildStatus": { + "title": "Build Status", + "cancelled": "Abgebrochen", + "failed": "Fehlgeschlagen", + "error": "Fehler", + "successful": "Erfolgreich" + }, + "buildAgentAddress": "Build Agent Adresse", + "buildStartDate": { + "title": "Build Startdatum", + "from": "Von", + "to": "Bis", + "invalidDate": "Von-Datum muss vor dem Bis-Datum liegen" + }, + "buildDuration": { + "title": "Build Dauer (in Sekunden)", + "lowerBound": "Untere Grenze", + "upperBound": "Obere Grenze", + "invalidState": "Die untere Grenze muss kleiner als die obere Grenze sein" + }, + "search": { + "title": "Suche", + "placeholder": "Suche nach Buildjob", + "loading": "Laden...", + "tooltip": "Die Suche wird auf Benutzerlogin, Kursname und Kurskurzname durchgeführt" + }, + "none": "Keine", + "close": "Schließen", + "apply": "Anwenden", + "open": "Filter ({{ num }})" + } } } } diff --git a/src/main/webapp/i18n/en/buildQueue.json b/src/main/webapp/i18n/en/buildQueue.json index 415e4aa38302..642994e6e287 100644 --- a/src/main/webapp/i18n/en/buildQueue.json +++ b/src/main/webapp/i18n/en/buildQueue.json @@ -26,7 +26,40 @@ "status": "Status", "buildLogs": "Build Logs" }, - "cancelAll": "Cancel All" + "cancelAll": "Cancel All", + "filter": { + "title": "BuildJobs Filter", + "buildStatus": { + "title": "Build Status", + "cancelled": "Cancelled", + "failed": "Failed", + "error": "Error", + "successful": "Successful" + }, + "buildAgentAddress": "Build Agent Address", + "buildStartDate": { + "title": "Build Start Date", + "from": "From", + "to": "To", + "invalidDate": "From date must be before to date" + }, + "buildDuration": { + "title": "Build Duration (in seconds)", + "lowerBound": "Lower Bound", + "upperBound": "Upper Bound", + "invalidState": "Lower bound must be less than upper bound" + }, + "search": { + "title": "Search", + "placeholder": "Search for build job", + "loading": "Loading...", + "tooltip": "Search will be performed on user login, course title, and course short name" + }, + "none": "None", + "close": "Close", + "apply": "Apply", + "open": "Filter ({{ num }})" + } } } } diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java index acdedada23c9..e7cdeb5e5ca3 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java @@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; import com.hazelcast.collection.IQueue; import com.hazelcast.core.HazelcastInstance; @@ -247,12 +248,48 @@ void testGetFinishedBuildJobs_returnsJobs() throws Exception { buildJobRepository.save(finishedJob1); buildJobRepository.save(finishedJob2); PageableSearchDTO pageableSearchDTO = pageableSearchUtilService.configureFinishedJobsSearchDTO(); - var result = request.getList("/api/admin/finished-jobs", HttpStatus.OK, FinishedBuildJobDTO.class, pageableSearchUtilService.searchMapping(pageableSearchDTO)); + var result = request.getList("/api/admin/finished-jobs", HttpStatus.OK, FinishedBuildJobDTO.class, pageableSearchUtilService.searchMapping(pageableSearchDTO, "pageable")); assertThat(result).hasSize(2); assertThat(result.get(0).id()).isEqualTo(finishedJob1.getBuildJobId()); assertThat(result.get(1).id()).isEqualTo(finishedJob2.getBuildJobId()); } + @Test + @WithMockUser(username = TEST_PREFIX + "admin", roles = "ADMIN") + void testGetFinishedBuildJobs_returnsFilteredJobs() throws Exception { + buildJobRepository.deleteAll(); + + // Create a failed job to filter for + JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(1).plusMinutes(2), + ZonedDateTime.now().plusDays(1).plusMinutes(10)); + BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null); + RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); + var failedJob1 = new BuildJobQueueItem("5", "job5", "address1", 1, course.getId(), 1, 1, 1, BuildStatus.FAILED, repositoryInfo, jobTimingInfo, buildConfig, null); + var jobResult = new Result().successful(false).rated(true).score(0D).assessmentType(AssessmentType.AUTOMATIC).completionDate(ZonedDateTime.now()); + var failedFinishedJob = new BuildJob(failedJob1, BuildStatus.FAILED, jobResult); + + // Save the jobs + buildJobRepository.save(finishedJob1); + buildJobRepository.save(finishedJob2); + resultRepository.save(jobResult); + buildJobRepository.save(failedFinishedJob); + + // Filter for the failed job + PageableSearchDTO pageableSearchDTO = pageableSearchUtilService.configureFinishedJobsSearchDTO(); + LinkedMultiValueMap searchParams = pageableSearchUtilService.searchMapping(pageableSearchDTO, "pageable"); + searchParams.add("buildStatus", "FAILED"); + searchParams.add("startDate", jobTimingInfo.buildStartDate().minusSeconds(10).toString()); + searchParams.add("endDate", jobTimingInfo.buildCompletionDate().plusSeconds(10).toString()); + searchParams.add("searchTerm", "short"); + searchParams.add("buildDurationLower", "120"); + searchParams.add("buildDurationUpper", "600"); + + // Check that only the failed job is returned + var result = request.getList("/api/admin/finished-jobs", HttpStatus.OK, FinishedBuildJobDTO.class, searchParams); + assertThat(result).hasSize(1); + assertThat(result.getFirst().id()).isEqualTo(failedFinishedJob.getBuildJobId()); + } + @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetFinishedBuildJobsForCourse_returnsJobs() throws Exception { @@ -261,7 +298,7 @@ void testGetFinishedBuildJobsForCourse_returnsJobs() throws Exception { buildJobRepository.save(finishedJob2); PageableSearchDTO pageableSearchDTO = pageableSearchUtilService.configureFinishedJobsSearchDTO(); var result = request.getList("/api/courses/" + course.getId() + "/finished-jobs", HttpStatus.OK, FinishedBuildJobDTO.class, - pageableSearchUtilService.searchMapping(pageableSearchDTO)); + pageableSearchUtilService.searchMapping(pageableSearchDTO, "pageable")); assertThat(result).hasSize(1); assertThat(result.getFirst().id()).isEqualTo(finishedJob1.getBuildJobId()); } diff --git a/src/test/java/de/tum/in/www1/artemis/util/PageableSearchUtilService.java b/src/test/java/de/tum/in/www1/artemis/util/PageableSearchUtilService.java index 73f06e0ec394..c8bdf9d77489 100644 --- a/src/test/java/de/tum/in/www1/artemis/util/PageableSearchUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/util/PageableSearchUtilService.java @@ -123,6 +123,20 @@ public PageableSearchDTO configureFinishedJobsSearchDTO() { * @return A LinkedMultiValueMap with parameter names as keys and their corresponding values */ public LinkedMultiValueMap searchMapping(PageableSearchDTO search) { + return searchMapping(search, ""); + } + + /** + * Generates a LinkedMultiValueMap from the given PageableSearchDTO. The map is used for REST calls and maps the parameters to the values. + * Converts a PageableSearchDTO into a LinkedMultiValueMap suitable for use with RESTful API calls. + * This conversion facilitates the transfer of search parameters and their values in a format + * that is acceptable for web requests. The parent key is used to prefix the parameter names. + * + * @param search The PageableSearchDTO containing search parameters and values + * @param parentKey The parent key to use for the search parameters + * @return A LinkedMultiValueMap with parameter names as keys and their corresponding values + */ + public LinkedMultiValueMap searchMapping(PageableSearchDTO search, String parentKey) { final ObjectMapper objectMapper = new ObjectMapper(); try { // Serialize the DTO into a JSON string and then deserialize it into a Map @@ -132,7 +146,14 @@ public LinkedMultiValueMap searchMapping(PageableSearchDTO paramMap = new LinkedMultiValueMap<>(); - params.forEach(paramMap::add); + params.forEach((key, value) -> { + if (parentKey.isBlank()) { + paramMap.add(key, value); + } + else { + paramMap.add(parentKey + "." + key, value); + } + }); return paramMap; } catch (Exception e) { diff --git a/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts b/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts index addbeb0cc909..01729c015f37 100644 --- a/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-queue/build-queue.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; -import { BuildQueueComponent } from 'app/localci/build-queue/build-queue.component'; +import { BuildQueueComponent, FinishedBuildJobFilter, FinishedBuildJobFilterStorageKey } from 'app/localci/build-queue/build-queue.component'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockComponent, MockPipe } from 'ng-mocks'; @@ -15,6 +15,9 @@ import { TriggeredByPushTo } from 'app/entities/repository-info.model'; import { waitForAsync } from '@angular/core/testing'; import { HttpResponse } from '@angular/common/http'; import { SortingOrder } from 'app/shared/table/pageable-table'; +import { LocalStorageService } from 'ngx-webstorage'; +import { MockLocalStorageService } from '../../../helpers/mocks/service/mock-local-storage.service'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; describe('BuildQueueComponent', () => { let component: BuildQueueComponent; @@ -36,6 +39,15 @@ describe('BuildQueueComponent', () => { getFinishedBuildJobs: jest.fn(), }; + const mockLocalStorageService = new MockLocalStorageService(); + mockLocalStorageService.clear = (key?: string) => { + if (key) { + delete mockLocalStorageService.storage[key]; + } else { + mockLocalStorageService.storage = {}; + } + }; + const accountServiceMock = { identity: jest.fn(), getAuthenticationState: jest.fn() }; const testCourseId = 123; @@ -231,19 +243,34 @@ describe('BuildQueueComponent', () => { pageSize: 50, sortedColumn: 'build_completion_date', sortingOrder: SortingOrder.DESCENDING, + searchTerm: '', + }; + + const filterOptionsEmpty = { + buildAgentAddress: undefined, + buildDurationFilterLowerBound: undefined, + buildDurationFilterUpperBound: undefined, + buildStartDateFilterFrom: undefined, + buildStartDateFilterTo: undefined, + status: undefined, + appliedFilters: new Map(), + areDatesValid: true, + areDurationFiltersValid: true, + numberOfAppliedFilters: 0, }; beforeEach(waitForAsync(() => { mockActivatedRoute = { params: of({ courseId: testCourseId }) }; TestBed.configureTestingModule({ - imports: [ArtemisTestModule, NgxDatatableModule], + imports: [ArtemisTestModule, NgxDatatableModule, ArtemisSharedComponentModule], declarations: [BuildQueueComponent, MockPipe(ArtemisTranslatePipe), MockComponent(DataTableComponent)], providers: [ { provide: BuildQueueService, useValue: mockBuildQueueService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: AccountService, useValue: accountServiceMock }, { provide: DataTableComponent, useClass: DataTableComponent }, + { provide: LocalStorageService, useValue: mockLocalStorageService }, ], }).compileComponents(); })); @@ -308,7 +335,7 @@ describe('BuildQueueComponent', () => { // Expectations: The service methods are called with the test course ID expect(mockBuildQueueService.getQueuedBuildJobsByCourseId).toHaveBeenCalledWith(testCourseId); expect(mockBuildQueueService.getRunningBuildJobsByCourseId).toHaveBeenCalledWith(testCourseId); - expect(mockBuildQueueService.getFinishedBuildJobsByCourseId).toHaveBeenCalledWith(testCourseId, request); + expect(mockBuildQueueService.getFinishedBuildJobsByCourseId).toHaveBeenCalledWith(testCourseId, request, filterOptionsEmpty); // Expectations: The component's properties are set with the mock data expect(component.queuedBuildJobs).toEqual(mockQueuedJobs); @@ -330,8 +357,10 @@ describe('BuildQueueComponent', () => { // Mock BuildQueueService to return mock data mockBuildQueueService.getRunningBuildJobs.mockReturnValue(of(mockRunningJobs)); - // Initialize the component + // Initialize the component and update the build job duration component.ngOnInit(); + component.updateBuildJobDuration(); // This method is called in ngOnInit in interval callback, but we call it to add coverage + // Expectations: The build job duration is calculated and set for each running build job for (const runningBuildJob of component.runningBuildJobs) { const { buildDuration, buildCompletionDate, buildStartDate } = runningBuildJob.jobTimingInfo!; @@ -455,7 +484,7 @@ describe('BuildQueueComponent', () => { component.ngOnInit(); - expect(mockBuildQueueService.getFinishedBuildJobs).toHaveBeenCalledWith(request); + expect(mockBuildQueueService.getFinishedBuildJobs).toHaveBeenCalledWith(request, filterOptionsEmpty); expect(component.finishedBuildJobs).toEqual(mockFinishedJobs); }); @@ -467,15 +496,38 @@ describe('BuildQueueComponent', () => { component.ngOnInit(); - expect(mockBuildQueueService.getFinishedBuildJobsByCourseId).toHaveBeenCalledWith(testCourseId, request); + expect(mockBuildQueueService.getFinishedBuildJobsByCourseId).toHaveBeenCalledWith(testCourseId, request, filterOptionsEmpty); expect(component.finishedBuildJobs).toEqual(mockFinishedJobs); }); + it('should return correct build agent addresses', () => { + component.finishedBuildJobs = mockFinishedJobs; + expect(component.buildAgentAddresses).toEqual(['agent5', 'agent6']); + }); + + it('should trigger refresh on search term change', async () => { + mockActivatedRoute.paramMap = of(new Map([])); + + mockBuildQueueService.getQueuedBuildJobs.mockReturnValue(of(mockQueuedJobs)); + mockBuildQueueService.getRunningBuildJobs.mockReturnValue(of(mockRunningJobs)); + mockBuildQueueService.getFinishedBuildJobs.mockReturnValue(of(mockFinishedJobsResponse)); + + component.ngOnInit(); + component.searchTerm = 'search'; + component.triggerLoadFinishedJobs(); + + const requestWithSearchTerm = { ...request }; + requestWithSearchTerm.searchTerm = 'search'; + // Wait for the debounce time to pass + await new Promise((resolve) => setTimeout(resolve, 110)); + expect(mockBuildQueueService.getFinishedBuildJobs).toHaveBeenNthCalledWith(2, requestWithSearchTerm, filterOptionsEmpty); + }); + it('should set build job duration', () => { // Mock ActivatedRoute to return no course ID mockActivatedRoute.paramMap = of(new Map([])); - mockBuildQueueService.getFinishedBuildJobs.mockReturnValue(of(mockFinishedJobs)); + mockBuildQueueService.getFinishedBuildJobs.mockReturnValue(of(mockFinishedJobsResponse)); component.ngOnInit(); @@ -486,4 +538,89 @@ describe('BuildQueueComponent', () => { } } }); + + it('should return correct number of filters applied', () => { + component.finishedBuildJobFilter = new FinishedBuildJobFilter(); + component.finishedBuildJobFilter.buildAgentAddress = 'agent1'; + component.filterBuildAgentAddressChanged(); + component.finishedBuildJobFilter.buildDurationFilterLowerBound = 1; + component.finishedBuildJobFilter.buildDurationFilterUpperBound = 2; + component.filterDurationChanged(); + component.finishedBuildJobFilter.buildStartDateFilterFrom = dayjs('2023-01-01'); + component.finishedBuildJobFilter.buildStartDateFilterTo = dayjs('2023-01-02'); + component.filterDateChanged(); + component.toggleBuildStatusFilter('SUCCESSFUL'); + + expect(component.finishedBuildJobFilter.numberOfAppliedFilters).toBe(6); + + component.finishedBuildJobFilter.buildAgentAddress = undefined; + component.filterBuildAgentAddressChanged(); + component.finishedBuildJobFilter.buildDurationFilterLowerBound = undefined; + component.filterDurationChanged(); + + expect(component.finishedBuildJobFilter.numberOfAppliedFilters).toBe(4); + }); + + it('should save filter in local storage', () => { + component.finishedBuildJobFilter = new FinishedBuildJobFilter(); + component.finishedBuildJobFilter.buildAgentAddress = 'agent1'; + component.finishedBuildJobFilter.buildDurationFilterLowerBound = 1; + component.finishedBuildJobFilter.buildDurationFilterUpperBound = 2; + component.finishedBuildJobFilter.buildStartDateFilterFrom = dayjs('2023-01-01'); + component.finishedBuildJobFilter.buildStartDateFilterTo = dayjs('2023-01-02'); + component.finishedBuildJobFilter.status = 'SUCCESSFUL'; + + component.filterDurationChanged(); + component.filterDateChanged(); + component.filterBuildAgentAddressChanged(); + component.toggleBuildStatusFilter('SUCCESSFUL'); + + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildAgentAddress)).toBe('agent1'); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildDurationFilterLowerBound)).toBe(1); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildDurationFilterUpperBound)).toBe(2); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildStartDateFilterFrom)).toEqual(dayjs('2023-01-01')); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildStartDateFilterTo)).toEqual(dayjs('2023-01-02')); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.status)).toBe('SUCCESSFUL'); + + component.finishedBuildJobFilter = new FinishedBuildJobFilter(); + + component.filterDurationChanged(); + component.filterDateChanged(); + component.filterBuildAgentAddressChanged(); + component.toggleBuildStatusFilter(); + + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildAgentAddress)).toBeUndefined(); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildDurationFilterLowerBound)).toBeUndefined(); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildDurationFilterUpperBound)).toBeUndefined(); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildStartDateFilterFrom)).toBeUndefined(); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.buildStartDateFilterTo)).toBeUndefined(); + expect(mockLocalStorageService.retrieve(FinishedBuildJobFilterStorageKey.status)).toBeUndefined(); + }); + + it('should validate correctly', () => { + component.finishedBuildJobFilter = new FinishedBuildJobFilter(); + component.finishedBuildJobFilter.buildDurationFilterLowerBound = 1; + component.finishedBuildJobFilter.buildStartDateFilterFrom = dayjs('2023-01-01'); + component.filterDurationChanged(); + component.filterDateChanged(); + + expect(component.finishedBuildJobFilter.areDatesValid).toBeTruthy(); + expect(component.finishedBuildJobFilter.areDurationFiltersValid).toBeTruthy(); + + component.finishedBuildJobFilter.buildDurationFilterUpperBound = 2; + component.finishedBuildJobFilter.buildStartDateFilterTo = dayjs('2023-01-02'); + component.filterDurationChanged(); + component.filterDateChanged(); + + expect(component.finishedBuildJobFilter.areDatesValid).toBeTruthy(); + expect(component.finishedBuildJobFilter.areDurationFiltersValid).toBeTruthy(); + + component.finishedBuildJobFilter.buildDurationFilterLowerBound = 3; + component.finishedBuildJobFilter.buildStartDateFilterFrom = dayjs('2023-01-03'); + component.filterDurationChanged(); + component.filterDateChanged(); + + expect(component.finishedBuildJobFilter.areDatesValid).toBeFalsy(); + expect(component.finishedBuildJobFilter.areDurationFiltersValid).toBeFalsy(); + }); }); diff --git a/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts b/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts index 24bbf9a3de70..cb5ee22cada5 100644 --- a/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts +++ b/src/test/javascript/spec/component/localci/build-queue/build-queue.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpClientTestingModule, HttpTestingController, TestRequest } from '@angular/common/http/testing'; import { Router } from '@angular/router'; import { MockRouter } from '../../../helpers/mocks/mock-router'; import { MockSyncStorage } from '../../../helpers/mocks/service/mock-sync-storage.service'; @@ -13,6 +13,7 @@ import dayjs from 'dayjs/esm'; import { RepositoryInfo, TriggeredByPushTo } from 'app/entities/repository-info.model'; import { JobTimingInfo } from 'app/entities/job-timing-info.model'; import { BuildConfig } from 'app/entities/build-config.model'; +import { FinishedBuildJobFilter } from 'app/localci/build-queue/build-queue.component'; describe('BuildQueueService', () => { let service: BuildQueueService; @@ -22,6 +23,23 @@ describe('BuildQueueService', () => { let jobTimingInfo: JobTimingInfo; let buildConfig: BuildConfig; + const filterOptions = new FinishedBuildJobFilter(); + filterOptions.buildAgentAddress = '[127.0.0.1]:5701'; + filterOptions.buildDurationFilterLowerBound = 1; + filterOptions.buildDurationFilterUpperBound = 10; + filterOptions.buildStartDateFilterFrom = dayjs('2024-01-01'); + filterOptions.buildStartDateFilterTo = dayjs('2024-01-02'); + filterOptions.status = 'SUCCESSFUL'; + + const expectFilterParams = (req: TestRequest, filterOptions: FinishedBuildJobFilter) => { + expect(req.request.params.get('buildAgentAddress')).toBe(filterOptions.buildAgentAddress); + expect(req.request.params.get('buildDurationLower')).toBe(filterOptions.buildDurationFilterLowerBound?.toString()); + expect(req.request.params.get('buildDurationUpper')).toBe(filterOptions.buildDurationFilterUpperBound?.toString()); + expect(req.request.params.get('startDate')).toBe(filterOptions.buildStartDateFilterFrom?.toISOString()); + expect(req.request.params.get('endDate')).toBe(filterOptions.buildStartDateFilterTo?.toISOString()); + expect(req.request.params.get('buildStatus')).toBe(filterOptions.status); + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], @@ -443,6 +461,19 @@ describe('BuildQueueService', () => { req.flush(expectedResponse); }); + it('should return filtered finished build jobs', () => { + const expectedResponse = [elem1]; + + service.getFinishedBuildJobs(undefined, filterOptions).subscribe((data) => { + expect(data).toEqual(expectedResponse); + }); + + const req = httpMock.expectOne((r) => r.url === `${service.adminResourceUrl}/finished-jobs`); + expect(req.request.method).toBe('GET'); + expectFilterParams(req, filterOptions); + req.flush(expectedResponse); + }); + it('should handle errors when getting all finished build jobs', fakeAsync(() => { let errorOccurred = false; @@ -478,6 +509,20 @@ describe('BuildQueueService', () => { req.flush(expectedResponse); }); + it('should return filtered finished build jobs for a specific course', () => { + const courseId = 1; + const expectedResponse = [elem1]; + + service.getFinishedBuildJobsByCourseId(courseId, undefined, filterOptions).subscribe((data) => { + expect(data).toEqual(expectedResponse); + }); + + const req = httpMock.expectOne((r) => r.url === `${service.resourceUrl}/courses/${courseId}/finished-jobs`); + expect(req.request.method).toBe('GET'); + expectFilterParams(req, filterOptions); + req.flush(expectedResponse); + }); + it('should handle errors when getting all finished build jobs for a specific course', fakeAsync(() => { const courseId = 1;