fixing the output logs
This commit is contained in:
@@ -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
|
||||||
@@ -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;
|
||||||
|
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;
|
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()) {
|
while (!Thread.currentThread().isInterrupted()) {
|
||||||
long length = file.length();
|
long length = raf.length();
|
||||||
|
|
||||||
if (length > pointer) {
|
if (length > pointer) {
|
||||||
file.seek(pointer);
|
raf.seek(pointer);
|
||||||
String line;
|
// Read all newly available bytes
|
||||||
while ((line = file.readLine()) != null) {
|
int b;
|
||||||
emitter.send(SseEmitter.event().data(line));
|
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 = file.getFilePointer();
|
|
||||||
}
|
}
|
||||||
Thread.sleep(1000);
|
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("&", "&") // must be first
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user