-
+
{{ finishedBuildJob.buildAgentAddress }}
@@ -742,7 +742,7 @@
@@ -879,3 +879,44 @@
+
+
+
+
+
+
+ @for (logEntry of rawBuildLogs; track logEntry) {
+
+ {{ logEntry.time | artemisDate: 'long' : true : undefined : false : true }} |
+
+ @for (line of logEntry.logLines; track line) {
+ {{ line }}
+
+ }
+ |
+
+ } @empty {
+
+
+
+ |
+
+ }
+
+
+
+
+
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 b67b0520fbb5..1989ed2fb6d9 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
@@ -34,3 +34,21 @@
.finish-jobs-column-strings {
max-width: 180px;
}
+
+.build-output {
+ height: inherit;
+ &__entry {
+ &-date {
+ width: 200px;
+ margin-right: 10px;
+ color: var(--secondary);
+ font-weight: normal;
+ float: left;
+ clear: left;
+ }
+ &-text {
+ margin-bottom: 0;
+ color: var(--body-color);
+ }
+ }
+}
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 79ca4931786c..45b337ba1bd1 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
@@ -19,6 +19,7 @@ import { NgbModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import { LocalStorageService } from 'ngx-webstorage';
import { Observable, OperatorFunction, Subject, Subscription, merge } from 'rxjs';
import { UI_RELOAD_TIME } from 'app/shared/constants/exercise-exam-constants';
+import { BuildLogEntry, BuildLogLines } from 'app/entities/programming/build-log.model';
export class FinishedBuildJobFilter {
status?: string = undefined;
@@ -159,6 +160,9 @@ export class BuildQueueComponent implements OnInit, OnDestroy {
searchSubscription: Subscription;
searchTerm?: string = undefined;
+ rawBuildLogs: BuildLogLines[] = [];
+ displayedBuildJobId?: string;
+
constructor(
private route: ActivatedRoute,
private websocketService: JhiWebsocketService,
@@ -386,11 +390,33 @@ export class BuildQueueComponent implements OnInit, OnDestroy {
/**
* View the build logs of a specific build job
- * @param resultId The id of the build job
+ * @param modal The modal to open
+ * @param buildJobId The id of the build job
+ */
+ viewBuildLogs(modal: any, buildJobId: string | undefined): void {
+ if (buildJobId) {
+ this.openModal(modal, true);
+ this.displayedBuildJobId = buildJobId;
+ this.buildQueueService.getBuildJobLogs(buildJobId).subscribe({
+ next: (buildLogs: BuildLogEntry[]) => {
+ this.rawBuildLogs = buildLogs.map((entry) => {
+ const logLines = entry.log ? entry.log.split('\n') : [];
+ return { time: entry.time, logLines: logLines };
+ });
+ },
+ error: (res: HttpErrorResponse) => {
+ onError(this.alertService, res, false);
+ },
+ });
+ }
+ }
+
+ /**
+ * Download the build logs of a specific build job
*/
- viewBuildLogs(resultId: string | undefined): void {
- if (resultId) {
- const url = `/api/build-log/${resultId}`;
+ downloadBuildLogs(): void {
+ if (this.displayedBuildJobId) {
+ const url = `/api/build-log/${this.displayedBuildJobId}`;
window.open(url, '_blank');
}
}
@@ -443,8 +469,8 @@ export class BuildQueueComponent implements OnInit, OnDestroy {
/**
* Opens the modal.
*/
- open(content: any) {
- this.modalService.open(content);
+ openModal(modal: any, fullscreen?: boolean, size?: 'sm' | 'lg' | 'xl', scrollable = true, keyboard = true) {
+ this.modalService.open(modal, { size, keyboard, scrollable, fullscreen });
}
/**
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 fdc9bec66e2f..a004acb9eaa8 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
@@ -7,6 +7,7 @@ import { BuildJob, BuildJobStatistics, FinishedBuildJob, SpanType } from 'app/en
import { createNestedRequestOption } from 'app/shared/util/request.util';
import { HttpResponse } from '@angular/common/http';
import { FinishedBuildJobFilter } from 'app/localci/build-queue/build-queue.component';
+import { BuildLogEntry } from 'app/entities/programming/build-log.model';
@Injectable({ providedIn: 'root' })
export class BuildQueueService {
@@ -188,4 +189,16 @@ export class BuildQueueService {
}),
);
}
+
+ /**
+ * Get all build jobs of a course in the queue
+ * @param buildJobId
+ */
+ getBuildJobLogs(buildJobId: string): Observable {
+ return this.http.get(`${this.resourceUrl}/build-log/${buildJobId}/entries`).pipe(
+ catchError(() => {
+ return throwError(() => new Error('artemisApp.buildQueue.logs.errorFetchingLogs'));
+ }),
+ );
+ }
}
diff --git a/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts b/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts
index 36d88d6474f7..cd3749e9ede0 100644
--- a/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts
+++ b/src/main/webapp/app/shared/pipes/artemis-date.pipe.ts
@@ -36,6 +36,7 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy {
private showTime = true;
private showSeconds = false;
private showWeekday = false;
+ private showMilliSeconds = false;
private static mobileDeviceSize = 768;
constructor(private translateService: TranslateService) {}
@@ -47,8 +48,9 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy {
* @param seconds Should seconds be displayed? Defaults to false.
* @param timeZone Explicit time zone that should be used instead of the local time zone.
* @param weekday Should the weekday be displayed? Defaults to false.
+ * @param milliSeconds Should milliseconds be displayed? Defaults to false.
*/
- transform(dateTime: DateType, format: DateFormat = 'long', seconds = false, timeZone: string | undefined = undefined, weekday = false): string {
+ transform(dateTime: DateType, format: DateFormat = 'long', seconds = false, timeZone: string | undefined = undefined, weekday = false, milliSeconds = false): string {
// Return empty string if given dateTime equals null or is not convertible to dayjs.
if (!dateTime || !dayjs(dateTime).isValid()) {
return '';
@@ -59,6 +61,7 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy {
this.showTime = format !== 'short-date' && format !== 'long-date';
this.showSeconds = seconds;
this.showWeekday = weekday;
+ this.showMilliSeconds = milliSeconds;
// Evaluate the format length based on the current window width.
this.formatLengthBasedOnWindowWidth(window.innerWidth);
@@ -88,12 +91,12 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy {
* @param format Format of the localized date time. Defaults to 'long'.
* @param seconds Should seconds be displayed? Defaults to false.
*/
- static format(locale = 'en', format: DateFormat = 'long', seconds = false): string {
+ static format(locale = 'en', format: DateFormat = 'long', seconds = false, showMilliSeconds = false): string {
const long = format === 'long' || format === 'long-date';
const showDate = format !== 'time';
const showTime = format !== 'short-date' && format !== 'long-date';
const dateFormat = ArtemisDatePipe.dateFormat(long, showDate, locale);
- const timeFormat = ArtemisDatePipe.timeFormat(showTime, seconds);
+ const timeFormat = ArtemisDatePipe.timeFormat(showTime, seconds, showMilliSeconds);
return dateFormat + (dateFormat && timeFormat ? ' ' : '') + timeFormat;
}
@@ -123,7 +126,7 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy {
private format(): string {
const dateFormat = ArtemisDatePipe.dateFormat(this.long, this.showDate, this.locale);
- const timeFormat = ArtemisDatePipe.timeFormat(this.showTime, this.showSeconds);
+ const timeFormat = ArtemisDatePipe.timeFormat(this.showTime, this.showSeconds, this.showMilliSeconds);
return dateFormat + (dateFormat && timeFormat ? ' ' : '') + timeFormat;
}
@@ -144,12 +147,14 @@ export class ArtemisDatePipe implements PipeTransform, OnDestroy {
return format;
}
- private static timeFormat(showTime: boolean, showSeconds: boolean): string {
+ private static timeFormat(showTime: boolean, showSeconds: boolean, showMilliSeconds: boolean): string {
if (!showTime) {
return '';
}
let format = 'HH:mm';
- if (showSeconds) {
+ if (showMilliSeconds) {
+ format = 'HH:mm:ss.SSS';
+ } else if (showSeconds) {
format = 'HH:mm:ss';
}
return format;
diff --git a/src/main/webapp/app/shared/util/global.utils.ts b/src/main/webapp/app/shared/util/global.utils.ts
index 78948d8c3340..3261878a8ef4 100644
--- a/src/main/webapp/app/shared/util/global.utils.ts
+++ b/src/main/webapp/app/shared/util/global.utils.ts
@@ -77,8 +77,9 @@ export const matchRegexWithLineNumbers = (multiLineText: string, regex: RegExp):
* Use alert service to show the error message from the error response
* @param alertService the service used to show the exception messages to the user
* @param error the error response that's status is used to determine the error message
+ * @param disableTranslation whether the error message should be translated
*/
-export const onError = (alertService: AlertService, error: HttpErrorResponse) => {
+export const onError = (alertService: AlertService, error: HttpErrorResponse, disableTranslation: boolean = true) => {
switch (error.status) {
case 400:
alertService.error('error.http.400');
@@ -100,7 +101,7 @@ export const onError = (alertService: AlertService, error: HttpErrorResponse) =>
alertService.addAlert({
type: AlertType.DANGER,
message: error.message,
- disableTranslation: true,
+ disableTranslation: disableTranslation,
});
break;
}
diff --git a/src/main/webapp/i18n/de/buildQueue.json b/src/main/webapp/i18n/de/buildQueue.json
index faa60fc5c108..7d56dc944003 100644
--- a/src/main/webapp/i18n/de/buildQueue.json
+++ b/src/main/webapp/i18n/de/buildQueue.json
@@ -74,6 +74,12 @@
"daySpan": "1 Tag",
"weekSpan": "7 Tage",
"monthSpan": "30 Tage"
+ },
+ "logs": {
+ "title": "Build Logs für Job",
+ "download": "Herunterladen",
+ "noLogs": "Keine Logs verfügbar",
+ "errorFetchingLogs": "Fehler beim Abrufen von Build-Log-Einträgen für Build-Job"
}
}
}
diff --git a/src/main/webapp/i18n/en/buildQueue.json b/src/main/webapp/i18n/en/buildQueue.json
index aa065f3617df..63a258d407d1 100644
--- a/src/main/webapp/i18n/en/buildQueue.json
+++ b/src/main/webapp/i18n/en/buildQueue.json
@@ -74,6 +74,12 @@
"daySpan": "1 day",
"weekSpan": "7 days",
"monthSpan": "30 days"
+ },
+ "logs": {
+ "title": "Build Logs for Job",
+ "download": "Download",
+ "noLogs": "No logs available",
+ "errorFetchingLogs": "Failed to get build log entries for build job"
}
}
}
diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java
index 4a5219c6ba7b..eb03d08f5fd8 100644
--- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java
+++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java
@@ -11,6 +11,7 @@
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
@@ -346,6 +347,30 @@ void testGetBuildLogsForResult() throws Exception {
}
}
+ @Test
+ @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR")
+ void testGetBuildLogsEntriesForResult() throws Exception {
+ try {
+ buildJobRepository.save(finishedJobForLogs);
+ BuildLogDTO buildLogEntry = new BuildLogDTO(ZonedDateTime.now(), "Dummy log");
+ buildLogEntryService.saveBuildLogsToFile(List.of(buildLogEntry), "6", programmingExercise);
+ var response = request.get("/api/build-log/6/entries", HttpStatus.OK, List.class);
+
+ LinkedHashMap, ?> responseMap = ((LinkedHashMap, ?>) response.getFirst());
+ String log = responseMap.get("log").toString();
+ ZonedDateTime time = ZonedDateTime.parse(responseMap.get("time").toString());
+ assertThat(response).hasSize(1);
+ assertThat(buildLogEntry.log()).isEqualTo(log);
+ assertThat(buildLogEntry.time()).isEqualTo(time);
+
+ }
+ finally {
+ Path buildLogFile = Path.of("build-logs").resolve(programmingExercise.getCourseViaExerciseGroupOrCourseMember().getShortName())
+ .resolve(programmingExercise.getShortName()).resolve("6.log");
+ Files.deleteIfExists(buildLogFile);
+ }
+ }
+
@Test
@WithMockUser(username = TEST_PREFIX + "admin", roles = "ADMIN")
void testGetBuildJobStatistics() throws Exception {
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 b22a67ee3f15..2e51313e967c 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
@@ -19,6 +19,7 @@ import { LocalStorageService } from 'ngx-webstorage';
import { MockLocalStorageService } from '../../../helpers/mocks/service/mock-local-storage.service';
import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module';
import { PieChartModule } from '@swimlane/ngx-charts';
+import { BuildLogEntry, BuildLogLines } from '../../../../../../main/webapp/app/entities/programming/build-log.model';
describe('BuildQueueComponent', () => {
let component: BuildQueueComponent;
@@ -40,6 +41,7 @@ describe('BuildQueueComponent', () => {
getFinishedBuildJobs: jest.fn(),
getBuildJobStatistics: jest.fn(),
getBuildJobStatisticsForCourse: jest.fn(),
+ getBuildJobLogs: jest.fn(),
};
const mockLocalStorageService = new MockLocalStorageService();
@@ -267,6 +269,17 @@ describe('BuildQueueComponent', () => {
numberOfAppliedFilters: 0,
};
+ const buildLogEntries: BuildLogEntry[] = [
+ {
+ time: dayjs('2024-01-01'),
+ log: 'log1',
+ },
+ {
+ time: dayjs('2024-01-02'),
+ log: 'log2',
+ },
+ ];
+
beforeEach(waitForAsync(() => {
mockActivatedRoute = { params: of({ courseId: testCourseId }) };
@@ -661,4 +674,24 @@ describe('BuildQueueComponent', () => {
expect(component.finishedBuildJobFilter.areDatesValid).toBeFalsy();
expect(component.finishedBuildJobFilter.areDurationFiltersValid).toBeFalsy();
});
+
+ it('should download build logs', () => {
+ const buildJobId = '1';
+ jest.spyOn(window, 'open').mockImplementation();
+
+ mockBuildQueueService.getBuildJobLogs = jest.fn().mockReturnValue(of(buildLogEntries));
+
+ const buildLogsMultiLines: BuildLogLines[] = buildLogEntries.map((entry) => {
+ return { time: entry.time, logLines: entry.log.split('\n') };
+ });
+
+ component.viewBuildLogs(undefined, buildJobId);
+
+ expect(mockBuildQueueService.getBuildJobLogs).toHaveBeenCalledWith(buildJobId);
+ expect(component.rawBuildLogs).toEqual(buildLogsMultiLines);
+
+ component.downloadBuildLogs();
+
+ expect(window.open).toHaveBeenCalledWith(`/api/build-log/${component.displayedBuildJobId}`, '_blank');
+ });
});
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 16a2d81d37ed..8f5a9af490f5 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
@@ -15,6 +15,7 @@ import { JobTimingInfo } from 'app/entities/job-timing-info.model';
import { BuildConfig } from 'app/entities/programming/build-config.model';
import { FinishedBuildJobFilter } from 'app/localci/build-queue/build-queue.component';
import { provideHttpClient } from '@angular/common/http';
+import { BuildLogEntry } from '../../../../../../main/webapp/app/entities/programming/build-log.model';
describe('BuildQueueService', () => {
let service: BuildQueueService;
@@ -32,6 +33,17 @@ describe('BuildQueueService', () => {
filterOptions.buildStartDateFilterTo = dayjs('2024-01-02');
filterOptions.status = 'SUCCESSFUL';
+ const buildLogEntries: BuildLogEntry[] = [
+ {
+ time: dayjs('2024-01-01'),
+ log: 'log1',
+ },
+ {
+ time: dayjs('2024-01-02'),
+ log: 'log2',
+ },
+ ];
+
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());
@@ -589,6 +601,41 @@ describe('BuildQueueService', () => {
expect(errorOccurred).toBeTrue();
}));
+ it('should return build log entries for a specific build job', () => {
+ const buildJobId = '1';
+ const expectedResponse = buildLogEntries;
+
+ service.getBuildJobLogs(buildJobId).subscribe((data) => {
+ expect(data).toEqual(expectedResponse);
+ });
+
+ const req = httpMock.expectOne(`${service.resourceUrl}/build-log/${buildJobId}/entries`);
+ expect(req.request.method).toBe('GET');
+ req.flush(expectedResponse);
+ });
+
+ it('should handle errors when getting build log entries for a specific build job', fakeAsync(() => {
+ const buildJobId = '1';
+
+ let errorOccurred = false;
+
+ service.getBuildJobLogs(buildJobId).subscribe({
+ error: (err) => {
+ expect(err.message).toBe('artemisApp.buildQueue.logs.errorFetchingLogs');
+ errorOccurred = true;
+ },
+ });
+
+ const req = httpMock.expectOne(`${service.resourceUrl}/build-log/${buildJobId}/entries`);
+ expect(req.request.method).toBe('GET');
+
+ req.flush(null, { status: 500, statusText: 'Internal Server Error' });
+
+ tick();
+
+ expect(errorOccurred).toBeTrue();
+ }));
+
afterEach(() => {
httpMock.verify(); // Verify that there are no outstanding requests.
});
|