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

master
Kurt92 4 months ago
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());
}
}

@ -10,54 +10,73 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.Optional;
import java.util.function.Consumer;
@Slf4j
@RestController
@RequiredArgsConstructor
public class RunController {
private final WarSyncScheduler warSyncScheduler;
private final SinmungoInOutScheduler sinmungoInOutScheduler;
private final TaxSunapScheduler taxSunapScheduler;
private final Optional<WarSyncScheduler> warSyncScheduler;
private final Optional<SinmungoInOutScheduler> sinmungoInOutScheduler;
private final Optional<TaxSunapScheduler> taxSunapScheduler;
// 공통 실행 헬퍼
private static <T> ResponseEntity<String> runIfPresent(
Optional<T> opt,
Consumer<T> action,
String notAvailableMsg
) {
return opt.map(bean -> {
action.accept(bean);
return ResponseEntity.ok("Success");
})
.orElseGet(() -> ResponseEntity.status(503).body(notAvailableMsg));
}
@PostMapping("/menual/update-war")
public ResponseEntity<?> updateWar() throws IOException {
warSyncScheduler.checkAndDeploy();
return ResponseEntity.ok("Success");
public ResponseEntity<String> updateWar() {
return runIfPresent(
warSyncScheduler,
WarSyncScheduler::checkAndDeploy,
"WarSyncScheduler not available (scheduler.update.enabled=false?)"
);
}
@PostMapping("/menual/sinmungo-polling")
public ResponseEntity<?> sinmungoPolling() throws IOException {
sinmungoInOutScheduler.sinmungoInOutScheduler();
return ResponseEntity.ok("Success");
public ResponseEntity<String> sinmungoPolling() {
// 네가 사용한 메서드명을 그대로 호출
return runIfPresent(
sinmungoInOutScheduler,
SinmungoInOutScheduler::sinmungoInOutScheduler,
"SinmungoInOutScheduler not available (scheduler.smg.enabled=false?)"
);
}
@PostMapping("/menual/sinmungo-send-answer")
public ResponseEntity<?> sinmungoSendAnswer() throws IOException {
sinmungoInOutScheduler.sinmungoAnswerSendScheduler();
return ResponseEntity.ok("Success");
public ResponseEntity<String> sinmungoSendAnswer() {
return runIfPresent(
sinmungoInOutScheduler,
SinmungoInOutScheduler::sinmungoAnswerSendScheduler,
"SinmungoInOutScheduler not available (scheduler.smg.enabled=false?)"
);
}
@PostMapping("/menual/restart-parking-app")
public ResponseEntity<?> restartParkingApp() throws IOException {
// 클린파킹 수동 실행
public ResponseEntity<String> restartParkingApp() {
// TODO: 실제 재시작 로직 추가 (예: 서비스 호출/스크립트 실행 등)
log.info("[manual] restart-parking-app requested");
return ResponseEntity.ok("Success");
}
@PostMapping("/menual/tax-sunap")
public ResponseEntity<?> taxSunap() throws IOException {
taxSunapScheduler.taxSunapScheduler();
return ResponseEntity.ok("Success");
public ResponseEntity<String> taxSunap() {
return runIfPresent(
taxSunapScheduler,
TaxSunapScheduler::taxSunapScheduler,
"TaxSunapScheduler not available (scheduler.tax.enabled=false?)"
);
}
}

@ -13,4 +13,10 @@ public class ViewController {
return "home";
}
@GetMapping("/logs")
public String logs() {
return "logs";
}
}

@ -37,7 +37,7 @@ public class SinmungoInOutScheduler {
/** esb에이전트 xml파일 읽기 */
@Scheduled(fixedRate = 10 * 60 * 1000) // 10분
public void sinmungoInOutScheduler() throws IOException {
public void sinmungoInOutScheduler() {
try{
log.info("신문고 신고 폴링 스케쥴러 시작!");
//setinfo 테이블에서 esb에이전트 정보 조회
@ -56,6 +56,7 @@ public class SinmungoInOutScheduler {
// cp와 ep 디비가 따로있다고함.
//deptCode로 cp/ep 대상 분리
// save cp
log.info(parseResult.size() + "개의 before parse 데이터");
List<SinmungoDto.SinmungoXml> cpList = parseResult.stream()
.filter(item -> {
try {
@ -74,6 +75,7 @@ public class SinmungoInOutScheduler {
dbPolling.saveCP(cpList, setInfo);
log.info("CP DB Insert Complete!");
// save ep
log.info(parseResult.size() + "개의 before parse 데이터");
List<SinmungoDto.SinmungoXml> epList = parseResult.stream()
.filter(item -> {
try {

@ -1,16 +1,6 @@
server:
port: 8011
scheduler:
smg:
enabled: false
epost:
enabled: true
tax-sunap:
enabled: false
update:
enabled: false
spring:
datasource:
# 122번 서버 보면 클린파킹 많은데 cp1이 최신임. cp1기준으로 작업.
@ -38,6 +28,16 @@ spring:
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
scheduler:
smg:
enabled: true
epost:
enabled: false
tax-sunap:
enabled: false
update:
enabled: false
esb:
info:
cp:
@ -79,6 +79,10 @@ e-post:
logging:
file:
name: ./cc-logs/worker.log
# charset:
# file: UTF-8
level:
root: info
org.springframework.scheduling: info

@ -1,15 +1,7 @@
server:
port: 8011
scheduler:
smg:
enabled: false
epost:
enabled: true
tax-sunap:
enabled: false
update:
enabled: false
spring:
datasource:
@ -38,6 +30,16 @@ spring:
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
scheduler:
smg:
enabled: true
epost:
enabled: false
tax-sunap:
enabled: false
update:
enabled: false
esb:
info:
cp:
@ -70,6 +72,10 @@ e-post:
logging:
file:
name: ./cc-logs/worker.log
charset:
file: UTF-8
level:
root: info
org.springframework.scheduling: info

@ -1,16 +1,6 @@
server:
port: 8011
scheduler:
smg:
enabled: false
epost:
enabled: true
tax-sunap:
enabled: false
update:
enabled: false
spring:
datasource:
url: jdbc:mariadb://211.119.124.122:53306/cleanparking2?useUnicode=true&characterEncoding=utf8
@ -27,6 +17,16 @@ spring:
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
scheduler:
smg:
enabled: true
epost:
enabled: false
tax-sunap:
enabled: false
update:
enabled: false
esb:
info:
xmlDir:
@ -47,6 +47,10 @@ tax-else:
logging:
file:
name: ./cc-logs/worker.log
charset:
file: UTF-8
level:
root: info
org.hibernate.SQL: debug

@ -20,7 +20,7 @@
<hr>
<a href="/manual/logs">🪵 로그 보기</a>
<a href="/logs">🪵 로그 보기</a>
</body>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!--<script type="text/javascript" src="/lib/jquery.js"></script>-->

@ -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…
Cancel
Save