feat : 시스템 실시간 로그확인 페이지 추가
parent
8064b72996
commit
880584d0d7
@ -1,4 +1,88 @@
|
||||
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 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;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
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 {
|
||||
Path path = Paths.get(logPath);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
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++;
|
||||
}
|
||||
pointer--;
|
||||
}
|
||||
|
||||
// 최근 N줄 위치로 이동
|
||||
raf.seek(Math.max(0, pointer + 2));
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// append 전용 포인터 저장
|
||||
lastReadPosition = raf.getFilePointer();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", "text/plain; charset=UTF-8")
|
||||
.body(sb.toString());
|
||||
}
|
||||
|
||||
// 이후 append 용
|
||||
@GetMapping("/logs/stream")
|
||||
public ResponseEntity<String> getNewLogs() throws IOException {
|
||||
Path path = Paths.get(logPath);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) {
|
||||
raf.seek(lastReadPosition);
|
||||
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();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", "text/plain; charset=UTF-8")
|
||||
.body(sb.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<button id="start">Start</button>
|
||||
<button id="stop" disabled>Stop</button>
|
||||
<label>속도
|
||||
<select id="speed">
|
||||
<option value="40">느림</option>
|
||||
<option value="25">보통</option>
|
||||
<option value="12">빠름</option>
|
||||
<option value="5" selected>매우 빠름</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>라인 수
|
||||
<select id="lines">
|
||||
<option value="1000" selected>1000</option>
|
||||
<option value="5000">5000</option>
|
||||
<option value="10000">10000</option>
|
||||
<option value="-1">전체</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="log-container"
|
||||
style="white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
background:#0b0f19; color:#e6e6e6; padding:12px; border-radius:8px; height:800px; overflow:auto;">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const container = document.getElementById("log-container");
|
||||
const startBtn = document.getElementById("start");
|
||||
const stopBtn = document.getElementById("stop");
|
||||
const speedSel = document.getElementById("speed");
|
||||
const lineSel = document.getElementById("lines");
|
||||
|
||||
let pollingTimer = null;
|
||||
let renderTimer = null;
|
||||
let running = false;
|
||||
let lineQueue = [];
|
||||
let maxLines = 3000;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 lines = text.split("\n").filter(l => l.length > 0);
|
||||
lineQueue.push(...lines);
|
||||
|
||||
if (lineQueue.length > 100_000) lineQueue = lineQueue.slice(-50_000);
|
||||
} catch (e) {
|
||||
console.debug("fetchLogs error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)));
|
||||
for (let i = 0; i < burst; i++) {
|
||||
const line = lineQueue.shift();
|
||||
if (!line) break;
|
||||
appendOneLine(line);
|
||||
}
|
||||
}, baseMs);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
|
||||
await initLogs(); // ✅ 선택된 라인 수만큼 초기 로드
|
||||
|
||||
pollingTimer = setInterval(fetchLogs, 2000);
|
||||
fetchLogs();
|
||||
startRenderLoop();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running = false;
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
if (pollingTimer) { clearInterval(pollingTimer); pollingTimer = null; }
|
||||
if (renderTimer) { clearInterval(renderTimer); renderTimer = null; }
|
||||
}
|
||||
|
||||
speedSel.addEventListener("change", () => {
|
||||
if (running) startRenderLoop();
|
||||
});
|
||||
|
||||
startBtn.addEventListener("click", start);
|
||||
stopBtn.addEventListener("click", stop);
|
||||
window.addEventListener("beforeunload", stop);
|
||||
</script>
|
||||
Loading…
Reference in New Issue