From acc767f58537d99d6931fa0a18c2972643480d57 Mon Sep 17 00:00:00 2001 From: Kurt92 Date: Mon, 25 Aug 2025 15:53:47 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=A1=9C=EA=B7=B8=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/manual/controller/LogController.java | 55 ++++++------ src/main/resources/templates/home.html | 5 ++ src/main/resources/templates/logs.html | 89 ++++++++----------- 3 files changed, 73 insertions(+), 76 deletions(-) diff --git a/src/main/java/com/manual/controller/LogController.java b/src/main/java/com/manual/controller/LogController.java index 36396ab..0589d05 100644 --- a/src/main/java/com/manual/controller/LogController.java +++ b/src/main/java/com/manual/controller/LogController.java @@ -3,17 +3,9 @@ package com.manual.controller; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; +import org.springframework.web.bind.annotation.*; import java.io.RandomAccessFile; -import java.nio.channels.Channels; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -24,30 +16,25 @@ public class LogController { @Value("${logging.file.name}") private String logPath; - private long lastReadPosition = 0L; - // 최근 N줄 반환 (기본 5000줄) @GetMapping("/logs/init") - public ResponseEntity initLogs(@RequestParam(defaultValue = "5000") int lines) throws IOException { + public ResponseEntity initLogs(@RequestParam(defaultValue = "1000") int lines) throws Exception { Path path = Paths.get(logPath); StringBuilder sb = new StringBuilder(); + long nextPos; try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) { long fileLength = raf.length(); long pointer = fileLength - 1; int lineCount = 0; - // 파일 끝에서부터 역으로 탐색 while (pointer >= 0 && lineCount <= lines) { raf.seek(pointer); - int readByte = raf.read(); - if (readByte == '\n') { - lineCount++; - } + int b = raf.read(); + if (b == '\n') lineCount++; pointer--; } - // 최근 N줄 위치로 이동 raf.seek(Math.max(0, pointer + 2)); String line; @@ -56,33 +43,49 @@ public class LogController { sb.append(fixed).append("\n"); } - // append 전용 포인터 저장 - lastReadPosition = raf.getFilePointer(); + nextPos = raf.getFilePointer(); } return ResponseEntity.ok() .header("Content-Type", "text/plain; charset=UTF-8") + .header("X-Log-Position", String.valueOf(nextPos)) .body(sb.toString()); } - // 이후 append 용 + // 지정 오프셋 이후의 신규 로그만 반환 @GetMapping("/logs/stream") - public ResponseEntity getNewLogs() throws IOException { + public ResponseEntity getNewLogs(@RequestParam(name = "from", required = false) Long from) throws Exception { Path path = Paths.get(logPath); StringBuilder sb = new StringBuilder(); + long nextPos; + boolean reset = false; try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) { - raf.seek(lastReadPosition); + long len = raf.length(); + + // 클라이언트가 오프셋 안 보냈으면 파일 끝에서 시작(=신규만 받기) + long start = (from == null) ? len : from; + + // 파일이 줄어든 경우(롤테이션/삭제 등) 보정 + if (start > len) { start = Math.max(0, len - 1); reset = true; } + if (start < 0) { start = 0; reset = true; } + + raf.seek(start); + String line; while ((line = raf.readLine()) != null) { String fixed = new String(line.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8); sb.append(fixed).append("\n"); } - lastReadPosition = raf.getFilePointer(); + + nextPos = raf.getFilePointer(); } - return ResponseEntity.ok() + ResponseEntity.BodyBuilder resp = ResponseEntity.ok() .header("Content-Type", "text/plain; charset=UTF-8") - .body(sb.toString()); + .header("X-Log-Position", String.valueOf(nextPos)); + + if (reset) resp.header("X-Log-Reset", "true"); + return resp.body(sb.toString()); } } diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index 0607d7d..2e77eb0 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -16,6 +16,11 @@ +


해당 실행이 실패하는 경우는

+1. 해당 서버에서는 안쓰는 기능이라 bean 생성이 false로 되어있음.
+2. 1번에 해당하지 않으면 로그에 출력됨. 추적해야함.
+3. 전체로그를 보는건 append 과정이 상당히 오래 걸림. 전체를 봐야하는 경우가 아니라면 라인수 정해서 볼것.
+4. 전체로그 보는기능 주석함. [실시간 로그 tail -f] 와 비슷한 기능이라 봐야함. 전체가 필요하면 로그파일 찾아볼것.

diff --git a/src/main/resources/templates/logs.html b/src/main/resources/templates/logs.html index 3a8f05a..ce0dfae 100644 --- a/src/main/resources/templates/logs.html +++ b/src/main/resources/templates/logs.html @@ -14,7 +14,7 @@ - + @@ -36,66 +36,59 @@ let running = false; let lineQueue = []; let maxLines = 3000; + let cursor = null; // 🔑 서버에서 받은 X-Log-Position 저장 + + function appendOneLine(line) { + const div = document.createElement("div"); + div.textContent = line; + div.style.opacity = "0"; + div.style.transform = "translateY(2px)"; + div.style.transition = "opacity 0.15s ease, transform 0.15s ease"; + container.appendChild(div); + while (container.childNodes.length > maxLines) container.removeChild(container.firstChild); + requestAnimationFrame(() => { div.style.opacity = "1"; div.style.transform = "translateY(0)"; }); + const nearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < 40; + if (nearBottom) container.scrollTop = container.scrollHeight; + } async function initLogs() { const selected = lineSel.value; const param = selected === "-1" ? "" : `?lines=${selected}`; - container.innerHTML = ""; // 초기화 - - try { - const res = await fetch(`/logs/init${param}`, { cache: "no-store" }); - if (!res.ok) return; - const text = await res.text(); - if (!text) return; - - const lines = text.split("\n").filter(l => l.length > 0); - lines.forEach(appendOneLine); - } catch (e) { - console.debug("initLogs error:", e); - } - } + container.innerHTML = ""; - async function fetchLogs() { - try { - const res = await fetch("/logs/stream", { cache: "no-store" }); - if (!res.ok) return; - const text = await res.text(); - if (!text) return; + const res = await fetch(`/logs/init${param}`, { cache: "no-store" }); + const text = await res.text(); - const lines = text.split("\n").filter(l => l.length > 0); - lineQueue.push(...lines); + // 🔑 초기 포인터 저장 + const pos = res.headers.get("X-Log-Position"); + if (pos) cursor = Number(pos); - if (lineQueue.length > 100_000) lineQueue = lineQueue.slice(-50_000); - } catch (e) { - console.debug("fetchLogs error:", e); - } + if (text.trim()) text.split("\n").forEach(l => l && appendOneLine(l)); } - function appendOneLine(line) { - const div = document.createElement("div"); - div.textContent = line; - div.style.opacity = "0"; - div.style.transform = "translateY(2px)"; - div.style.transition = "opacity 0.15s ease, transform 0.15s ease"; - container.appendChild(div); + async function fetchLogs() { + // 🔑 클라이언트가 cursor를 들고 있다가 파라미터로 전달 + const url = (cursor == null) ? "/logs/stream" : `/logs/stream?from=${cursor}`; + const res = await fetch(url, { cache: "no-store" }); + const text = await res.text(); - while (container.childNodes.length > maxLines) { - container.removeChild(container.firstChild); - } + // 🔑 새 포인터로 갱신 + const pos = res.headers.get("X-Log-Position"); + if (pos) cursor = Number(pos); - requestAnimationFrame(() => { - div.style.opacity = "1"; - div.style.transform = "translateY(0)"; - }); + // 파일 리셋 감지(선택): 필요시 재-init + // if (res.headers.get("X-Log-Reset") === "true") { await initLogs(); return; } - const nearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < 40; - if (nearBottom) container.scrollTop = container.scrollHeight; + if (text.trim()) { + const lines = text.split("\n").filter(Boolean); + lineQueue.push(...lines); + if (lineQueue.length > 100_000) lineQueue = lineQueue.slice(-50_000); + } } function startRenderLoop() { const baseMs = Number(speedSel.value) || 25; if (renderTimer) clearInterval(renderTimer); - renderTimer = setInterval(() => { if (!running) return; const burst = Math.min(5, Math.max(1, Math.floor(lineQueue.length / 200))); @@ -113,8 +106,7 @@ startBtn.disabled = true; stopBtn.disabled = false; - await initLogs(); // ✅ 선택된 라인 수만큼 초기 로드 - + await initLogs(); // 최근 N줄 + cursor 수신 pollingTimer = setInterval(fetchLogs, 2000); fetchLogs(); startRenderLoop(); @@ -128,10 +120,7 @@ if (renderTimer) { clearInterval(renderTimer); renderTimer = null; } } - speedSel.addEventListener("change", () => { - if (running) startRenderLoop(); - }); - + speedSel.addEventListener("change", () => { if (running) startRenderLoop(); }); startBtn.addEventListener("click", start); stopBtn.addEventListener("click", stop); window.addEventListener("beforeunload", stop);