Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrated code lifecycle: View build logs in the browser #9990

Merged
merged 14 commits into from
Jan 5, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -491,4 +494,51 @@ public boolean buildJobHasLogFile(String buildJobId, ProgrammingExercise program
return Files.exists(logPath);
}

/**
* Parses the build log entries from a given file and returns them as a list of {@link BuildLogDTO} objects.
*
* <p>
* The method reads the file line by line and splits each line into a timestamp and a log message.
* The timestamp is expected to be separated from the log message by a tab character.
* If the timestamp cannot be parsed, the log message is appended to the previous entry.
* </p>
*
* @param buildLog The {@link FileSystemResource} representing the build log file.
* @return A list of {@link BuildLogDTO} objects containing the parsed build log entries.
*/
public List<BuildLogDTO> parseBuildLogEntries(FileSystemResource buildLog) {
try {
List<BuildLogDTO> buildLogEntries = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(buildLog.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split("\t", 2);
if (parts.length == 2) {
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
try {
ZonedDateTime time = ZonedDateTime.parse(parts[0]);
buildLogEntries.add(new BuildLogDTO(time, parts[1]));
}
catch (DateTimeParseException e) {
// If the time cannot be parsed, append the line to the last entry
if (!buildLogEntries.isEmpty()) {
BuildLogDTO lastEntry = buildLogEntries.getLast();
buildLogEntries.set(buildLogEntries.size() - 1, new BuildLogDTO(lastEntry.time(), lastEntry.log() + "\n\t" + line));
}
}
}
else {
// If the line does not contain a tab, add it to in a new entry
BuildLogDTO lastEntry = buildLogEntries.getLast();
buildLogEntries.add(new BuildLogDTO(lastEntry.time(), line));
}
}
}
return buildLogEntries;
}
catch (IOException e) {
log.error("Error occurred while trying to parse build log entries", e);
return new ArrayList<>();
}
}
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALCI;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
Expand All @@ -16,6 +18,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import de.tum.cit.aet.artemis.buildagent.dto.BuildLogDTO;
import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor;
import de.tum.cit.aet.artemis.programming.service.BuildLogEntryService;

Expand Down Expand Up @@ -52,4 +55,25 @@ public ResponseEntity<Resource> getBuildLogForBuildJob(@PathVariable String buil
responseHeaders.setContentDispositionFormData("attachment", "build-" + buildJobId + ".log");
return new ResponseEntity<>(buildLog, responseHeaders, HttpStatus.OK);
}

/**
* GET /build-log/{buildJobId}/entries : get the build log entries for a given result
*
* @param buildJobId the id of the build job for which to retrieve the build log entries
* @return the ResponseEntity with status 200 (OK) and the build log entries in the body, or with status 404 (Not Found) if the build log entries could not be found
*/
@GetMapping("build-log/{buildJobId}/entries")
@EnforceAtLeastEditor
public ResponseEntity<List<BuildLogDTO>> getBuildLogEntriesForBuildJob(@PathVariable String buildJobId) {
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
FileSystemResource buildLog = buildLogEntryService.retrieveBuildLogsFromFileForBuildJob(buildJobId);
if (buildLog == null) {
return ResponseEntity.notFound().build();
}

var buildLogEntries = buildLogEntryService.parseBuildLogEntries(buildLog);
if (buildLogEntries == null || buildLogEntries.isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(buildLogEntries);
}
}
5 changes: 5 additions & 0 deletions src/main/webapp/app/entities/programming/build-log.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export enum BuildLogType {
OTHER = 'OTHER',
}

export type BuildLogLines = {
time: any;
logLines: string[];
};
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

export type BuildLogEntry = {
time: any;
log: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ <h3 id="build-queue-finished-heading" jhiTranslate="artemisApp.buildQueue.finish
<div>
<button
class="btn"
(click)="this.open(content)"
(click)="this.openModal(filterModal)"
[ngClass]="{ 'btn-secondary': !finishedBuildJobFilter.numberOfAppliedFilters, 'btn-success': !!finishedBuildJobFilter.numberOfAppliedFilters }"
>
<fa-icon [icon]="faFilter" />
Expand Down Expand Up @@ -617,7 +617,7 @@ <h3 id="build-queue-finished-heading" jhiTranslate="artemisApp.buildQueue.finish
}
</td>
<td class="finish-jobs-column-strings">
<a class="detail-link" (click)="viewBuildLogs(finishedBuildJob.id)" jhiTranslate="artemisApp.result.buildLogs.viewLogs"></a>
<a class="detail-link" (click)="viewBuildLogs(buildLogsModal, finishedBuildJob.id)" jhiTranslate="artemisApp.result.buildLogs.viewLogs"></a>
</td>
<td class="finish-jobs-column-strings">
<span>{{ finishedBuildJob.buildAgentAddress }}</span>
Expand Down Expand Up @@ -742,7 +742,7 @@ <h3 id="build-queue-finished-heading" jhiTranslate="artemisApp.buildQueue.finish
}
</div>
<!-- Modal -->
<ng-template #content let-modal>
<ng-template #filterModal let-modal>
<div class="modal-header">
<h5 class="modal-title">
<span jhiTranslate="artemisApp.buildQueue.filter.title"></span>
Expand Down Expand Up @@ -879,3 +879,38 @@ <h5 class="my-0">
</button>
</div>
</ng-template>

<ng-template #buildLogsModal let-modal>
<div class="modal-header">
<h5 class="modal-title">
<span jhiTranslate="artemisApp.buildQueue.logs.title"></span>
<span> {{ this.displayedBuildJobId }}</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss()"></button>
</div>
<div class="modal-body">
<table class="table table-borderless">
<tbody>
@for (logEntry of rawBuildLogs; track logEntry) {
<tr class="build-output__entry">
<td class="build-output__entry-date">{{ logEntry.time | artemisDate: 'long' : true : undefined : false : true }}</td>
<td class="build-output__entry-text">
@for (line of logEntry.logLines; track line) {
<span>{{ line }}</span>
<br />
}
</td>
</tr>
}
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="modal.close()">
<span jhiTranslate="artemisApp.buildQueue.filter.close"></span>
</button>
<button class="btn btn-primary" (click)="downloadBuildLogs()">
<span jhiTranslate="artemisApp.buildQueue.logs.download"></span>
</button>
</div>
</ng-template>
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
},
});
}
}
BBesrour marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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');
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
}
}
Expand Down Expand Up @@ -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 });
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/main/webapp/app/localci/build-queue/build-queue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -188,4 +189,16 @@ export class BuildQueueService {
}),
);
}

/**
* Get all build jobs of a course in the queue
* @param buildJobId
*/
getBuildJobLogs(buildJobId: string): Observable<BuildLogEntry[]> {
return this.http.get<BuildLogEntry[]>(`${this.resourceUrl}/build-log/${buildJobId}/entries`).pipe(
catchError((err) => {
return throwError(() => new Error(`Failed to get build log entries for build job ${buildJobId}\n${err.message}`));
BBesrour marked this conversation as resolved.
Show resolved Hide resolved
}),
);
}
}
17 changes: 11 additions & 6 deletions src/main/webapp/app/shared/pipes/artemis-date.pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand All @@ -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 '';
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/main/webapp/i18n/de/buildQueue.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@
"daySpan": "1 Tag",
"weekSpan": "7 Tage",
"monthSpan": "30 Tage"
},
"logs": {
"title": "Build Logs für Job",
"download": "Herunterladen"
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/webapp/i18n/en/buildQueue.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@
"daySpan": "1 day",
"weekSpan": "7 days",
"monthSpan": "30 days"
},
"logs": {
"title": "Build Logs for Job",
"download": "Download"
}
}
}
Expand Down
Loading
Loading