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) {}