feat : 시스템 실시간 로그확인 페이지 추가

master
Kurt92 4 months ago
parent 880584d0d7
commit acc767f585

@ -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<String> initLogs(@RequestParam(defaultValue = "5000") int lines) throws IOException {
public ResponseEntity<String> 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<String> getNewLogs() throws IOException {
public ResponseEntity<String> 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());
}
}

@ -16,6 +16,11 @@
<!-- 필요한 스케쥴러 추가 -->
</select>
<button id="runSchedulerBtn">실행</button>
<br><br><br>해당 실행이 실패하는 경우는<br><br>
1. 해당 서버에서는 안쓰는 기능이라 bean 생성이 false로 되어있음.<br>
2. 1번에 해당하지 않으면 로그에 출력됨. 추적해야함.<br>
3. 전체로그를 보는건 append 과정이 상당히 오래 걸림. 전체를 봐야하는 경우가 아니라면 라인수 정해서 볼것.<br>
4. 전체로그 보는기능 주석함. [실시간 로그 tail -f] 와 비슷한 기능이라 봐야함. 전체가 필요하면 로그파일 찾아볼것.<br>
<hr>

@ -14,7 +14,7 @@
<option value="1000" selected>1000</option>
<option value="5000">5000</option>
<option value="10000">10000</option>
<option value="-1">전체</option>
<!-- <option value="-1">전체</option>-->
</select>
</label>
</div>
@ -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);

Loading…
Cancel
Save