Skip to content

Commit

Permalink
Integrated code lifecycle: View build logs in the browser (#9990)
Browse files Browse the repository at this point in the history
  • Loading branch information
BBesrour authored Jan 5, 2025
1 parent 6b6e23e commit 99c5fb9
Show file tree
Hide file tree
Showing 14 changed files with 319 additions and 17 deletions.
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 @@ -489,4 +492,53 @@ 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) {
// split into timestamp and log message
int logMessageParts = 2;
String[] parts = line.split("\t", logMessageParts);
if (parts.length == logMessageParts) {
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<>();
}
}

}
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) {
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[];
};

export type BuildLogEntry = {
time: any;
log: string;
Expand Down
47 changes: 44 additions & 3 deletions src/main/webapp/app/localci/build-queue/build-queue.component.html
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,44 @@ <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>
} @empty {
<tr class="build-output__entry">
<td class="build-output__entry-text">
<span jhiTranslate="artemisApp.buildQueue.logs.noLogs"></span>
</td>
</tr>
}
</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>
18 changes: 18 additions & 0 deletions src/main/webapp/app/localci/build-queue/build-queue.component.scss
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);
}
}
}
38 changes: 32 additions & 6 deletions src/main/webapp/app/localci/build-queue/build-queue.component.ts
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, 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');
}
}
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(() => {
return throwError(() => new Error('artemisApp.buildQueue.logs.errorFetchingLogs'));
}),
);
}
}
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
5 changes: 3 additions & 2 deletions src/main/webapp/app/shared/util/global.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -100,7 +101,7 @@ export const onError = (alertService: AlertService, error: HttpErrorResponse) =>
alertService.addAlert({
type: AlertType.DANGER,
message: error.message,
disableTranslation: true,
disableTranslation: disableTranslation,
});
break;
}
Expand Down
Loading

0 comments on commit 99c5fb9

Please sign in to comment.