diff --git a/src/main/java/org/zaine/app/controller/zone/BuildController.java b/src/main/java/org/zaine/app/controller/zone/BuildController.java index a476e5f..b8d7c90 100644 --- a/src/main/java/org/zaine/app/controller/zone/BuildController.java +++ b/src/main/java/org/zaine/app/controller/zone/BuildController.java @@ -1,34 +1,71 @@ package org.zaine.app.controller.zone; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStreamWriter; import java.io.RandomAccessFile; +import java.io.Writer; import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequestMapping("/api") public class BuildController { - // Separate state for each build + private static final Logger log = LoggerFactory.getLogger(BuildController.class); + private static final Instant SERVER_START = Instant.now(); + + // ── ANSI escape sequence pattern ───────────────────────────────────────── + // Matches CSI sequences: ESC [ + // and simple ESC sequences: ESC + private static final Pattern ANSI_ESCAPE = Pattern.compile( + "\u001B(?:\\[[0-9;]*[A-Za-z]|[^\\[])"); + + // Map of SGR codes to CSS classes (added to ) + // Covers the codes actually emitted by build-site.el + private static final Map SGR_CLASS = Map.ofEntries( + Map.entry("0", "ansi-reset"), + Map.entry("1", "ansi-bold"), + Map.entry("2", "ansi-dim"), + Map.entry("31", "ansi-red"), + Map.entry("32", "ansi-green"), + Map.entry("33", "ansi-yellow"), + Map.entry("34", "ansi-blue"), + Map.entry("35", "ansi-magenta"), + Map.entry("36", "ansi-cyan"), + Map.entry("37", "ansi-white") + ); + + /* ========================================================= + BUILD STATE + ========================================================= */ + private final AtomicBoolean webBuildRunning = new AtomicBoolean(false); private final AtomicBoolean roamBuildRunning = new AtomicBoolean(false); private final AtomicBoolean emacsRunning = new AtomicBoolean(false); private final AtomicBoolean combinedRunning = new AtomicBoolean(false); - // Cancellable process references private final AtomicReference webProcess = new AtomicReference<>(); private final AtomicReference roamProcess = new AtomicReference<>(); private final AtomicReference emacsProcess = new AtomicReference<>(); @@ -43,31 +80,17 @@ public class BuildController { private volatile Instant combinedLastRun; private volatile Integer combinedLastExitCode; - private static final Logger log = LoggerFactory.getLogger(BuildController.class); + /* ========================================================= + CONFIG + ========================================================= */ - // Server start time for uptime calculation - private static final Instant SERVER_START = Instant.now(); - - @Value("${zone.build.dir}") - private String webBuildDirectory; - - @Value("${zone.build.log}") - private String webBuildLogFile; - - @Value("${orgroam.build.dir}") - private String roamBuildDirectory; - - @Value("${orgroam.build.log}") - private String roamBuildLogFile; - - @Value("${emacs.run.dir}") - private String emacsRunDirectory; - - @Value("${emacs.run.log}") - private String emacsRunLogFile; - - @Value("${combined.run.log}") - private String combinedRunLogFile; + @Value("${zone.build.dir}") private String webBuildDirectory; + @Value("${zone.build.log}") private String webBuildLogFile; + @Value("${orgroam.build.dir}") private String roamBuildDirectory; + @Value("${orgroam.build.log}") private String roamBuildLogFile; + @Value("${emacs.run.dir}") private String emacsRunDirectory; + @Value("${emacs.run.log}") private String emacsRunLogFile; + @Value("${combined.run.log}") private String combinedRunLogFile; /* ========================================================= HEALTH + UPTIME @@ -80,7 +103,7 @@ public class BuildController { @GetMapping("/uptime") public ResponseEntity> getUptime() { - long uptimeMs = Instant.now().toEpochMilli() - SERVER_START.toEpochMilli(); + long uptimeMs = Instant.now().toEpochMilli() - SERVER_START.toEpochMilli(); long jvmUptimeMs = ManagementFactory.getRuntimeMXBean().getUptime(); return ResponseEntity.ok(Map.of( "serverStart", SERVER_START, @@ -173,7 +196,8 @@ public class BuildController { } if (emacsRunning.get() || roamBuildRunning.get()) { combinedRunning.set(false); - return ResponseEntity.status(HttpStatus.CONFLICT).body("Emacs or Roam already running independently"); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body("Emacs or Roam already running independently"); } try { @@ -184,7 +208,6 @@ public class BuildController { try { appendToLog(logFile, "=== [1/2] Starting Emacs ===\n"); - // Step 1: Emacs ProcessBuilder emacsPb = new ProcessBuilder( "bash", "-c", "TERM=vt100 /usr/bin/timeout 10 /usr/bin/script -q -c \"emacs -nw\" /dev/null" @@ -201,7 +224,6 @@ public class BuildController { appendToLog(logFile, "\n=== Emacs exited (code " + emacsExit + ") ===\n"); appendToLog(logFile, "=== [2/2] Starting Roam build ===\n"); - // Step 2: Roam make ProcessBuilder roamPb = new ProcessBuilder("make"); roamPb.directory(Path.of(roamBuildDirectory).toFile()); roamPb.redirectErrorStream(true); @@ -253,7 +275,7 @@ public class BuildController { } /* ========================================================= - LAST RUN TIMESTAMPS (all at once) + LAST RUN TIMESTAMPS ========================================================= */ @GetMapping("/last-runs") @@ -381,31 +403,185 @@ public class BuildController { LOG STREAMING (SSE) ========================================================= */ + /** + * Tail a log file and stream each line as a named SSE "log" event, + * with ANSI colour sequences converted to HTML spans. + * + *

Why RandomAccessFile + manual byte accumulation instead of + * BufferedReader(FileInputStream): + * A FileInputStream reads to EOF and then readLine() returns null + * forever — it never observes bytes written after that point. + * RandomAccessFile.seek() lets us reposition to the current file + * pointer after each poll, so we correctly tail a growing file. + * We read raw bytes ourselves and decode with UTF-8 to avoid the + * ISO-8859-1 mangling that RandomAccessFile.readLine() does. + */ private SseEmitter streamLogs(String logFilePath) { SseEmitter emitter = new SseEmitter(0L); + new Thread(() -> { - try (RandomAccessFile file = new RandomAccessFile(logFilePath, "r")) { - long pointer = 0; - while (!Thread.currentThread().isInterrupted()) { - long length = file.length(); - if (length > pointer) { - file.seek(pointer); - String line; - while ((line = file.readLine()) != null) { - emitter.send(SseEmitter.event().data(line)); - } - pointer = file.getFilePointer(); - } - Thread.sleep(1000); + final long IDLE_TIMEOUT_MS = 5_000; + final long POLL_INTERVAL_MS = 300; + + long lastActivity = System.currentTimeMillis(); + + // Wait up to 2 s for the log file to appear (build may not have + // created it yet when the client opens the SSE connection) + File logFile = new File(logFilePath); + long waitStart = System.currentTimeMillis(); + while (!logFile.exists() && System.currentTimeMillis() - waitStart < 2_000) { + try { Thread.sleep(100); } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); return; } + } + + try (RandomAccessFile raf = new RandomAccessFile(logFile, "r")) { + long pointer = 0; + // Line accumulator — holds a partial line across poll cycles + java.io.ByteArrayOutputStream lineBuf = new java.io.ByteArrayOutputStream(256); + + while (!Thread.currentThread().isInterrupted()) { + long length = raf.length(); + + if (length > pointer) { + raf.seek(pointer); + // Read all newly available bytes + int b; + while ((b = raf.read()) != -1) { + if (b == '\n') { + // Decode the accumulated bytes as UTF-8 + String line = lineBuf.toString(StandardCharsets.UTF_8); + lineBuf.reset(); + lastActivity = System.currentTimeMillis(); + String html = ansiToHtml(htmlEscape(line)); + emitter.send(SseEmitter.event().name("log").data(html)); + } else if (b != '\r') { + // Skip bare CR; accumulate everything else + lineBuf.write(b); + } + } + pointer = raf.getFilePointer(); + } else { + // No new bytes — flush any partial line that has been + // sitting in the buffer for a while (e.g. a line that + // the process wrote without a trailing newline yet) + if (lineBuf.size() > 0 + && System.currentTimeMillis() - lastActivity > 1_000) { + String line = lineBuf.toString(StandardCharsets.UTF_8); + lineBuf.reset(); + lastActivity = System.currentTimeMillis(); + emitter.send(SseEmitter.event() + .name("log") + .data(ansiToHtml(htmlEscape(line)))); + } + + // Idle timeout — build is done + if (System.currentTimeMillis() - lastActivity > IDLE_TIMEOUT_MS) { + emitter.send(SseEmitter.event() + .name("done").data("stream-end")); + emitter.complete(); + return; + } + Thread.sleep(POLL_INTERVAL_MS); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } catch (Exception e) { - log.info("Log stream closed"); + log.debug("Log stream ended: {}", e.getMessage()); + } finally { emitter.complete(); } }).start(); + return emitter; } + /* ========================================================= + ANSI → HTML CONVERSION + ========================================================= */ + + /** + * Convert ANSI SGR escape sequences in {@code text} to HTML {@code } + * elements with CSS classes, and strip all other ANSI escape sequences. + * + *

The caller must HTML-escape the raw text before calling this + * method so that any {@code <} / {@code >} / {@code &} in the log output + * are already safe, and the {@code } tags we insert here are the only + * real HTML in the result. + * + *

Example input: {@code "\033[32m\033[1m ✓ \033[0mBuild complete"} + *

Example output: {@code "Build complete"} + */ + static String ansiToHtml(String text) { + // Fast path: no ESC → nothing to do + if (text.indexOf('\u001B') == -1) return text; + + // Pattern for CSI SGR sequences only: ESC [ m + Pattern SGR = Pattern.compile("\u001B\\[([0-9;]*)m"); + + StringBuilder out = new StringBuilder(text.length() + 64); + int pos = 0; + boolean inSpan = false; + + // We scan for SGR sequences; anything else (cursor movement etc.) is stripped + // by the final cleanup pass at the end. + Matcher m = SGR.matcher(text); + + while (m.find()) { + // Append the literal text between last match and this one + out.append(text, pos, m.start()); + pos = m.end(); + + String params = m.group(1); // e.g. "32", "1;32", "0", "" + + // SGR 0 (or bare ESC[m) → close any open span + boolean isReset = params.isEmpty() || params.equals("0"); + if (isReset) { + if (inSpan) { + out.append(""); + inSpan = false; + } + continue; + } + + // Build CSS class list from the semicolon-separated codes + String[] codes = params.split(";"); + StringBuilder classes = new StringBuilder(); + for (String code : codes) { + String cls = SGR_CLASS.get(code); + if (cls != null) { + if (classes.length() > 0) classes.append(' '); + classes.append(cls); + } + } + + if (classes.length() > 0) { + if (inSpan) out.append(""); + out.append(""); + inSpan = true; + } + } + + // Append any trailing text after the last match + out.append(text, pos, text.length()); + if (inSpan) out.append(""); + + // Strip any remaining non-SGR ANSI sequences (cursor movement, etc.) + return ANSI_ESCAPE.matcher(out).replaceAll(""); + } + + /** + * Escape HTML-special characters so log output is safe for innerHTML. + * Must be called BEFORE {@link #ansiToHtml} so the spans we insert aren't escaped. + */ + static String htmlEscape(String text) { + return text + .replace("&", "&") // must be first + .replace("<", "<") + .replace(">", ">"); + } + /* ========================================================= HELPERS ========================================================= */ @@ -413,13 +589,15 @@ public class BuildController { private File prepareLogFile(String logPath) throws IOException { File logFile = new File(logPath); if (logFile.getParentFile() != null) logFile.getParentFile().mkdirs(); - if (logFile.exists()) logFile.delete(); - logFile.createNewFile(); + // Truncate/recreate cleanly + Files.deleteIfExists(logFile.toPath()); + Files.createFile(logFile.toPath()); return logFile; } private void appendToLog(File logFile, String text) { - try (java.io.FileWriter fw = new java.io.FileWriter(logFile, true)) { + try (Writer fw = new OutputStreamWriter( + new FileOutputStream(logFile, true), StandardCharsets.UTF_8)) { fw.write(text); } catch (IOException e) { log.warn("Could not append to log", e); @@ -440,9 +618,9 @@ public class BuildController { public Integer lastExitCode; public BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) { - this.running = running; - this.lastRun = lastRun; + this.running = running; + this.lastRun = lastRun; this.lastExitCode = lastExitCode; } } -} +} \ No newline at end of file