fixing the output logs
This commit is contained in:
@@ -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 [ <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 roamBuildRunning = new AtomicBoolean(false);
|
||||
private final AtomicBoolean emacsRunning = new AtomicBoolean(false);
|
||||
private final AtomicBoolean combinedRunning = new AtomicBoolean(false);
|
||||
|
||||
// Cancellable process references
|
||||
private final AtomicReference<Process> webProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> roamProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> 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<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();
|
||||
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.
|
||||
*
|
||||
* <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) {
|
||||
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 <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("&", "&") // 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,8 +618,8 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user