You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/08/28 10:22:02 UTC
(maven) 07/07: Add mvnlog --web mode: local HTTP server for interactive build report viewing
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch feature/mvnlog-web
in repository https://gitbox.apache.org/repos/asf/maven.git
commit a9795c8845cc58e657752a8cfb9ecfa23c1e7ec9
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Fri Aug 28 12:21:43 2026 +0200
Add mvnlog --web mode: local HTTP server for interactive build report viewing
Adds web-based build report viewer to mvnlog (addresses #12645):
- --web flag starts a local HTTP server on loopback (localhost only)
- --port to configure the server port (default 8080, auto-fallback)
- Interactive HTML report viewer with filtering and search
- Path traversal protection with regex + normalize() checks
- Graceful shutdown via CountDownLatch after 30min inactivity
- Auto-opens browser on supported desktops
- Build report opt-out via -Dmaven.build.report.skip=true
- Report directory changed to target/build-reports/ with latest symlink
Also addresses review feedback from PR #12716:
- Remove Google Fonts CDN links, use system font stack
- Use LogEvent API instead of string parsing in BuildReportEventListener
- Use Collections.synchronizedList instead of CopyOnWriteArrayList
- Fix @since 4.0.0 to 4.1.0 in package-info
- Make lastRequestTime an instance field (not static)
- Add mojoFinished(ExecutionEvent, String) to BuildEventListener interface
- Update all implementations and tests for new interface signatures
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../apache/maven/api/cli/mvnlog/LogOptions.java | 16 ++
.../apache/maven/api/cli/mvnlog/package-info.java | 2 +-
.../cling/event/MachineBuildEventListener.java | 7 +-
.../maven/cling/event/RichBuildEventListener.java | 7 +-
.../apache/maven/cling/invoker/LookupInvoker.java | 4 +-
.../cling/invoker/mvnlog/CommonsCliLogOptions.java | 36 +++
.../maven/cling/invoker/mvnlog/LogInvoker.java | 302 ++++++++++++++++++++-
.../apache/maven/cling/invoker/mvnlog/report.html | 25 +-
.../cling/event/MachineBuildEventListenerTest.java | 3 +-
.../cling/event/RichBuildEventListenerTest.java | 22 +-
.../apache/maven/logging/BuildEventListener.java | 1 -
.../maven/logging/BuildReportEventListener.java | 116 ++++----
.../maven/logging/LoggingExecutionListener.java | 8 +-
.../maven/logging/SimpleBuildEventListener.java | 1 -
14 files changed, 476 insertions(+), 74 deletions(-)
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
index 9d7a07c0aa..2049b3f20b 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
@@ -112,4 +112,20 @@ public interface LogOptions extends Options {
* @return an {@link Optional} containing the grep pattern
*/
Optional<String> grep();
+
+ /**
+ * Whether to launch the web-based report viewer instead of terminal output.
+ * When enabled, starts a local HTTP server serving an interactive build report viewer.
+ *
+ * @return an {@link Optional} containing {@code true} if the web viewer should be launched
+ */
+ Optional<Boolean> web();
+
+ /**
+ * The port number for the web server when {@link #web()} is enabled.
+ * If not specified, defaults to port 8080 with automatic fallback to an available port.
+ *
+ * @return an {@link Optional} containing the port number, or empty for automatic selection
+ */
+ Optional<Integer> port();
}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java
index 3fd00c3e4e..26e66a2af6 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java
@@ -22,6 +22,6 @@
*
* @see org.apache.maven.api.cli.Tools#MVNLOG_CMD
* @see org.apache.maven.api.cli.Tools#MVNLOG_NAME
- * @since 4.0.0
+ * @since 4.1.0
*/
package org.apache.maven.api.cli.mvnlog;
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java
index c2c4e4585e..34b1c2fd35 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java
@@ -95,7 +95,7 @@ public void projectLogMessage(String projectId, LogEvent event) {
}
@Override
- public void projectFinished(String projectId) {
+ public void projectFinished(String projectId, String status) {
// Handled by MachineExecutionEventLogger.projectSucceeded/Failed/Skipped()
}
@@ -113,6 +113,11 @@ public void mojoStarted(ExecutionEvent event) {
// Handled by MachineExecutionEventLogger.mojoStarted()
}
+ @Override
+ public void mojoFinished(ExecutionEvent event, String status) {
+ // Handled by MachineExecutionEventLogger.mojoSucceeded/Failed()
+ }
+
@Override
public void finish(int exitCode) throws Exception {
// No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded()
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java
index dc22c07d3b..8946f00dee 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java
@@ -303,7 +303,7 @@ public void projectStarted(String projectId) {
}
@Override
- public void projectFinished(String projectId) {
+ public void projectFinished(String projectId, String status) {
activeProjects.remove(projectId);
completedProjects++;
redraw();
@@ -365,6 +365,11 @@ public void mojoStarted(ExecutionEvent event) {
}
}
+ @Override
+ public void mojoFinished(ExecutionEvent event, String status) {
+ // No-op — mojoStarted updates the display; mojo completion is implicit
+ }
+
@Override
public void executionFailure(String projectId, boolean halted, String exception) {
ProjectState state = activeProjects.get(projectId);
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
index aeb038a573..90288cc2ac 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
@@ -336,13 +336,13 @@ protected BuildEventListener determineBuildEventListener(C context) {
protected BuildEventListener doDetermineBuildEventListener(C context) {
Consumer<String> writer = determineWriter(context);
BuildEventListener delegate = new SimpleBuildEventListener(writer);
- if (context.invokerRequest.command().equals("mvn")) {
+ if (context.invokerRequest.parserRequest().command().equals("mvn")
+ && !Boolean.parseBoolean(System.getProperty("maven.build.report.skip", "false"))) {
return new org.apache.maven.logging.BuildReportEventListener(delegate);
}
return delegate;
}
-
protected final void createTerminal(C context) {
if (context.terminal == null) {
MessageUtils.systemInstall(builder -> doCreateTerminal(context, builder), terminal -> {
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
index a80192bf47..42ea7ae435 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
@@ -125,6 +125,26 @@ public Optional<String> grep() {
return Optional.empty();
}
+ @Override
+ public Optional<Boolean> web() {
+ if (commandLine.hasOption(CLIManager.WEB)) {
+ return Optional.of(Boolean.TRUE);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Integer> port() {
+ if (commandLine.hasOption(CLIManager.PORT)) {
+ try {
+ return Optional.of(Integer.parseInt(commandLine.getOptionValue(CLIManager.PORT)));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ }
+ return Optional.empty();
+ }
+
@Override
public void displayHelp(ParserRequest request, Consumer<String> printStream) {
super.displayHelp(request, printStream);
@@ -146,6 +166,10 @@ public void displayHelp(ParserRequest request, Consumer<String> printStream) {
printStream.accept(" mvnlog --json --module api Output filtered JSON");
printStream.accept(" mvnlog --json | jq '.modules' Pipe to jq for complex queries");
printStream.accept("");
+ printStream.accept("Web viewer:");
+ printStream.accept(" mvnlog --web Launch interactive web report viewer");
+ printStream.accept(" mvnlog --web --port 9090 Use custom port");
+ printStream.accept("");
}
@Override
@@ -164,6 +188,8 @@ protected static class CLIManager extends CommonsCliOptions.CLIManager {
public static final String MOJO = "M";
public static final String LEVEL = "l";
public static final String GREP = "g";
+ public static final String WEB = "w";
+ public static final String PORT = "p";
@Override
protected void prepareOptions(org.apache.commons.cli.Options options) {
@@ -212,6 +238,16 @@ protected void prepareOptions(org.apache.commons.cli.Options options) {
.argName("pattern")
.desc("Search log messages for <pattern> (case-insensitive)")
.get());
+ options.addOption(Option.builder(WEB)
+ .longOpt("web")
+ .desc("Launch web-based interactive report viewer")
+ .get());
+ options.addOption(Option.builder(PORT)
+ .longOpt("port")
+ .hasArg()
+ .argName("port")
+ .desc("Port for the web server (default: 8080)")
+ .get());
}
}
}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
index bc194888fa..177f6b8f95 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
@@ -19,11 +19,28 @@
package org.apache.maven.cling.invoker.mvnlog;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
import org.apache.maven.api.annotations.Nullable;
import org.apache.maven.api.cli.InvokerRequest;
import org.apache.maven.api.cli.mvnlog.LogOptions;
@@ -37,6 +54,12 @@
* This is a lightweight invoker that does NOT set up the DI container,
* Maven settings, or any build infrastructure. It only needs a terminal
* (for colors and width detection) and the parsed CLI options.
+ * <p>
+ * Supports two output modes:
+ * <ul>
+ * <li>Terminal mode (default): renders formatted build report via {@link BuildReportRenderer}</li>
+ * <li>Web mode ({@code --web}): starts a local HTTP server with an interactive report viewer</li>
+ * </ul>
*
* @since 4.1.0
*/
@@ -48,6 +71,20 @@ public class LogInvoker extends LookupInvoker<LogContext> {
private static final String DEFAULT_REPORT_DIR = "target/build-reports";
private static final String DEFAULT_REPORT_FILE = "build-report-latest.json";
+ private static final int DEFAULT_WEB_PORT = 8080;
+ private static final long INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000L;
+
+ /**
+ * Pattern to validate report IDs: only allows simple filenames
+ * (letters, digits, hyphens, dots) to prevent path traversal attacks.
+ */
+ private static final Pattern SAFE_REPORT_ID = Pattern.compile("[a-zA-Z0-9._-]+\\.json");
+
+ /**
+ * Tracks the time of the last HTTP request for the inactivity shutdown timer.
+ * Instance field (not static) so multiple LogInvoker instances don't interfere.
+ */
+ private volatile long lastRequestTime = System.currentTimeMillis();
public LogInvoker(Lookup protoLookup, @Nullable Consumer<LookupContext> contextConsumer) {
super(protoLookup, contextConsumer);
@@ -82,6 +119,13 @@ protected void lookup(LogContext context) throws Exception {
@Override
protected int execute(LogContext context) throws Exception {
LogOptions options = context.options();
+
+ // Check for --web mode
+ if (options != null && options.web().orElse(false)) {
+ return startWebServer(context);
+ }
+
+ // Terminal mode below
Consumer<String> output = line -> {
if (context.writer != null) {
context.writer.accept(line);
@@ -140,7 +184,7 @@ protected int execute(LogContext context) throws Exception {
// If log-level filters are active (--level, --grep), render a log view
// instead of the default summary.
if (filter.hasLogFilters()) {
- java.util.List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
+ List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
renderer.renderLogEvents(events);
return OK;
}
@@ -162,6 +206,232 @@ protected int execute(LogContext context) throws Exception {
return OK;
}
+ // ---- Web server mode ----
+
+ private int startWebServer(LogContext context) {
+ Path reportFile = resolveReportFile(context);
+ if (!Files.isRegularFile(reportFile)) {
+ context.logger.error("Build report not found: " + reportFile);
+ context.logger.error("Run a Maven build first, then use mvnlog --web to view the report.");
+ return BAD_INPUT;
+ }
+
+ Path reportDir = reportFile.getParent();
+ int preferredPort = DEFAULT_WEB_PORT;
+ LogOptions options = context.options();
+ if (options != null) {
+ preferredPort = options.port().orElse(DEFAULT_WEB_PORT);
+ }
+
+ HttpServer server;
+ int actualPort;
+ try {
+ // Bind to loopback only (not 0.0.0.0) to prevent network exposure
+ InetSocketAddress addr = new InetSocketAddress(InetAddress.getLoopbackAddress(), preferredPort);
+ server = HttpServer.create(addr, 0);
+ actualPort = preferredPort;
+ } catch (IOException e) {
+ // Preferred port busy, try a random available port on loopback
+ try (ServerSocket socket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) {
+ actualPort = socket.getLocalPort();
+ socket.close();
+ server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), actualPort), 0);
+ } catch (IOException ioException) {
+ context.logger.error("Failed to start web server: " + ioException.getMessage());
+ return ERROR;
+ }
+ }
+
+ context.logger.info("Starting Maven Build Report viewer on http://localhost:" + actualPort);
+
+ // Serve the HTML frontend
+ server.createContext("/", exchange -> {
+ lastRequestTime = System.currentTimeMillis();
+ if (!"/".equals(exchange.getRequestURI().getPath())) {
+ exchange.sendResponseHeaders(404, -1);
+ exchange.close();
+ return;
+ }
+ serveClasspathResource(exchange, "report.html", "text/html; charset=UTF-8");
+ });
+
+ // Serve a specific build report by ID (with path traversal protection)
+ server.createContext("/api/report", exchange -> {
+ lastRequestTime = System.currentTimeMillis();
+ Path fileToServe = reportFile;
+
+ String query = exchange.getRequestURI().getQuery();
+ if (query != null && query.contains("id=")) {
+ String reqId = extractQueryParam(query, "id");
+ if (reqId != null) {
+ // Validate: only allow simple filenames (no path separators, no ..)
+ if (!SAFE_REPORT_ID.matcher(reqId).matches()) {
+ sendError(exchange, 400, "Invalid report ID");
+ return;
+ }
+ Path candidate = reportDir.resolve(reqId);
+ // Double-check the resolved path is still within the report directory
+ if (!candidate.normalize().startsWith(reportDir.normalize())) {
+ sendError(exchange, 400, "Invalid report ID");
+ return;
+ }
+ if (Files.isRegularFile(candidate)) {
+ fileToServe = candidate;
+ } else {
+ sendError(exchange, 404, "Report not found");
+ return;
+ }
+ }
+ }
+
+ if (!Files.isRegularFile(fileToServe)) {
+ exchange.sendResponseHeaders(404, -1);
+ exchange.close();
+ return;
+ }
+
+ byte[] bytes = Files.readAllBytes(fileToServe);
+ exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ });
+
+ // List available reports
+ server.createContext("/api/reports", exchange -> {
+ lastRequestTime = System.currentTimeMillis();
+ byte[] bytes = listReportsJson(reportDir).getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ });
+
+ server.setExecutor(Executors.newFixedThreadPool(4));
+ server.start();
+
+ // Auto-open browser (best-effort, ignore if headless)
+ try {
+ if (java.awt.Desktop.isDesktopSupported()
+ && java.awt.Desktop.getDesktop().isSupported(java.awt.Desktop.Action.BROWSE)) {
+ java.awt.Desktop.getDesktop().browse(URI.create("http://localhost:" + actualPort));
+ }
+ } catch (Exception e) {
+ // Ignore — headless or desktop not supported
+ }
+
+ // Graceful inactivity shutdown (no System.exit!)
+ CountDownLatch shutdownLatch = new CountDownLatch(1);
+ ScheduledExecutorService shutdownExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "mvnlog-shutdown-monitor");
+ t.setDaemon(true);
+ return t;
+ });
+
+ HttpServer finalServer = server;
+ shutdownExecutor.scheduleAtFixedRate(
+ () -> {
+ if (System.currentTimeMillis() - lastRequestTime > INACTIVITY_TIMEOUT_MS) {
+ context.logger.info("Terminating server after 30 minutes of inactivity.");
+ finalServer.stop(1);
+ shutdownExecutor.shutdown();
+ shutdownLatch.countDown();
+ }
+ },
+ 1,
+ 1,
+ TimeUnit.MINUTES);
+
+ // Block until inactivity shutdown or interrupt (Ctrl+C)
+ try {
+ shutdownLatch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ context.logger.info("Server interrupted, shutting down.");
+ server.stop(1);
+ shutdownExecutor.shutdown();
+ }
+
+ return OK;
+ }
+
+ private void serveClasspathResource(HttpExchange exchange, String resource, String contentType) throws IOException {
+ try (InputStream is = getClass().getResourceAsStream("/org/apache/maven/cling/invoker/mvnlog/" + resource)) {
+ if (is == null) {
+ sendError(exchange, 404, resource + " not found");
+ return;
+ }
+ byte[] bytes = is.readAllBytes();
+ exchange.getResponseHeaders().set("Content-Type", contentType);
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+ }
+
+ private static void sendError(HttpExchange exchange, int code, String message) throws IOException {
+ byte[] bytes = message.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8");
+ exchange.sendResponseHeaders(code, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ private static String extractQueryParam(String query, String param) {
+ String prefix = param + "=";
+ int start = query.indexOf(prefix);
+ if (start == -1) {
+ return null;
+ }
+ start += prefix.length();
+ int end = query.indexOf('&', start);
+ return end == -1 ? query.substring(start) : query.substring(start, end);
+ }
+
+ private String listReportsJson(Path reportDir) {
+ List<String> entries = new ArrayList<>();
+ if (Files.isDirectory(reportDir)) {
+ try (Stream<Path> stream = Files.list(reportDir)) {
+ stream.filter(p -> {
+ String name = p.getFileName().toString();
+ return name.startsWith("build-report-") && name.endsWith(".json");
+ })
+ .sorted((a, b) -> b.getFileName()
+ .toString()
+ .compareTo(a.getFileName().toString()))
+ .forEach(p -> {
+ try {
+ String content = Files.readString(p);
+ Map<String, Object> report = SimpleJsonReader.parse(content);
+ @SuppressWarnings("unchecked")
+ Map<String, Object> metadata =
+ (Map<String, Object>) report.getOrDefault("metadata", Map.of());
+ String id = p.getFileName().toString();
+ String reportStatus = String.valueOf(metadata.getOrDefault("status", "unknown"));
+ Object startTime = metadata.getOrDefault("startTime", 0);
+ Object durationMs = metadata.getOrDefault("durationMs", 0);
+ Object goals = metadata.getOrDefault("goals", List.of());
+ entries.add(" {\"id\": \"" + escapeJson(id) + "\", \"status\": \""
+ + escapeJson(reportStatus) + "\", \"startTime\": " + startTime
+ + ", \"durationMs\": " + durationMs + ", \"goals\": "
+ + SimpleJsonWriter.toJson(goals) + "}");
+ } catch (Exception e) {
+ // Skip unreadable files
+ }
+ });
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+ return "[\n" + String.join(",\n", entries) + "\n]";
+ }
+
+ // ---- Helpers ----
+
private static BuildReportFilter buildFilter(LogOptions options) {
if (options == null) {
return new BuildReportFilter(null, null, null, null);
@@ -196,4 +466,34 @@ private Path resolveReportFile(LogContext context) {
// Default: target/build-reports/build-report-latest.json
return resolveReportDir(context).resolve(DEFAULT_REPORT_FILE);
}
+
+ private static String escapeJson(String string) {
+ if (string == null) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < string.length(); i++) {
+ char ch = string.charAt(i);
+ switch (ch) {
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ default:
+ sb.append(ch);
+ }
+ }
+ return sb.toString();
+ }
}
diff --git a/impl/maven-cli/src/main/resources/org/apache/maven/cling/invoker/mvnlog/report.html b/impl/maven-cli/src/main/resources/org/apache/maven/cling/invoker/mvnlog/report.html
index 78726e7403..eb7c887c1b 100644
--- a/impl/maven-cli/src/main/resources/org/apache/maven/cling/invoker/mvnlog/report.html
+++ b/impl/maven-cli/src/main/resources/org/apache/maven/cling/invoker/mvnlog/report.html
@@ -1,11 +1,26 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Maven Build Report Explorer</title>
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0f172a;
@@ -23,8 +38,8 @@
--warning-bg: rgba(245, 158, 11, 0.15);
--info-color: #06b6d4;
--border-color: #334155;
- --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- --font-mono: 'JetBrains Mono', monospace;
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
@media (prefers-color-scheme: light) {
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java
index 67bbe36ff3..e7a15654ab 100644
--- a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java
@@ -229,8 +229,9 @@ void testNoOpMethods() throws Exception {
// These should not produce any output (handled by MachineExecutionEventLogger)
listener.sessionStarted(null);
listener.projectStarted("my-core");
- listener.projectFinished("my-core");
+ listener.projectFinished("my-core", "SUCCESS");
listener.mojoStarted(null);
+ listener.mojoFinished(null, "SUCCESS");
listener.finish(0);
listener.fail(new RuntimeException("error"));
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java
index 1c7dafe2e9..67285e3bf1 100644
--- a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java
@@ -82,7 +82,7 @@ void testProjectStartedAndFinished() {
listener.initReactor(session);
listener.projectStarted("api");
- listener.projectFinished("api");
+ listener.projectFinished("api", "SUCCESS");
}
@Test
@@ -158,7 +158,7 @@ void testMojoStartedUpdatesState() {
listener.mojoStarted(mojoEvent);
- listener.projectFinished("api");
+ listener.projectFinished("api", "SUCCESS");
}
@Test
@@ -199,12 +199,12 @@ void testParallelProjects() {
listener.projectStarted("api");
listener.projectStarted("core");
- listener.projectFinished("api");
+ listener.projectFinished("api", "SUCCESS");
listener.projectStarted("cli");
- listener.projectFinished("core");
- listener.projectFinished("cli");
+ listener.projectFinished("core", "SUCCESS");
+ listener.projectFinished("cli", "SUCCESS");
}
@Test
@@ -214,7 +214,7 @@ void testExecutionFailureUpdatesState() {
listener.projectStarted("api");
listener.executionFailure("api", true, "Compilation error");
- listener.projectFinished("api");
+ listener.projectFinished("api", "SUCCESS");
}
@Test
@@ -259,11 +259,11 @@ void testLargeReactorProgressBar() {
listener.projectStarted("module-" + i);
}
for (int i = 0; i < 5; i++) {
- listener.projectFinished("module-" + i);
+ listener.projectFinished("module-" + i, "SUCCESS");
}
// 5 completed, 5 active, 40 pending — should render without error
for (int i = 5; i < 10; i++) {
- listener.projectFinished("module-" + i);
+ listener.projectFinished("module-" + i, "SUCCESS");
}
}
@@ -276,11 +276,11 @@ void testSmallReactorUsesIndicators() {
listener.initReactor(session);
listener.projectStarted("api");
- listener.projectFinished("api");
+ listener.projectFinished("api", "SUCCESS");
listener.projectStarted("core");
- listener.projectFinished("core");
+ listener.projectFinished("core", "SUCCESS");
listener.projectStarted("cli");
- listener.projectFinished("cli");
+ listener.projectFinished("cli", "SUCCESS");
}
@Test
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
index 2dee9d3a6e..c8df76979d 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
@@ -41,7 +41,6 @@ public interface BuildEventListener {
void mojoFinished(ExecutionEvent event, String status);
-
void finish(int exitCode) throws Exception;
void fail(Throwable t) throws Exception;
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildReportEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildReportEventListener.java
index 4e77bda2d7..bc3b432d51 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildReportEventListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildReportEventListener.java
@@ -23,6 +23,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
@@ -30,8 +31,8 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CopyOnWriteArrayList;
+import org.apache.maven.api.build.report.LogEvent;
import org.apache.maven.execution.ExecutionEvent;
import org.eclipse.aether.transfer.TransferEvent;
@@ -46,11 +47,11 @@ public class BuildReportEventListener implements BuildEventListener {
private String status = "SUCCESS";
private final Map<String, ModuleInfo> modules = new ConcurrentHashMap<>();
- private final List<ModuleInfo> orderedModules = new CopyOnWriteArrayList<>();
+ private final List<ModuleInfo> orderedModules = Collections.synchronizedList(new ArrayList<>());
private final Map<Long, MojoInfo> activeMojos = new ConcurrentHashMap<>();
- private final List<ProblemInfo> problems = new CopyOnWriteArrayList<>();
- private final List<FailureInfo> failures = new CopyOnWriteArrayList<>();
- private final List<LogEntry> logs = new CopyOnWriteArrayList<>();
+ private final List<ProblemInfo> problems = Collections.synchronizedList(new ArrayList<>());
+ private final List<FailureInfo> failures = Collections.synchronizedList(new ArrayList<>());
+ private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
public BuildReportEventListener(BuildEventListener delegate) {
this.delegate = delegate;
@@ -106,13 +107,12 @@ public void mojoStarted(ExecutionEvent event) {
delegate.mojoStarted(event);
if (event.getMojoExecution() != null) {
MojoInfo info = new MojoInfo(
- event.getMojoExecution().getGroupId(),
- event.getMojoExecution().getArtifactId(),
- event.getMojoExecution().getVersion(),
- event.getMojoExecution().getGoal(),
- event.getMojoExecution().getExecutionId(),
- event.getMojoExecution().getLifecyclePhase()
- );
+ event.getMojoExecution().getGroupId(),
+ event.getMojoExecution().getArtifactId(),
+ event.getMojoExecution().getVersion(),
+ event.getMojoExecution().getGoal(),
+ event.getMojoExecution().getExecutionId(),
+ event.getMojoExecution().getLifecyclePhase());
info.startTime = System.currentTimeMillis();
activeMojos.put(Thread.currentThread().getId(), info);
@@ -137,25 +137,16 @@ public void mojoFinished(ExecutionEvent event, String status) {
}
@Override
- public void projectLogMessage(String projectId, String event) {
+ public void projectLogMessage(String projectId, LogEvent event) {
delegate.projectLogMessage(projectId, event);
- if (event == null) return;
-
- long timestamp = System.currentTimeMillis();
- String cleanMessage = event.replaceAll("\\u001B\\[[;\\d]*[ -/]*[@-~]", "");
-
- // Determine log level
- String level = "INFO";
- if (cleanMessage.contains("[TRACE]")) {
- level = "TRACE";
- } else if (cleanMessage.contains("[DEBUG]")) {
- level = "DEBUG";
- } else if (cleanMessage.contains("[WARNING]") || cleanMessage.contains("[WARN]")) {
- level = "WARN";
- } else if (cleanMessage.contains("[ERROR]")) {
- level = "ERROR";
+ if (event == null) {
+ return;
}
+ long timestamp = event.timestamp() != null ? event.timestamp().toEpochMilli() : System.currentTimeMillis();
+ String level = event.level() != null ? event.level().name() : "INFO";
+ String cleanMessage = event.message();
+
// Get currently active mojo on this thread
MojoInfo activeMojo = activeMojos.get(Thread.currentThread().getId());
String mojoId = activeMojo != null ? activeMojo.getId() : null;
@@ -177,8 +168,10 @@ private void addProblemFromLog(String level, String projectId, String mojoId, St
}
if (httpIdx != -1) {
int endIdx = httpIdx;
- while (endIdx < message.length() && !Character.isWhitespace(message.charAt(endIdx))
- && message.charAt(endIdx) != ')' && message.charAt(endIdx) != ']') {
+ while (endIdx < message.length()
+ && !Character.isWhitespace(message.charAt(endIdx))
+ && message.charAt(endIdx) != ')'
+ && message.charAt(endIdx) != ']') {
endIdx++;
}
docUrl = message.substring(httpIdx, endIdx);
@@ -188,7 +181,8 @@ private void addProblemFromLog(String level, String projectId, String mojoId, St
int mngIdx = message.indexOf("MNG-");
if (mngIdx != -1) {
int endIdx = mngIdx;
- while (endIdx < message.length() && (Character.isLetterOrDigit(message.charAt(endIdx)) || message.charAt(endIdx) == '-')) {
+ while (endIdx < message.length()
+ && (Character.isLetterOrDigit(message.charAt(endIdx)) || message.charAt(endIdx) == '-')) {
endIdx++;
}
key = message.substring(mngIdx, endIdx);
@@ -236,7 +230,7 @@ public void transfer(String projectId, TransferEvent e) {
private void writeReport() {
try {
- Path reportsDir = topDirectory.resolve(".mvn").resolve("reports");
+ Path reportsDir = topDirectory.resolve("target").resolve("build-reports");
Files.createDirectories(reportsDir);
String timestamp = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date());
@@ -246,8 +240,10 @@ private void writeReport() {
writer.write("{\n");
writer.write(" \"metadata\": {\n");
writer.write(" \"status\": \"" + status + "\",\n");
- writer.write(" \"mavenVersion\": \"" + escapeJson(System.getProperty("maven.version", "4.1.0-SNAPSHOT")) + "\",\n");
- writer.write(" \"javaVersion\": \"" + escapeJson(System.getProperty("java.version", "unknown")) + "\",\n");
+ writer.write(" \"mavenVersion\": \""
+ + escapeJson(System.getProperty("maven.version", "4.1.0-SNAPSHOT")) + "\",\n");
+ writer.write(" \"javaVersion\": \"" + escapeJson(System.getProperty("java.version", "unknown"))
+ + "\",\n");
writer.write(" \"startTime\": " + startTime + ",\n");
writer.write(" \"endTime\": " + endTime + ",\n");
writer.write(" \"durationMs\": " + (endTime - startTime) + ",\n");
@@ -304,7 +300,8 @@ private void writeReport() {
FailureInfo fail = failures.get(i);
writer.write(" {\n");
writer.write(" \"moduleId\": \"" + escapeJson(fail.moduleId) + "\",\n");
- writer.write(" \"mojo\": " + (fail.mojoId != null ? "\"" + escapeJson(fail.mojoId) + "\"" : "null") + ",\n");
+ writer.write(" \"mojo\": "
+ + (fail.mojoId != null ? "\"" + escapeJson(fail.mojoId) + "\"" : "null") + ",\n");
writer.write(" \"message\": \"" + escapeJson(fail.message) + "\"\n");
writer.write(" }" + (i < failures.size() - 1 ? "," : "") + "\n");
}
@@ -316,14 +313,21 @@ private void writeReport() {
writer.write(" {\n");
writer.write(" \"timestamp\": " + entry.timestamp + ",\n");
writer.write(" \"level\": \"" + entry.level + "\",\n");
- writer.write(" \"moduleId\": " + (entry.moduleId != null ? "\"" + escapeJson(entry.moduleId) + "\"" : "null") + ",\n");
- writer.write(" \"mojoId\": " + (entry.mojoId != null ? "\"" + escapeJson(entry.mojoId) + "\"" : "null") + ",\n");
+ writer.write(" \"moduleId\": "
+ + (entry.moduleId != null ? "\"" + escapeJson(entry.moduleId) + "\"" : "null")
+ + ",\n");
+ writer.write(" \"mojoId\": "
+ + (entry.mojoId != null ? "\"" + escapeJson(entry.mojoId) + "\"" : "null") + ",\n");
writer.write(" \"message\": \"" + escapeJson(entry.message) + "\"\n");
writer.write(" }" + (i < logs.size() - 1 ? "," : "") + "\n");
}
writer.write(" ]\n");
writer.write("}\n");
}
+
+ // Write a latest symlink/copy for easy access
+ Path latestFile = reportsDir.resolve("build-report-latest.json");
+ Files.copy(reportFile, latestFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
// Silently ignore report writing failures to not crash maven build
}
@@ -348,13 +352,27 @@ private static String escapeJson(String string) {
for (int i = 0; i < string.length(); i++) {
char ch = string.charAt(i);
switch (ch) {
- case '"': sb.append("\\\""); break;
- case '\\': sb.append("\\\\"); break;
- case '\b': sb.append("\\b"); break;
- case '\f': sb.append("\\f"); break;
- case '\n': sb.append("\\n"); break;
- case '\r': sb.append("\\r"); break;
- case '\t': sb.append("\\t"); break;
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\b':
+ sb.append("\\b");
+ break;
+ case '\f':
+ sb.append("\\f");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
default:
if (ch < ' ') {
String t = "000" + Integer.toHexString(ch);
@@ -372,7 +390,7 @@ private static class ModuleInfo {
String status = "SKIPPED";
long startTime;
long endTime;
- final List<MojoInfo> mojos = new CopyOnWriteArrayList<>();
+ final List<MojoInfo> mojos = Collections.synchronizedList(new ArrayList<>());
ModuleInfo(String id) {
this.id = id;
@@ -412,7 +430,13 @@ private static class ProblemInfo {
final String suggestion;
final String documentationUrl;
- ProblemInfo(String severity, String message, String key, String source, String suggestion, String documentationUrl) {
+ ProblemInfo(
+ String severity,
+ String message,
+ String key,
+ String source,
+ String suggestion,
+ String documentationUrl) {
this.severity = severity;
this.message = message;
this.key = key;
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
index 6685351b36..cf6fd9b005 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
@@ -100,14 +100,14 @@ public void projectStarted(ExecutionEvent event) {
public void projectSucceeded(ExecutionEvent event) {
setMdc(event);
delegate.projectSucceeded(event);
- buildEventListener.projectFinished(event.getProject().getArtifactId());
+ buildEventListener.projectFinished(event.getProject().getArtifactId(), "SUCCESS");
}
@Override
public void projectFailed(ExecutionEvent event) {
setMdc(event);
delegate.projectFailed(event);
- buildEventListener.projectFinished(event.getProject().getArtifactId());
+ buildEventListener.projectFinished(event.getProject().getArtifactId(), "FAILURE");
}
@Override
@@ -115,7 +115,7 @@ public void projectSkipped(ExecutionEvent event) {
setMdc(event);
buildEventListener.projectStarted(event.getProject().getArtifactId());
delegate.projectSkipped(event);
- buildEventListener.projectFinished(event.getProject().getArtifactId());
+ buildEventListener.projectFinished(event.getProject().getArtifactId(), "SKIPPED");
}
@Override
@@ -130,6 +130,7 @@ public void mojoStarted(ExecutionEvent event) {
public void mojoSucceeded(ExecutionEvent event) {
setMdc(event);
ProjectBuildLogAppender.setMojoId(null);
+ buildEventListener.mojoFinished(event, "SUCCESS");
delegate.mojoSucceeded(event);
}
@@ -137,6 +138,7 @@ public void mojoSucceeded(ExecutionEvent event) {
public void mojoFailed(ExecutionEvent event) {
setMdc(event);
ProjectBuildLogAppender.setMojoId(null);
+ buildEventListener.mojoFinished(event, "FAILURE");
delegate.mojoFailed(event);
}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java
index 83ab4a0e19..6bab1812a4 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java
@@ -50,7 +50,6 @@ public void projectFinished(String projectId, String status) {}
@Override
public void mojoFinished(ExecutionEvent event, String status) {}
-
@Override
public void executionFailure(String projectId, boolean halted, String exception) {}