HANTE 1 hónapja
szülő
commit
2e193c74d5

+ 1 - 1
conf/icmp-ping-server.pid

@@ -1 +1 @@
-38088
+22460

+ 6 - 17
src/main/java/com/its/icmp/ping/server/controller/PageApiController.java

@@ -2,6 +2,7 @@ package com.its.icmp.ping.server.controller;
 
 import com.its.icmp.ping.server.config.ScheduleJobConfig;
 import com.its.icmp.ping.server.config.ServerConfig;
+import com.its.icmp.ping.server.dto.FileViewRequestDto;
 import com.its.icmp.ping.server.dto.FileViewResponseDto;
 import com.its.icmp.ping.server.dto.LoginRequestDto;
 import com.its.icmp.ping.server.dto.LoginResponseDto;
@@ -25,7 +26,6 @@ import org.springframework.web.servlet.ModelAndView;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpSession;
-import java.io.UnsupportedEncodingException;
 import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
@@ -125,7 +125,6 @@ public class PageApiController {
 
     @GetMapping("/facility/{facility}")
     public ResponseEntity<?> getFacilityData(@PathVariable("facility") String facility) {
-        log.info("getFacilityData: facility={}", facility);
         ConcurrentHashMap<String, voIcmpCtlr> facilityMap = this.icmpPingService.getIcmpCtlrMap().get(facility);
         if (facilityMap == null) {
             return errorResponse(HttpStatus.NOT_FOUND, "Facility not found: " + facility);
@@ -155,26 +154,16 @@ public class PageApiController {
             HttpServletRequest req,
             HttpServletResponse res,
             ModelAndView mav
-    ) throws UnsupportedEncodingException {
-        return fileService.fileDownload(fileName, filePath);
+    ) {
+        return this.fileService.fileDownload(fileName, filePath);
     }
 
-//    @PostMapping(value = "/view", produces = "application/json")
-//    public String getFileView(
-//            @RequestParam("fileName") String fileName,
-//            @RequestParam("filePath") String filePath,
-//            HttpServletRequest request
-//    ) {
-//        return fileService.getView(request, fileName, filePath); // ✅ 그대로 사용 가능
-//    }
     @PostMapping(value = "/log-files/view", produces = "application/json")
     public FileViewResponseDto getFileView(
-            @RequestParam("filePath") String filePath,
-            @RequestParam("fileName") String fileName,
-            HttpServletRequest request
+            @RequestBody FileViewRequestDto req
     ) {
-//        log.info("getFileView: filePath={}, fileName={}, preEndPoint={}", filePath, fileName, request.getParameter("preEndPoint"));
-        return this.fileService.getView(request, fileName, filePath);
+//        log.info("getFileView: filePath={}, fileName={}, preEndPoint={}", req.getFilePath(), req.getFileName(), req.getPreEndPoint());
+        return this.fileService.getView(req.getFilePath(), req.getFileName(), req.getPreEndPoint());
     }
 
     @PostMapping(value = "/ping-test")

+ 17 - 0
src/main/java/com/its/icmp/ping/server/dto/FileViewRequestDto.java

@@ -0,0 +1,17 @@
+package com.its.icmp.ping.server.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class FileViewRequestDto {
+    private String fileName;
+    private String filePath;
+    private Long preEndPoint;
+
+}

+ 4 - 11
src/main/java/com/its/icmp/ping/server/service/FileService.java

@@ -21,10 +21,11 @@ import java.io.IOException;
 import java.io.RandomAccessFile;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
 
+import static java.nio.file.Paths.get;
+
 @Service
 public class FileService {
 	private static final Logger log = LoggerFactory.getLogger(FileService.class);
@@ -32,15 +33,7 @@ public class FileService {
 	private final String[] exceptDir = new String[]{"backup"};
 	private int id = 1;
 
-	public FileViewResponseDto getView(HttpServletRequest request, String fileName, String filePath) {
-		long preEndPoint = 0L;
-		try {
-			String value = request.getParameter("preEndPoint");
-			if (value != null && !value.trim().isEmpty()) {
-				preEndPoint = Long.parseLong(value.trim());
-			}
-		} catch (NumberFormatException ignored) {}
-
+	public FileViewResponseDto getView(String filePath, String fileName, Long preEndPoint) {
 		StringBuilder sb = new StringBuilder();
 		RandomAccessFile file = null;
 		long endPoint = 0L;
@@ -141,7 +134,7 @@ public class FileService {
 
 	public ResponseEntity<Resource> fileDownload(String fileName, String filePath) {
 		try {
-			Path path = Paths.get(System.getProperty("user.dir") + filePath + fileName);
+			Path path = get(System.getProperty("user.dir") + filePath + fileName);
 			String contentType = "application/download";
 			HttpHeaders headers = new HttpHeaders();
 			headers.add("Content-Type", contentType);

+ 20 - 32
src/main/resources/static/css/logFiles.css

@@ -21,25 +21,41 @@
 }
 .log-table td:first-child {
     text-align: left;
-    width: 200px;
     font-size: 16px;
 }
 
 .log-table td:nth-child(2) {
     text-align: center;
-    width: 80px;
 }
 
 .log-table td:nth-child(3) {
     text-align: center;
-    width: 150px;
 }
 
+.log-table td:nth-child(1),
+.log-table th:nth-child(1) {
+    width: 400px;
+    text-align: left;
+}
+
+.log-table td:nth-child(2),
+.log-table th:nth-child(2) {
+    width: 80px;
+    text-align: center;
+}
+
+.log-table td:nth-child(3),
+.log-table th:nth-child(3) {
+    width: 150px;
+    text-align: center;
+}
 
 .filename-cell {
     max-width: 480px;
     word-break: break-word;
-    white-space: normal;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
     font-weight: bold;
     text-align: left;
     color: #333;
@@ -56,34 +72,6 @@
     font-size: 0.9em;
 }
 
-/* 📄 로그 뷰 영역 */
-#logScroll {
-    width: 98%;
-    height: 660px;
-    overflow: auto;
-    border: 1px solid #ccc;
-    background: #fff;
-    margin: 5px auto;
-    font-family: monospace;
-    font-size: 13px;
-    box-sizing: border-box;
-}
-
-.log-line {
-    font-family: Consolas, monospace;
-    font-size: 13px;
-    white-space: pre-wrap;
-    line-height: 1.5em;
-    text-align: left !important;
-}
-
-.loading-text,
-.placeholder-text {
-    color: gray;
-    text-align: center;
-    font-style: italic;
-}
-
 .error-text {
     color: crimson;
     font-weight: bold;

+ 51 - 22
src/main/resources/static/css/main.css

@@ -4,11 +4,6 @@ body {
     background-color: #f4f6f8;
 }
 
-/*.app-header {*/
-/*    align: center;*/
-/*}*/
-
-/* 공통 테이블 스타일 추출 */
 .app-title-table,
 .app-time-table {
     width: 100%;
@@ -22,7 +17,6 @@ body {
 .app-time-table {
 }
 
-/* 🎯 제목 텍스트 */
 .app-title-text,
 .app-time-text {
     font-family: Tahoma, sans-serif;
@@ -36,7 +30,6 @@ body {
     text-align: center;
     padding: 10px 0;
 }
-/* 🕒 시간 텍스트 */
 .app-time-text {
     /*font-weight: bold;*/
     /*color: black;*/
@@ -46,19 +39,6 @@ body {
 }
 
 
-
-
-
-
-
-
-/*.app-header {*/
-/*    background-color: #fff;*/
-/*    padding: 16px;*/
-/*    text-align: center;*/
-/*    position: relative;*/
-/*}*/
-
 .navigation {
     background-color: #fff;
     text-align: center;
@@ -96,6 +76,7 @@ body {
 }
 
 .content-view {
+    width: 100%;
     background-color: #fff;
     /*padding: 24px;*/
     margin: 0;
@@ -128,7 +109,7 @@ legend {
 }
 
 .sub-detail-info table {
-    margin: 0 auto;
+    margin: 0 0;
 }
 
 .button-link {
@@ -144,4 +125,52 @@ legend {
 .button-link:hover {
     color: #005fa3;
     text-decoration: underline;
-}
+}
+
+
+.loading-text,
+.placeholder-text {
+    color: gray;
+    text-align: center;
+    font-style: italic;
+}
+
+/* 하단 상세정보 표출 css */
+.detail-fieldset {
+    width: 100%;
+    margin-top: 20px;
+    box-sizing: border-box;
+}
+#detail-legend {
+    text-align: center;     /* legend 텍스트 중앙 정렬 */
+}
+
+/* 상단 오른쪽 명령 버튼 */
+.right-top-button {
+    text-align: right;
+    margin: 10px 50px;
+}
+
+/* 상단 정보 목록 테이블 div */
+.info-table-wrapper {
+    overflow-x: auto;
+}
+
+#logScroll {
+    width: 100%;
+    height: 400px;
+    overflow-x: auto;
+    overflow-y: auto;
+    border: 1px solid #ccc;
+    background: #fff;
+    padding: 8px;
+    box-sizing: border-box;
+}
+
+.log-line {
+    font-family: Consolas, monospace;
+    font-size: 13px;
+    white-space: pre-wrap;
+    line-height: 1.5em;
+    text-align: left !important;
+}

+ 0 - 52
src/main/resources/static/css/pingPage.css

@@ -1,23 +1,3 @@
-.pingpage-container {
-    width: 100%;
-    padding: 16px;
-    box-sizing: border-box;
-}
-
-.right-align,
-.ping-controls {
-    text-align: right;
-    margin: 10px 30px;
-}
-
-.ping-controls {
-    margin: 10px 0;
-}
-
-.ping-table-wrapper {
-    overflow-x: auto;
-}
-
 .ping-table {
     width: 70%;
     border-collapse: collapse;
@@ -34,43 +14,11 @@
     word-break: keep-all;
 }
 
-.loading-text,
-.placeholder-text {
-    color: gray;
-    text-align: center;
-    font-style: italic;
-}
-
 .error-text {
     color: crimson;
     font-weight: bold;
 }
 
-#FileForm {
-    width: 100%;
-    margin-top: 20px;
-    box-sizing: border-box;
-}
-
-#logScroll {
-    width: 100%;
-    height: 660px;
-    overflow-x: auto;
-    overflow-y: auto;
-    border: 1px solid #ccc;
-    background: #fff;
-    padding: 8px;
-    box-sizing: border-box;
-}
-
-.log-line {
-    font-family: Consolas, monospace;
-    font-size: 13px;
-    white-space: pre-wrap;
-    line-height: 1.5em;
-    text-align: left;
-}
-
 .alive {
     color: green;
     font-weight: bold;

+ 0 - 6
src/main/resources/static/css/system.css

@@ -1,9 +1,3 @@
-.content {
-    width: 600px;
-    margin: 40px auto;
-    font-family: "Segoe UI", "Tahoma", sans-serif;
-    font-size: 14px;
-}
 
 .system-table {
     width: 500px;

+ 67 - 15
src/main/resources/static/js/common.js

@@ -11,25 +11,77 @@ export function clearApiToken() {
     localStorage.removeItem("authToken");
 }
 
-export function apiRequest(method, url, body = null) {
+export function apiRequest(method, url, payload = null, {
+    expectJson = true,
+    useForm = false,
+    timeout = 10000,
+    requireAuth = true  // 👈 기본은 true, 로그인 등은 false
+} = {}) {
     const token = getApiToken();
-    if (!token) return Promise.reject(new Error("토큰 없음"));
+    if (requireAuth && !token) {
+        return Promise.reject("토큰 없음");
+    }
 
-    const normalizedMethod = method.toUpperCase();
 
-    const options = {
-        method: normalizedMethod,
-        headers: {
-            "Authorization": "Bearer " + token,
-            "Content-Type": "application/json"
-        }
+    const normalizedMethod = method.toUpperCase();
+    const headers = {
+        "Authorization": `Bearer ${token}`
     };
 
-    if (body) {
-        options.body = JSON.stringify(body);
+    let fullUrl = url;
+    let body = null;
+
+    // GET/DELETE = 쿼리스트링
+    if ((normalizedMethod === "GET" || normalizedMethod === "DELETE") && payload) {
+        const params = new URLSearchParams(payload).toString();
+        fullUrl += "?" + params;
     }
+    // POST/PUT = JSON 또는 x-www-form-urlencoded
+    else if (payload) {
+        if (useForm) {
+            headers["Content-Type"] = "application/x-www-form-urlencoded";
+            body = new URLSearchParams(payload).toString();
+        } else {
+            headers["Content-Type"] = "application/json";
+            body = JSON.stringify(payload);
+        }
+    }
+
+    // 타임아웃 처리
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+    return fetch(fullUrl, {
+        method: normalizedMethod,
+        headers,
+        body,
+        signal: controller.signal
+    }).then(res =>
+        res.text().then(raw => {
+            clearTimeout(timeoutId);
+            if (!res.ok) {
+                return Promise.reject({
+                    status: res.status,
+                    message: res.statusText || "요청 실패",
+                    body: raw
+                });
+            }
 
-    return fetch(url, options).then(res =>
-        res.ok ? res.json() : Promise.reject(res)
-    );
-}
+            try {
+                return expectJson ? JSON.parse(raw) : raw;
+            } catch {
+                return raw;
+            }
+        })
+    ).catch(err => {
+        clearTimeout(timeoutId);
+        if (err.name === "AbortError") {
+            return Promise.reject({ status: 408, message: "요청 시간이 초과되었습니다." });
+        }
+        return Promise.reject({
+            status: err.status || 500,
+            message: err.message || "알 수 없는 오류",
+            body: err.body || null
+        });
+    });
+}

+ 116 - 81
src/main/resources/static/js/logFiles.js

@@ -1,49 +1,58 @@
 import { apiRequest } from "./common.js";
+import timerManager from "./timerManager.js";
 
-let _fileViewTimer = null;
+let _logMsgBody = null;
+let _logMsgScrollBox = null;
+
+let _endPoint = 0;
+let _currentFilePath = "";
+let _currentFileName = "";
 
 export function main(container) {
 	container.innerHTML = `
-  <section class="sub-main-info">
-    <fieldset>
-      <legend>📜 Log Files</legend>
-      <div>
-        <table class="log-table" id="logFiles">
-          <thead>
-            <tr>
-              <th width="70%">파일명</th>
-              <th width="10%">내용</th>
-              <th width="20%">동작</th>
-            </tr>
-          </thead>
-          <tbody id="logTbody">
-            <tr>
-              <td colspan="3" class="loading-text">로딩 중입니다...</td>
-            </tr>
-          </tbody>
-        </table>
-      </div>
-    </fieldset>
-  </section>
-
-  <section class="sub-detail-info">
-    <div class="content">
-      <fieldset id="FileForm">
-        <legend id="logFileName">Log File View</legend>
-        <div id="logScroll">
-          <table>
-            <tbody id="fileTbody">
-              <tr>
-                <td class="placeholder-text">목록에서 파일을 선택해주세요.</td>
-              </tr>
-            </tbody>
-          </table>
-        </div>
-      </fieldset>
-    </div>
-  </section>
+	  <section class="sub-main-info">
+		<fieldset>
+		  <legend>📜 Log Files</legend>
+		
+		  <div class="right-top-button">
+			<button id="stopLogFetch" class="button-link">Log File 보기 멈춤</button>
+		  </div>
+		
+		  <div class="info-table-wrapper">
+			<table  id="logFiles" class="log-table">
+			  <thead>
+				<tr>
+				  <th>파일명</th>
+				  <th>내용</th>
+				  <th>동작</th>
+				</tr>
+			  </thead>
+			  <tbody id="logTbody">
+				<tr>
+				  <td colspan="3" class="loading-text">로딩 중입니다...</td>
+				</tr>
+			  </tbody>
+			</table>
+		  </div>
+		</fieldset>
+	  </section>
+	
+	  <section class="sub-detail-info">
+	  <fieldset class="detail-fieldset">
+		<legend id="detail-legend">Log File View</legend>
+		<div id="logScroll">
+		  <table>
+			<tbody id="fileTbody">
+			  <tr><td class="placeholder-text">목록에서 파일을 선택해주세요.</td></tr>
+			</tbody>
+		  </table>
+		</div>
+	  </fieldset>
+	  </section>
   `;
 
+	_logMsgBody = document.getElementById("fileTbody");
+	_logMsgScrollBox = document.getElementById("logScroll");
 
 	apiRequest("GET", "/api/log-files")
 		.then(data => {
@@ -61,6 +70,8 @@ export function main(container) {
 			document.getElementById("logTbody").innerHTML =
 				`<tr><td colspan="3" class="error-text">📛 ${err}</td></tr>`;
 		});
+
+	document.getElementById("stopLogFetch").addEventListener("click", stopLogFetchTimer);
 }
 
 function renderTree(node, parentId, tbody) {
@@ -93,8 +104,12 @@ function renderTree(node, parentId, tbody) {
 		link.addEventListener("click", e => {
 			e.preventDefault();
 			const action = link.dataset.action;
-			if (action === "view") loadLog(link.dataset.path, link.dataset.name);
-			else if (action === "download") downloadLog(link.dataset.path, link.dataset.name);
+			if (action === "view") {
+				loadLog(link.dataset.path, link.dataset.name);
+			}
+			else if (action === "download") {
+				downloadLog(link.dataset.path, link.dataset.name);
+			}
 		});
 	});
 }
@@ -104,46 +119,66 @@ function downloadLog(filePath, fileName) {
 	window.open(url, "_blank");
 }
 
+function startLogFetchTimer() {
+	timerManager.start("logFilesTimer", () => {
+		requestLog();
+	}, 2000);
+}
+
+function stopLogFetchTimer() {
+	timerManager.stop("logFilesTimer");
+	_endPoint = 0;
+	_currentFilePath = null;
+	_currentFileName = null;
+}
+
+function requestLog() {
+	if (!_currentFileName || !_currentFilePath) return;
+
+	apiRequest("POST", "/api/log-files/view", {
+		fileName: _currentFileName,
+		filePath: _currentFilePath,
+		preEndPoint: _endPoint
+	}).then(data => {
+		if (!data?.log) return;
+		_endPoint = data.endPoint;
+		appendLog(data.log);
+	}).catch(err => {
+		console.error("로그 요청 오류:", err);
+	});
+}
+
+
 function loadLog(filePath, fileName) {
-	if (_fileViewTimer) clearInterval(_fileViewTimer);
-
-	const fileTbody = document.getElementById("fileTbody");
-	const scrollBox = document.getElementById("logScroll");
-	document.getElementById("logFileName").textContent = fileName;
-
-	fileTbody.innerHTML = "";
-	let endPoint = 0;
-
-	function requestLog() {
-		$.post("/api/log-files/view", {
-			fileName, filePath, preEndPoint: endPoint
-		}).done(data => {
-			if (!data?.log) return;
-			endPoint = data.endPoint;
-
-			const tr = document.createElement("tr");
-			const td = document.createElement("td");
-
-			// const safeHTML = data.log
-			// 	.replaceAll("&", "&amp;")
-			// 	.replaceAll("<", "&lt;")
-			// 	.replaceAll(">", "&gt;")
-			// 	.replaceAll("&lt;br&gt;", "<br>");
-
-			td.className = "log-line";
-
-			td.innerHTML = data.log;
-			// td.textContent = data.log;
-			// td.style.whiteSpace = "pre-wrap";
-
-			tr.appendChild(td);
-			fileTbody.appendChild(tr);
-			scrollBox.scrollTop = scrollBox.scrollHeight;
-		}).fail(err => {
-			console.error("로그 요청 오류:", err);
-		});
-	}
+	stopLogFetchTimer();
+
+	document.getElementById("detail-legend").textContent = fileName;
+
+	_logMsgBody.innerHTML = "";
+
+	_endPoint = 0;
+	_currentFilePath = filePath;
+	_currentFileName = fileName;
 
 	requestLog();
-	_fileViewTimer = setInterval(requestLog, 2000);
-}
+	startLogFetchTimer();
+}
+
+function appendLog(logMsg) {
+	const tr = document.createElement("tr");
+	const td = document.createElement("td");
+
+	td.className = "log-line";
+	td.innerHTML = logMsg;
+	tr.appendChild(td);
+	_logMsgBody.appendChild(tr);
+
+	if (_logMsgScrollBox) {
+		_logMsgScrollBox.scrollTop = _logMsgScrollBox.scrollHeight;
+	}
+}
+
+export function cleanupLogFilesView() {
+	stopLogFetchTimer();
+}
+

+ 35 - 12
src/main/resources/static/js/login.js

@@ -1,4 +1,6 @@
-import { setApiToken } from "./common.js";
+import { apiRequest, setApiToken } from "./common.js";
+import timerManager from "./timerManager.js";
+
 
 function main() {
 	const form = document.getElementById("loginForm");
@@ -14,17 +16,13 @@ function main() {
 		errorBox.style.display = "none";
 
 		try {
-			const res = await fetch("/api/login", {
-				method: "POST",
-				headers: { "Content-Type": "application/json" },
-				body: JSON.stringify({ userId, password })
+			const data = await apiRequest("POST", "/api/login", {
+				userId,
+				password
+			}, {
+				requireAuth: false
 			});
 
-			if (!res.ok) {
-				throw new Error("로그인 실패");
-			}
-
-			const data = await res.json();
 			setApiToken(data.token);
 			location.href = "/main";
 
@@ -54,6 +52,31 @@ function updateDateTime() {
 	el.textContent = `${year}-${month}-${date} ${hour}:${min}:${sec}`;
 }
 
+function startLoginTimer() {
+	timerManager.start("loginTimer", () => {
+		const el = document.getElementById("serverTime");
+		if (!el) return;
+
+		const now = new Date();
+		const year = now.getFullYear();
+		const month = pad(now.getMonth() + 1);
+		const date = pad(now.getDate());
+		const hour = pad(now.getHours());
+		const min = pad(now.getMinutes());
+		const sec = pad(now.getSeconds());
+
+		el.textContent = `${year}-${month}-${date} ${hour}:${min}:${sec}`;
+	}, 1000);
+}
+
+function stopLoginTimer() {
+	timerManager.stop("loginTimer");
+}
+
 main(); // 실행 시작
-updateDateTime();
-setInterval(updateDateTime, 1000);
+startLoginTimer();
+
+window.addEventListener("beforeunload", () => {
+	stopLoginTimer();
+});
+

+ 27 - 24
src/main/resources/static/js/main.js

@@ -1,9 +1,9 @@
 import { getApiToken, clearApiToken, apiRequest } from "./common.js";
 import { renderMenus, loadView } from "./menu.js";
+import timerManager from "./timerManager.js";
 
-const serverTimeBox = document.getElementById("serverTime");
-let serverTime = null;
-let timer = null;
+const _serverTimeBox = document.getElementById("serverTime");
+let _serverTime = null;
 
 function main() {
     const token = getApiToken();
@@ -13,12 +13,11 @@ function main() {
         return;
     }
 
-    apiRequest("get", "/api/menus")
+    apiRequest("GET", "/api/menus")
         .then(data => {
-            serverTime = new Date(data.serverTime);
+            _serverTime = new Date(data.serverTime);
             renderMenus(data.menus || []);
-            startClock();
-            // loadView("system");
+            startMainTimer();
         })
         .catch(() => {
             alert("세션이 만료되었거나 인증 실패했습니다.");
@@ -27,26 +26,30 @@ function main() {
         });
 }
 
-function startClock() {
-    updateClock();
-    clearInterval(timer);
-    timer = setInterval(() => {
-        serverTime.setSeconds(serverTime.getSeconds() + 1);
-        updateClock();
-    }, 1000);
-}
+function startMainTimer() {
+    timerManager.start("mainHeartbeat", () => {
+        const pad = n => String(n).padStart(2, "0");
+
+        _serverTime.setSeconds(_serverTime.getSeconds() + 1);
 
-function updateClock() {
-    const pad = n => String(n).padStart(2, "0");
+        const y = _serverTime.getFullYear();
+        const m = pad(_serverTime.getMonth() + 1);  // 월은 0부터 시작
+        const d = pad(_serverTime.getDate());
+        const h = pad(_serverTime.getHours());
+        const mi = pad(_serverTime.getMinutes());
+        const s = pad(_serverTime.getSeconds());
 
-    const y = serverTime.getFullYear();
-    const m = pad(serverTime.getMonth() + 1);  // 월은 0부터 시작
-    const d = pad(serverTime.getDate());
-    const h = pad(serverTime.getHours());
-    const mi = pad(serverTime.getMinutes());
-    const s = pad(serverTime.getSeconds());
+        _serverTimeBox.textContent = `${y}-${m}-${d} ${h}:${mi}:${s}`;
+    }, 1000);
+}
 
-    serverTimeBox.textContent = `${y}-${m}-${d} ${h}:${mi}:${s}`;
+function stopMainTimer() {
+    timerManager.stop("mainHeartbeat");
 }
 
 main();
+
+window.addEventListener("beforeunload", () => {
+    stopMainTimer();
+    timerManager.stopAll(); // 메인이 종료됨으로 모든 타이머 정지
+});

+ 9 - 1
src/main/resources/static/js/menu.js

@@ -1,4 +1,7 @@
 import {apiRequest, clearApiToken} from "./common.js";
+import {cleanupLogFilesView} from "./logFiles.js";
+
+let _currentViewName = null;
 
 export function renderMenus(facilities) {
     const menu = document.getElementById("menu");
@@ -32,6 +35,11 @@ export function loadView(viewName, props = {}) {
     const container = document.getElementById("view");
     const jsPath = `/js/${viewName}.js`;
 
+    if (_currentViewName === "logFiles") {
+        cleanupLogFilesView();
+    }
+    _currentViewName = viewName;
+
     import(jsPath)
         .then(mod => {
             if (typeof mod.main === "function") {
@@ -48,7 +56,7 @@ export function loadView(viewName, props = {}) {
 }
 
 function logout() {
-    apiRequest("post", "/api/logout")
+    apiRequest("POST", "/api/logout")
         .finally(() => {
             clearApiToken();
             location.href = "/login";

+ 68 - 66
src/main/resources/static/js/pingPage.js

@@ -1,60 +1,62 @@
 import { apiRequest } from "./common.js";
 
 let _controllerList = [];
+let _logMsgBody = null;
+let _logMsgScrollBox = null;
 
 export function main(container, { facility } = {}) {
 	const facilityCode = facility?.name ?? "-";
 	const facilityDesc = facility?.description ?? "-";
 
 	container.innerHTML = `
-<!--    <div class="pingpage-container">-->
-    <section class="sub-main-info">
-  <fieldset>
-    <legend>🏭 ${facilityDesc} Network Ping Information</legend>
-
-    <div class="ping-controls">
-      <button id="pingAllBtn" class="button-link">전체 Ping</button>
-    </div>
-
-    <div class="ping-table-wrapper">
-      <table id="facilityTable" class="ping-table">
-        <thead>
-          <tr>
-            <th>관리번호</th>
-            <th>ID</th>
-            <th>명칭</th>
-            <th>IP</th>
-            <th>상태</th>
-            <th>마지막 ping</th>
-            <th>명령</th>
-          </tr>
-        </thead>
-        <tbody id="tableBody">
-          <tr>
-            <td colspan="7" class="loading-text">로딩 중입니다...</td>
-          </tr>
-        </tbody>
-      </table>
-    </div>
-  </fieldset>
-</section>
+	<section class="sub-main-info">
+	<fieldset>
+		<legend>🏭 ${facilityDesc} Network Ping Information</legend>
+		
+		<div class="right-top-button">
+			<button id="pingAllBtn" class="button-link">전체 Ping</button>
+		</div>
+		
+		<div class="info-table-wrapper">
+			<table id="facilityTable" class="ping-table">
+				<thead>
+					<tr>
+						<th>관리번호</th>
+						<th>ID</th>
+						<th>명칭</th>
+						<th>IP</th>
+						<th>상태</th>
+						<th>마지막 ping</th>
+						<th>명령</th>
+					</tr>
+				</thead>
+				<tbody id="tableBody">
+					<tr>
+					<td colspan="7" class="loading-text">로딩 중입니다...</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+	</fieldset>
+	</section>
 
 	<section class="sub-detail-info">
-      <div class="content">
-        <fieldset id="FileForm">
-          <legend id="logFileName">Ping Test</legend>
-          <div id="logScroll">
-            <table><tbody id="fileTbody">
-              <tr><td class="placeholder-text">Ping 로그를 확인하세요.</td></tr>
-            </tbody></table>
-          </div>
-        </fieldset>
-      </div>
+	<fieldset class="detail-fieldset">
+	  <legend id="detail-legend">Network Ping Test</legend>
+	  <div id="logScroll">
+		<table>
+		<tbody id="fileTbody">
+		  <tr><td class="placeholder-text">Ping 로그를 확인하세요.</td></tr>
+		</tbody>
+		</table>
+	  </div>
+	</fieldset>
     </section>
-      
-<!--    </div>-->
   `;
 
+	_logMsgBody = document.getElementById("fileTbody");
+	_logMsgScrollBox = document.getElementById("logScroll");
+
 	apiRequest("GET", `/api/facility/${facilityCode}`)
 		.then(list => {
 			_controllerList = list || [];
@@ -89,7 +91,7 @@ export function main(container, { facility } = {}) {
 	document.getElementById("pingAllBtn").addEventListener("click", allControllerPing);
 }
 
-function controllerPing(id, name, ip) {
+function pingText(id, name, ip) {
 	if (!ip || ip === "NULL") {
 		appendLog(`${id}, ${name}, IP 주소가 없습니다.`);
 		return;
@@ -97,42 +99,42 @@ function controllerPing(id, name, ip) {
 
 	appendLog(`ping: ${id}, ${name}, ${ip}  =====> <br/>`);
 
-	fetch("/api/ping-test", {
-		method: "POST",
-		headers: { "Content-Type": "application/x-www-form-urlencoded" },
-		body: `ipAddr=${encodeURIComponent(ip)}`
-	})
-		.then(res => res.ok ? res.text() : Promise.reject("서버 오류"))
-		.then(data => {
-			appendLog(`&nbsp;<===== ${data}<br/>`);
-		})
-		.catch(error => {
-			console.error("ping error:", error);
-			appendLog(`&nbsp;&nbsp;&nbsp;&nbsp;<span style="color:red;">Error: ${error}</span><br/>`);
-		});
+	apiRequest("POST", "/api/ping-test", { ipAddr: ip }, {
+		useForm: true,
+		expectJson: false
+	}).then(data => {
+		appendLog(`&nbsp;<===== ${data}<br/>`);
+	}).catch(error => {
+		console.error("ping error:", error);
+		appendLog(`&nbsp;<===== Error: <span style="color:red;">${error.message || error}</span><br/>`);
+	});
+}
+
+function controllerPing(id, name, ip) {
+	_logMsgBody.innerHTML = ""; // 초기화
+	pingText(id, name, ip);
 }
 
 function allControllerPing() {
-	const tbody = document.getElementById("fileTbody");
-	tbody.innerHTML = ""; // 초기화
+	_logMsgBody.innerHTML = ""; // 초기화
 
 	_controllerList.forEach(({ ctlrIp, ctlrId, istlLctnNm }) => {
 		if (ctlrIp && ctlrIp !== "NULL") {
-			controllerPing(ctlrId, istlLctnNm, ctlrIp);
+			pingText(ctlrId, istlLctnNm, ctlrIp);
 		}
 	});
 }
 
-function appendLog(htmlString) {
-	const tbody = document.getElementById("fileTbody");
+function appendLog(logMsg) {
 	const tr = document.createElement("tr");
 	const td = document.createElement("td");
 
-	td.innerHTML = htmlString;
 	td.className = "log-line";
+	td.innerHTML = logMsg;
 	tr.appendChild(td);
-	tbody.appendChild(tr);
+	_logMsgBody.appendChild(tr);
 
-	const scrollDiv = document.getElementById("logScroll");
-	if (scrollDiv) scrollDiv.scrollTop = scrollDiv.scrollHeight;
+	if (_logMsgScrollBox) {
+		_logMsgScrollBox.scrollTop = _logMsgScrollBox.scrollHeight;
+	}
 }

+ 21 - 0
src/main/resources/static/js/timerManager.js

@@ -0,0 +1,21 @@
+const timerManager = {
+    timers: {},
+
+    start(name, fn, interval = 1000) {
+        this.stop(name);  // 중복 방지
+        this.timers[name] = setInterval(fn, interval);
+    },
+
+    stop(name) {
+        if (this.timers[name]) {
+            clearInterval(this.timers[name]);
+            delete this.timers[name];
+        }
+    },
+
+    stopAll() {
+        Object.keys(this.timers).forEach(name => this.stop(name));
+    }
+};
+
+export default timerManager;