fixing the output logs

This commit is contained in:
2026-03-20 23:53:07 +00:00
parent 7fa7cbb4c7
commit e04d6f98d1

View File

@@ -1,34 +1,71 @@
package org.zaine.app.controller.zone; 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.File;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.RandomAccessFile; import java.io.RandomAccessFile;
import java.io.Writer;
import java.lang.management.ManagementFactory; import java.lang.management.ManagementFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; 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 @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class BuildController { 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 [ <params> <final-byte>
// and simple ESC sequences: ESC <single-char>
private static final Pattern ANSI_ESCAPE = Pattern.compile(
"\u001B(?:\\[[0-9;]*[A-Za-z]|[^\\[])");
// Map of SGR codes to CSS classes (added to <span class="...">)
// Covers the codes actually emitted by build-site.el
private static final Map<String, String> 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 webBuildRunning = new AtomicBoolean(false);
private final AtomicBoolean roamBuildRunning = new AtomicBoolean(false); private final AtomicBoolean roamBuildRunning = new AtomicBoolean(false);
private final AtomicBoolean emacsRunning = new AtomicBoolean(false); private final AtomicBoolean emacsRunning = new AtomicBoolean(false);
private final AtomicBoolean combinedRunning = new AtomicBoolean(false); private final AtomicBoolean combinedRunning = new AtomicBoolean(false);
// Cancellable process references
private final AtomicReference<Process> webProcess = new AtomicReference<>(); private final AtomicReference<Process> webProcess = new AtomicReference<>();
private final AtomicReference<Process> roamProcess = new AtomicReference<>(); private final AtomicReference<Process> roamProcess = new AtomicReference<>();
private final AtomicReference<Process> emacsProcess = new AtomicReference<>(); private final AtomicReference<Process> emacsProcess = new AtomicReference<>();
@@ -43,31 +80,17 @@ public class BuildController {
private volatile Instant combinedLastRun; private volatile Instant combinedLastRun;
private volatile Integer combinedLastExitCode; private volatile Integer combinedLastExitCode;
private static final Logger log = LoggerFactory.getLogger(BuildController.class); /* =========================================================
CONFIG
========================================================= */
// Server start time for uptime calculation @Value("${zone.build.dir}") private String webBuildDirectory;
private static final Instant SERVER_START = Instant.now(); @Value("${zone.build.log}") private String webBuildLogFile;
@Value("${orgroam.build.dir}") private String roamBuildDirectory;
@Value("${zone.build.dir}") @Value("${orgroam.build.log}") private String roamBuildLogFile;
private String webBuildDirectory; @Value("${emacs.run.dir}") private String emacsRunDirectory;
@Value("${emacs.run.log}") private String emacsRunLogFile;
@Value("${zone.build.log}") @Value("${combined.run.log}") private String combinedRunLogFile;
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 HEALTH + UPTIME
@@ -80,7 +103,7 @@ public class BuildController {
@GetMapping("/uptime") @GetMapping("/uptime")
public ResponseEntity<Map<String, Object>> getUptime() { public ResponseEntity<Map<String, Object>> getUptime() {
long uptimeMs = Instant.now().toEpochMilli() - SERVER_START.toEpochMilli(); long uptimeMs = Instant.now().toEpochMilli() - SERVER_START.toEpochMilli();
long jvmUptimeMs = ManagementFactory.getRuntimeMXBean().getUptime(); long jvmUptimeMs = ManagementFactory.getRuntimeMXBean().getUptime();
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"serverStart", SERVER_START, "serverStart", SERVER_START,
@@ -173,7 +196,8 @@ public class BuildController {
} }
if (emacsRunning.get() || roamBuildRunning.get()) { if (emacsRunning.get() || roamBuildRunning.get()) {
combinedRunning.set(false); 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 { try {
@@ -184,7 +208,6 @@ public class BuildController {
try { try {
appendToLog(logFile, "=== [1/2] Starting Emacs ===\n"); appendToLog(logFile, "=== [1/2] Starting Emacs ===\n");
// Step 1: Emacs
ProcessBuilder emacsPb = new ProcessBuilder( ProcessBuilder emacsPb = new ProcessBuilder(
"bash", "-c", "bash", "-c",
"TERM=vt100 /usr/bin/timeout 10 /usr/bin/script -q -c \"emacs -nw\" /dev/null" "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, "\n=== Emacs exited (code " + emacsExit + ") ===\n");
appendToLog(logFile, "=== [2/2] Starting Roam build ===\n"); appendToLog(logFile, "=== [2/2] Starting Roam build ===\n");
// Step 2: Roam make
ProcessBuilder roamPb = new ProcessBuilder("make"); ProcessBuilder roamPb = new ProcessBuilder("make");
roamPb.directory(Path.of(roamBuildDirectory).toFile()); roamPb.directory(Path.of(roamBuildDirectory).toFile());
roamPb.redirectErrorStream(true); roamPb.redirectErrorStream(true);
@@ -253,7 +275,7 @@ public class BuildController {
} }
/* ========================================================= /* =========================================================
LAST RUN TIMESTAMPS (all at once) LAST RUN TIMESTAMPS
========================================================= */ ========================================================= */
@GetMapping("/last-runs") @GetMapping("/last-runs")
@@ -381,31 +403,185 @@ public class BuildController {
LOG STREAMING (SSE) 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.
*
* <p>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) { private SseEmitter streamLogs(String logFilePath) {
SseEmitter emitter = new SseEmitter(0L); SseEmitter emitter = new SseEmitter(0L);
new Thread(() -> { new Thread(() -> {
try (RandomAccessFile file = new RandomAccessFile(logFilePath, "r")) { final long IDLE_TIMEOUT_MS = 5_000;
long pointer = 0; final long POLL_INTERVAL_MS = 300;
while (!Thread.currentThread().isInterrupted()) {
long length = file.length(); long lastActivity = System.currentTimeMillis();
if (length > pointer) {
file.seek(pointer); // Wait up to 2 s for the log file to appear (build may not have
String line; // created it yet when the client opens the SSE connection)
while ((line = file.readLine()) != null) { File logFile = new File(logFilePath);
emitter.send(SseEmitter.event().data(line)); long waitStart = System.currentTimeMillis();
} while (!logFile.exists() && System.currentTimeMillis() - waitStart < 2_000) {
pointer = file.getFilePointer(); try { Thread.sleep(100); } catch (InterruptedException ie) {
} Thread.currentThread().interrupt(); return;
Thread.sleep(1000);
} }
}
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) { } catch (Exception e) {
log.info("Log stream closed"); log.debug("Log stream ended: {}", e.getMessage());
} finally {
emitter.complete(); emitter.complete();
} }
}).start(); }).start();
return emitter; return emitter;
} }
/* =========================================================
ANSI → HTML CONVERSION
========================================================= */
/**
* Convert ANSI SGR escape sequences in {@code text} to HTML {@code <span>}
* elements with CSS classes, and strip all other ANSI escape sequences.
*
* <p>The caller must HTML-escape the raw text <em>before</em> calling this
* method so that any {@code <} / {@code >} / {@code &} in the log output
* are already safe, and the {@code <span>} tags we insert here are the only
* real HTML in the result.
*
* <p>Example input: {@code "\033[32m\033[1m ✓ \033[0mBuild complete"}
* <p>Example output: {@code "<span class=\"ansi-green ansi-bold\"> ✓ </span>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 [ <numbers separated by ;> 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("</span>");
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("</span>");
out.append("<span class=\"").append(classes).append("\">");
inSpan = true;
}
}
// Append any trailing text after the last match
out.append(text, pos, text.length());
if (inSpan) out.append("</span>");
// 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("&", "&amp;") // must be first
.replace("<", "&lt;")
.replace(">", "&gt;");
}
/* ========================================================= /* =========================================================
HELPERS HELPERS
========================================================= */ ========================================================= */
@@ -413,13 +589,15 @@ public class BuildController {
private File prepareLogFile(String logPath) throws IOException { private File prepareLogFile(String logPath) throws IOException {
File logFile = new File(logPath); File logFile = new File(logPath);
if (logFile.getParentFile() != null) logFile.getParentFile().mkdirs(); if (logFile.getParentFile() != null) logFile.getParentFile().mkdirs();
if (logFile.exists()) logFile.delete(); // Truncate/recreate cleanly
logFile.createNewFile(); Files.deleteIfExists(logFile.toPath());
Files.createFile(logFile.toPath());
return logFile; return logFile;
} }
private void appendToLog(File logFile, String text) { 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); fw.write(text);
} catch (IOException e) { } catch (IOException e) {
log.warn("Could not append to log", e); log.warn("Could not append to log", e);
@@ -440,9 +618,9 @@ public class BuildController {
public Integer lastExitCode; public Integer lastExitCode;
public BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) { public BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) {
this.running = running; this.running = running;
this.lastRun = lastRun; this.lastRun = lastRun;
this.lastExitCode = lastExitCode; this.lastExitCode = lastExitCode;
} }
} }
} }