(function () { "use strict"; let currentPath = document.body.dataset.currentPath || window.location.pathname; const loadedUIVersion = document.body.dataset.uiVersion || ""; let portSettingsPath = /^\/ports\/(?:[1-9]|[1-3][0-9]|4[0-8])\/(?:parameters|operation-1|operation-2|data-log|advanced)$/.test(currentPath); const toastRoot = document.getElementById("toastRoot"); const portVisualStates = new Map(); const portSyncIntervalMs = 500; const dashboardSyncIntervalMs = 5000; const portRuntimeStatusPath = "/ports/runtime-status"; const portStateConfirmationSamples = 2; const portUnknownConfirmationSamples = 8; let portSyncTimer = null; let portSyncInFlight = false; let dashboardSyncTimer = null; let dashboardSyncInFlight = false; let networkModeTimer = null; let disposeLogPage = null; const persistentTerminalNavigation = { active: false, // Keep same-origin console navigation in-process from the first screen. // Waiting until a terminal session is active causes every normal menu // click to reload the complete document, scripts, and API initialization. spaActive: true, terminalContent: null, terminalPath: "", navigating: false, pendingNavigation: null, sequence: 0 }; if (portSettingsPath) { document.body.dataset.portLoadState = "loading"; } function isPortDebugPath(path) { return path === "/port-debug" || /^\/port-debug\/(?:[1-9]|[1-3][0-9]|4[0-8])$/.test(path); } function setCurrentPath(path) { if (disposeLogPage) { disposeLogPage(); disposeLogPage = null; } currentPath = path || "/"; portSettingsPath = /^\/ports\/(?:[1-9]|[1-3][0-9]|4[0-8])\/(?:parameters|operation-1|operation-2|data-log|advanced)$/.test(currentPath); document.body.dataset.currentPath = currentPath; if (portSettingsPath) { document.body.dataset.portLoadState = "loading"; } else { delete document.body.dataset.portLoadState; } } function sameOriginConsoleURL(target) { let url; try { url = new URL(target, window.location.href); } catch (error) { return null; } if (url.origin !== window.location.origin || url.pathname.startsWith("/api/") || url.pathname.startsWith("/static/") || url.pathname === "/login" || url.pathname === "/logout") { return null; } return url; } function copyNavigationChrome(nextDocument, nextPath) { const nextBody = nextDocument.body; ["currentRole", "currentTabKey", "currentTabLabel"].forEach(function (key) { if (nextBody.dataset[key] !== undefined) { document.body.dataset[key] = nextBody.dataset[key]; } }); const currentCrumbs = document.querySelector(".topbar-crumbs"); const nextCrumbs = nextDocument.querySelector(".topbar-crumbs"); if (currentCrumbs && nextCrumbs) { currentCrumbs.innerHTML = nextCrumbs.innerHTML; } const nextLinks = new Map(); nextDocument.querySelectorAll(".nav a[href]").forEach(function (link) { const url = new URL(link.getAttribute("href"), window.location.origin); const kind = link.hasAttribute("data-nav-toggle") ? "group:" : "page:"; nextLinks.set(kind + url.pathname, link); }); document.querySelectorAll(".nav a[href]").forEach(function (link) { const url = new URL(link.getAttribute("href"), window.location.origin); const kind = link.hasAttribute("data-nav-toggle") ? "group:" : "page:"; const nextLink = nextLinks.get(kind + url.pathname); if (nextLink) { // Parent toggles and child links can share a URL. Preserve their // structural classes so navigation cannot change menu dimensions. link.classList.toggle("active", nextLink.classList.contains("active")); } else { link.classList.toggle("active", url.pathname === nextPath); } }); const nextGroups = Array.from(nextDocument.querySelectorAll("[data-nav-group]")); document.querySelectorAll("[data-nav-group]").forEach(function (group) { const path = group.getAttribute("data-path"); const nextGroup = nextGroups.find(function (candidate) { return candidate.getAttribute("data-path") === path; }); if (!nextGroup) { return; } group.classList.toggle("active", nextGroup.classList.contains("active")); if (nextGroup.classList.contains("expanded")) { group.classList.add("expanded"); } }); const currentTabs = document.querySelector(".workspace-tabs"); if (typeof window.teraAppSyncNavigation === "function") { // app.js owns tab persistence and installs the close handlers. Rebuild // the tabs instead of importing inert server-rendered close buttons. window.teraAppSyncNavigation(); } else if (currentTabs) { let activeTab = null; currentTabs.querySelectorAll(".workspace-tab").forEach(function (tab) { const active = tab.dataset.tabKey === nextBody.dataset.currentTabKey || tab.dataset.tabPath === nextPath; tab.classList.toggle("active", active); if (active) { activeTab = tab; } }); if (!activeTab) { const nextActiveTab = nextDocument.querySelector(".workspace-tab.active"); if (nextActiveTab) { currentTabs.appendChild(document.importNode(nextActiveTab, true)); } } } } async function navigateWithinConsole(target, historyMode) { const url = sameOriginConsoleURL(target); if (!url) { return; } if (persistentTerminalNavigation.navigating) { // Keep only the latest destination while the current page initializes. // Overlapping initializers can otherwise write into the next page's DOM. persistentTerminalNavigation.pendingNavigation = { target: url.href, historyMode: historyMode }; return; } const sequence = ++persistentTerminalNavigation.sequence; persistentTerminalNavigation.navigating = true; try { const response = await fetch(url.pathname + url.search, { method: "GET", headers: { Accept: "text/html" }, credentials: "same-origin", cache: "no-store" }); if (response.status === 401 || response.redirected && new URL(response.url).pathname === "/login") { persistentTerminalNavigation.pendingNavigation = null; window.location.assign(response.redirected ? response.url : "/login?next=" + encodeURIComponent(url.pathname)); return; } if (!response.ok) { throw new Error("화면을 불러오지 못했습니다. (HTTP " + response.status + ")"); } const nextDocument = new DOMParser().parseFromString(await response.text(), "text/html"); const nextUIVersion = nextDocument.body.dataset.uiVersion || ""; // A login in another browser tab can replace the shared cookie. Rebuild // all navigation/handlers instead of keeping the old account's chrome. if (["workspaceId", "currentUser", "currentRole"].some(function (key) { return document.body.dataset[key] !== nextDocument.body.dataset[key]; })) { persistentTerminalNavigation.pendingNavigation = null; window.location.assign(url.href); return; } if (loadedUIVersion && nextUIVersion && loadedUIVersion !== nextUIVersion) { persistentTerminalNavigation.pendingNavigation = null; window.location.assign(url.href); return; } const nextContent = nextDocument.querySelector(".content"); const currentContent = document.querySelector(".content"); if (!nextContent || !currentContent || sequence !== persistentTerminalNavigation.sequence) { throw new Error("화면 구성 정보를 불러오지 못했습니다."); } if (persistentTerminalNavigation.active && isPortDebugPath(currentPath) && currentContent.querySelector("[data-serial-terminal]")) { persistentTerminalNavigation.terminalContent = currentContent; persistentTerminalNavigation.terminalPath = currentPath; } else if (isPortDebugPath(currentPath)) { persistentTerminalNavigation.terminalContent = null; persistentTerminalNavigation.terminalPath = ""; } const restoringTerminal = persistentTerminalNavigation.active && isPortDebugPath(url.pathname) && persistentTerminalNavigation.terminalContent; const replacement = restoringTerminal ? persistentTerminalNavigation.terminalContent : document.importNode(nextContent, true); if (!restoringTerminal) { replacement.setAttribute("data-i18n-skip", "true"); } currentContent.replaceWith(replacement); setCurrentPath(url.pathname); copyNavigationChrome(nextDocument, url.pathname); document.title = nextDocument.title || document.title; if (historyMode !== "none") { const nextAddress = url.pathname + url.search + url.hash; if (historyMode === "replace") { window.history.replaceState({ teracloneDynamic: true }, "", nextAddress); } else { window.history.pushState({ teracloneDynamic: true }, "", nextAddress); } } if (!restoringTerminal) { window.teraAppBindContent?.(); try { await initializeCurrentPage(); } finally { replacement.removeAttribute("data-i18n-skip"); if (window.teraI18n?.language) { window.teraI18n.setLanguage(window.teraI18n.language); } } } document.dispatchEvent(new CustomEvent("teraclone:navigation", { detail: { path: url.pathname } })); } finally { persistentTerminalNavigation.navigating = false; const pending = persistentTerminalNavigation.pendingNavigation; persistentTerminalNavigation.pendingNavigation = null; if (pending) { navigateWithinConsole(pending.target, pending.historyMode).catch(report); } } } function requestConsoleNavigation(target, historyMode) { if (!persistentTerminalNavigation.active && !persistentTerminalNavigation.spaActive) { return false; } const url = sameOriginConsoleURL(target); if (!url) { return false; } persistentTerminalNavigation.spaActive = true; navigateWithinConsole(url.href, historyMode || "push").catch(report); return true; } window.teraNavigate = requestConsoleNavigation; window.teraReload = function () { return requestConsoleNavigation(window.location.href, "replace"); }; document.addEventListener("click", function (event) { if ((!persistentTerminalNavigation.active && !persistentTerminalNavigation.spaActive) || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } const target = event.target; const link = target instanceof Element ? target.closest("a[href]") : null; if (!link || link.hasAttribute("download") || link.hasAttribute("data-nav-toggle") || (link.target && link.target !== "_self")) { return; } const url = sameOriginConsoleURL(link.href); if (!url) { return; } if (!persistentTerminalNavigation.navigating && url.pathname === currentPath && url.search === window.location.search) { if (!url.hash && link.closest(".nav, .workspace-tabs, .subtabs")) { event.preventDefault(); } return; } event.preventDefault(); requestConsoleNavigation(url.href, "push"); }, true); window.addEventListener("popstate", function () { if (!persistentTerminalNavigation.spaActive) { return; } navigateWithinConsole(window.location.href, "none").catch(report); }); function toast(message, tone) { if (!toastRoot) { return; } const normalizedTone = ["success", "error", "info"].includes(tone) ? tone : "info"; const item = document.createElement("div"); item.className = "toast toast-" + normalizedTone; item.setAttribute("role", normalizedTone === "error" ? "alert" : "status"); item.textContent = message; toastRoot.appendChild(item); window.setTimeout(function () { item.remove(); }, 3200); } async function api(path, options) { const requestOptions = Object.assign({}, options || {}); requestOptions.headers = Object.assign({}, requestOptions.headers || {}); const isBinaryBody = requestOptions.body instanceof Blob || requestOptions.body instanceof ArrayBuffer || ArrayBuffer.isView(requestOptions.body) || requestOptions.body instanceof URLSearchParams; if (requestOptions.body && !(requestOptions.body instanceof FormData) && !isBinaryBody && typeof requestOptions.body !== "string") { requestOptions.headers["Content-Type"] = "application/json"; requestOptions.body = JSON.stringify(requestOptions.body); } const response = await fetch("/api/v1" + path, requestOptions); const contentType = response.headers.get("Content-Type") || ""; const payload = contentType.includes("application/json") ? await response.json().catch(function () { return {}; }) : await response.text(); if (response.status === 401) { window.location.assign("/login?next=" + encodeURIComponent(currentPath)); throw new Error("로그인 세션이 종료되었습니다. 다시 로그인하세요."); } if (response.status === 403 && payload?.error?.code === "password_change_required" && currentPath !== "/password") { window.location.assign("/password"); throw new Error("처음 로그인한 계정은 비밀번호를 먼저 변경해야 합니다."); } if (!response.ok) { const message = payload && payload.error && payload.error.message ? payload.error.message : (payload && payload.message ? payload.message : "백엔드 요청에 실패했습니다."); throw new Error(message); } return payload; } function report(error) { console.error(error); toast(error instanceof Error ? error.message : "요청 처리 중 오류가 발생했습니다.", "error"); } function form() { return document.querySelector("[data-api-form]"); } function field(root, name) { return root ? root.querySelector('[name="' + name + '"]') : null; } function value(root, name) { const input = field(root, name); return input ? input.value.trim() : ""; } function checked(root, name) { const input = field(root, name); return Boolean(input && input.checked); } function setValue(root, name, next) { const input = field(root, name); if (!input || next === undefined || next === null) { return; } if (input.type === "checkbox") { input.checked = Boolean(next); return; } input.value = String(next); } function setAutomatedFieldVisibility(root, names, visible) { names.forEach(function (name) { const input = field(root, name); const row = input?.closest(".form-row"); if (!input || !row) { return; } if (!Object.prototype.hasOwnProperty.call(input.dataset, "automationRequired")) { input.dataset.automationRequired = input.required ? "true" : "false"; input.dataset.automationDisabled = input.disabled ? "true" : "false"; } row.classList.toggle("automation-hidden", !visible); input.disabled = !visible || input.dataset.automationDisabled === "true"; input.required = visible && input.dataset.automationRequired === "true"; }); } function safeGeneratedID(parts, fallback) { const generated = parts.join("-").toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/-+/g, "-") .replace(/^[-_]+|[-_]+$/g, "") .slice(0, 56) .replace(/[-_]+$/g, ""); return generated || fallback; } function uniqueGeneratedID(base, existingIDs) { if (!existingIDs.has(base)) { return base; } let suffix = 2; while (existingIDs.has((base.slice(0, 60) + "-" + suffix).slice(0, 64))) { suffix += 1; } return (base.slice(0, 60) + "-" + suffix).slice(0, 64); } function integer(root, name, fallback) { const parsed = Number.parseInt(value(root, name), 10); return Number.isFinite(parsed) ? parsed : fallback; } function selectedFile(root, name) { const input = field(root, name); return input && input.files && input.files.length ? input.files[0] : null; } async function readTextFile(file, maximumBytes) { if (!file) { throw new Error("파일을 선택하세요."); } if (maximumBytes && file.size > maximumBytes) { throw new Error("선택한 파일이 허용 크기를 초과했습니다."); } return file.text(); } function downloadText(filename, content, contentType) { const blob = new Blob([content], { type: contentType || "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename || "download.txt"; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(function () { URL.revokeObjectURL(url); }, 0); } function csvValues(text) { return String(text || "").split(/[;,\n]/).map(function (item) { return item.trim(); }).filter(Boolean); } async function applyPendingConfiguration(options) { const includeNetwork = Boolean(options && options.includeNetwork); let status = await api("/config/status"); const pendingSections = status.pending_sections || []; if (pendingSections.includes("network_interfaces")) { if (!includeNetwork) { throw new Error("네트워크 보류 설정은 네트워크 메뉴에서 적용하세요."); } await api("/network/apply", { method: "POST" }); status = await api("/config/status"); } if ((Number(status.pending_changes) || 0) > 0) { await api("/config/apply", { method: "POST" }); await api("/config/save", { method: "POST" }); } return status; } function markIntegrated(root) { if (!root) { return; } root.dataset.integrated = "true"; root.querySelectorAll("[data-ui-action]").forEach(function (button) { button.dataset.integrated = "true"; }); } function bindForm(root, save) { if (!root) { return; } markIntegrated(root); root.addEventListener("submit", async function (event) { event.preventDefault(); try { await save(); await applyPendingConfiguration(); toast("설정을 장비에 적용하고 영구 저장했습니다.", "success"); } catch (error) { report(error); } }); } function bindNetworkForm(root, save) { if (!root) { return; } markIntegrated(root); const applyButton = root.querySelector('[data-ui-action="apply"]'); if (!applyButton) { bindForm(root, save); return; } applyButton.textContent = "적용"; let busy = false; function setBusy(next) { busy = next; applyButton.disabled = next; applyButton.title = next ? "네트워크 설정을 적용하고 있습니다." : ""; } async function waitForNetworkReconnect() { const deadline = Date.now() + 45000; while (Date.now() < deadline) { try { const status = await api("/network/apply-status", { cache: "no-store" }); const state = String(status?.state || ""); if (state === "confirmed" || state === "idle") { return { network_apply: state === "confirmed" ? status : null }; } if (state.startsWith("rolled_back") || state === "rollback_failed") { throw new Error("네트워크 설정이 안전을 위해 이전 값으로 복구되었습니다. 장비 설정을 확인하세요."); } } catch (error) { if (!(error instanceof TypeError)) { throw error; } } await new Promise(function (resolve) { window.setTimeout(resolve, 1000); }); } throw new Error("네트워크 적용 후 다시 연결하지 못했습니다. 변경한 장비 IP로 접속하거나 시리얼 콘솔에서 상태를 확인하세요."); } async function applyNetwork() { try { return await api("/network/apply", { method: "POST", keepalive: true }); } catch (error) { if (!(error instanceof TypeError)) { throw error; } toast("네트워크가 재시작되었습니다. 장비 재연결과 저장 상태를 확인하고 있습니다.", "info"); return waitForNetworkReconnect(); } } root.addEventListener("submit", async function (event) { event.preventDefault(); if (busy) { return; } try { setBusy(true); await save(); const result = await applyNetwork(); toast(result.network_apply ? "네트워크 설정을 실제 장비에 적용하고 영구 저장했습니다." : "적용할 네트워크 변경사항이 없습니다.", result.network_apply ? "success" : "info"); } catch (error) { report(error); } finally { setBusy(false); } }); } function panelByTitle(title) { const translatedTitle = window.teraI18n?.t ? window.teraI18n.t(title) : title; return Array.from(document.querySelectorAll(".panel")).find(function (panel) { const heading = (panel.querySelector(".panel-header h2")?.textContent || "").trim(); return heading === title || heading === translatedTitle; }); } function renderKeyValues(title, entries) { const table = panelByTitle(title)?.querySelector(".kv-table"); if (!table) { return; } const existingRows = Array.from(table.querySelectorAll(":scope > .kv-row")); const reusable = existingRows.length === entries.length && entries.every(function (entry, index) { return (existingRows[index].querySelector(".kv-key")?.textContent || "") === String(entry[0]); }); if (reusable) { entries.forEach(function (entry, index) { const valueElement = existingRows[index].querySelector(".kv-value"); const nextValue = entry[1] === undefined || entry[1] === null ? "-" : String(entry[1]); if (valueElement && valueElement.textContent !== nextValue) { valueElement.textContent = nextValue; } }); return; } table.replaceChildren(); entries.forEach(function (entry) { const row = document.createElement("div"); row.className = "kv-row"; const key = document.createElement("div"); key.className = "kv-key"; key.textContent = entry[0]; const val = document.createElement("div"); val.className = "kv-value"; val.textContent = entry[1]; row.append(key, val); table.appendChild(row); }); } function renderRows(title, rows, columnCount) { const body = panelByTitle(title)?.querySelector("tbody"); if (!body) { return; } body.replaceChildren(); if (!rows.length) { const row = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = columnCount; cell.className = "empty-state"; cell.textContent = "데이터가 없습니다."; row.appendChild(cell); body.appendChild(row); return; } rows.forEach(function (cells) { const row = document.createElement("tr"); cells.forEach(function (cellValue) { const cell = document.createElement("td"); cell.textContent = cellValue === undefined || cellValue === null ? "-" : String(cellValue); row.appendChild(cell); }); body.appendChild(row); }); } function duration(seconds) { const total = Math.max(0, Number(seconds) || 0); const days = Math.floor(total / 86400); const hours = Math.floor((total % 86400) / 3600); const minutes = Math.floor((total % 3600) / 60); return days + "일 " + hours + "시간 " + minutes + "분"; } function formatBytes(bytes) { let size = Number(bytes) || 0; const units = ["B", "KB", "MB", "GB"]; let index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; } return (index === 0 ? size : size.toFixed(1)) + " " + units[index]; } function formatTime(input) { const date = new Date(input); return Number.isNaN(date.getTime()) ? String(input || "-") : date.toLocaleString("ko-KR"); } function portNumber(channel) { return Number.parseInt(String(channel || "").replace(/\D/g, ""), 10) || 0; } function classifyPort(port) { // The API applies the same CR response grace period as the event observer. // Cached counters and a skipped probe must not independently resurrect UP. const status = String(port.status || "").toLowerCase(); const connectedUsers = Number(port.connected_users) || 0; if (status === "session") { const sessionCount = Math.max(connectedUsers, 1); return { active: true, tone: "session", label: "Session_" + sessionCount, sessionCount: sessionCount, summary: "포트 세션 " + sessionCount + "명 연결 중" }; } if (status === "down") { return { active: false, tone: "error", label: "DOWN", summary: "링크 신호 끊김 확인" }; } if (status === "up") { return { active: true, tone: "connected", label: "UP", summary: "상대 장비 응답 확인 · 일시적 응답 누락은 15초간 재확인" }; } return { active: false, tone: "waiting", label: "Unknown", summary: "장비 응답 미확인 · 응답이 없다는 이유만으로 케이블 단절을 판정할 수 없습니다." }; } function normalizePortView(view) { if (/^SESSION_[1-9][0-9]*$/i.test(String(view.label || ""))) { return Object.assign({}, view, { active: true, tone: "session", visualKey: "session" }); } if (view.active) { return Object.assign({}, view, { active: true, tone: "connected", label: "UP", visualKey: "up" }); } if (view.tone === "error" || String(view.label || "").toUpperCase() === "DOWN") { return Object.assign({}, view, { active: false, tone: "error", label: "DOWN", visualKey: "down" }); } return Object.assign({}, view, { active: false, tone: "waiting", label: "Unknown", visualKey: "unknown" }); } function stabilizePortView(port, rawView) { const channel = String(port.channel || ""); const next = normalizePortView(rawView); const current = portVisualStates.get(channel); if (!current) { portVisualStates.set(channel, { stable: next, candidateKey: "", candidate: null, samples: 0 }); return next; } if (current.stable.visualKey === next.visualKey) { current.stable = next; current.candidateKey = ""; current.candidate = null; current.samples = 0; return current.stable; } if (current.candidateKey === next.visualKey) { current.samples += 1; current.candidate = next; } else { current.candidateKey = next.visualKey; current.candidate = next; current.samples = 1; } const requiredSamples = ["session", "up", "down"].includes(next.visualKey) ? 1 : (next.visualKey === "unknown" && current.stable.visualKey !== "unknown" ? portUnknownConfirmationSamples : portStateConfirmationSamples); if (current.samples >= requiredSamples) { current.stable = current.candidate; current.candidateKey = ""; current.candidate = null; current.samples = 0; } return current.stable; } function decoratePortStates(ports) { ports.forEach(function (port) { port.__linkView = stabilizePortView(port, classifyPort(port)); }); return ports; } function portView(port) { return port.__linkView || classifyPort(port); } function portIsUp(port) { return portView(port).active; } function updatePortMap(ports) { if (ports.some(function (port) { return !port.__linkView; })) { decoratePortStates(ports); } const byNumber = new Map(ports.map(function (port) { return [portNumber(port.channel), port]; })); document.querySelectorAll(".port-link:not([data-dashboard-port]), .port-link-card").forEach(function (element) { const numberElement = element.querySelector(".port-link-number"); const number = Number.parseInt(numberElement?.textContent || element.dataset.portCard || "", 10); const port = byNumber.get(number); if (!port) { return; } const view = portView(port); const visualKey = view.visualKey || "unknown"; [["up", visualKey === "up"], ["down", visualKey === "down"], ["session", visualKey === "session"], ["signal", view.tone === "signal"], ["error", view.tone === "error"]].forEach(function (entry) { if (element.classList.contains(entry[0]) !== entry[1]) { element.classList.toggle(entry[0], entry[1]); } }); const state = element.querySelector(".port-link-state"); if (state && state.textContent !== view.label) { state.textContent = view.label; } const alias = String(port.alias || ""); const aliasElement = element.querySelector("[data-port-alias]"); if (aliasElement) { if (aliasElement.textContent !== (alias || "—")) { aliasElement.textContent = alias || "—"; } if (aliasElement.title !== alias) { aliasElement.title = alias; } const button = element.matches("[data-port-view]") ? element : element.querySelector("[data-port-view]"); if (button) { const description = "Port " + number + (alias ? " · " + alias : "") + " · " + view.label; if (button.title !== description) { button.title = description; } const accessibleLabel = description + " 개별 보기"; if (button.getAttribute("aria-label") !== accessibleLabel) { button.setAttribute("aria-label", accessibleLabel); } } } }); } function renderPortStatusRows(ports) { const rows = ports.map(function (port) { const runtime = port.runtime || {}; const sessionIPs = (runtime.operations || []).find(function (operation) { return Array.isArray(operation.session_ips); })?.session_ips || []; return [port.channel, portView(port).label, port.alias || "—", port.mode, Number(port.connected_users) || 0, formatBytes(runtime.rx_bytes) + " / " + formatBytes(runtime.tx_bytes)].concat([0, 1, 2, 3].map(function (index) { return sessionIPs[index] || "—"; })); }); const body = panelByTitle("포트 상태 요약")?.querySelector("tbody"); if (!body) { return; } const existingRows = Array.from(body.rows); const reusable = existingRows.length === rows.length && existingRows.every(function (row) { return row.cells.length === 10; }); if (!reusable) { renderRows("포트 상태 요약", rows, 10); } else { rows.forEach(function (values, rowIndex) { values.forEach(function (cellValue, cellIndex) { if (cellIndex === 1) { return; } const nextText = cellValue === undefined || cellValue === null ? "-" : String(cellValue); const cell = existingRows[rowIndex].cells[cellIndex]; if (cell.textContent !== nextText) { cell.textContent = nextText; } }); }); } Array.from(body.rows).forEach(function (row, rowIndex) { row.cells[2].title = String(ports[rowIndex].alias || ""); const view = portView(ports[rowIndex]); const visualKey = ["up", "down", "session"].includes(view.visualKey) ? view.visualKey : "unknown"; const label = visualKey === "up" ? "UP" : visualKey === "down" ? "DOWN" : visualKey === "session" ? view.label : "Unknown"; const cell = row.cells[1]; let badge = cell.querySelector(".port-status-badge"); if (!badge) { badge = document.createElement("span"); const dot = document.createElement("span"); dot.className = "port-status-dot"; dot.setAttribute("aria-hidden", "true"); const text = document.createElement("span"); text.className = "port-status-label"; badge.append(dot, text); cell.replaceChildren(badge); } const className = "port-status-badge " + visualKey; if (badge.className !== className) { badge.className = className; } const text = badge.querySelector(".port-status-label"); if (text.textContent !== label) { text.textContent = label; } const summary = String(view.summary || label); if (badge.title !== summary) { badge.title = summary; } }); } async function synchronizePorts() { const isPortSettingsPage = /^\/ports\/(?:[1-9]|[1-3][0-9]|4[0-8])\/(?:parameters|operation-1|operation-2|data-log|advanced)$/.test(currentPath); if ((currentPath !== "/" && currentPath !== "/port-status" && !isPortSettingsPage) || portSyncInFlight || document.hidden) { return; } portSyncInFlight = true; try { const ports = decoratePortStates(await api(portRuntimeStatusPath)); updatePortMap(ports); if (currentPath === "/") { renderPortUsage(ports); } if (currentPath === "/port-status") { renderPortStatusRows(ports); } } catch (error) { console.error("port status synchronization failed", error); if (currentPath === "/") { window.teraDashboard?.syncState("port", "error"); } } finally { portSyncInFlight = false; } } function startPortSynchronization() { const isPortSettingsPage = /^\/ports\/(?:[1-9]|[1-3][0-9]|4[0-8])\/(?:parameters|operation-1|operation-2|data-log|advanced)$/.test(currentPath); if (currentPath !== "/" && currentPath !== "/port-status" && !isPortSettingsPage) { return; } if (portSyncTimer !== null) { return; } portSyncTimer = window.setInterval(synchronizePorts, portSyncIntervalMs); document.addEventListener("visibilitychange", function () { if (!document.hidden) { synchronizePorts(); } }); window.addEventListener("beforeunload", function () { if (portSyncTimer !== null) { window.clearInterval(portSyncTimer); } }); } function renderDashboardPanels(status, network) { if (document.querySelector("[data-dashboard]")) { window.teraDashboard?.renderStatus(status, network, { bytes: formatBytes, duration: duration, time: formatTime }); return; } const memoryTotal = Number(status.memory_total_bytes) || 0; const memoryUsed = Number(status.memory_used_bytes) || 0; const memoryAvailable = Number(status.memory_available_bytes) || 0; renderKeyValues("시스템 정보", [ ["모델", status.model], ["하드웨어 프로필", status.hardware_profile], ["빌드번호", status.build_number], ["장치 ID", status.device_id || "-"], ["펌웨어", status.firmware_version], ["가동 시간", duration(status.uptime_seconds)], ["시스템 시간", formatTime(status.system_time)], ["CPU 평균", (status.load_averages || []).join(" / ") || "-"] ]); renderKeyValues("네트워크 정보", (network.interfaces || []).map(function (item) { return [item.name, [item.device, item.address || "주소 없음", item.speed || "속도 미확인", item.link].join(" / ")]; }).concat([["호스트 이름", network.host_name], ["운영 모드", network.operating_mode]])); renderKeyValues("리소스 상태", [ ["CPU 사용률", status.cpu_usage_percent === null ? "-" : status.cpu_usage_percent + "%"], ["전체 메모리", memoryTotal > 0 ? formatBytes(memoryTotal) : "-"], ["사용 중 메모리", memoryTotal > 0 ? formatBytes(memoryUsed) : "-"], ["남은 메모리", memoryTotal > 0 ? formatBytes(memoryAvailable) : "-"], ["메모리 사용률", status.memory_usage_percent === null || status.memory_usage_percent === undefined ? "-" : status.memory_usage_percent + "%"], ["온도", status.temperature_celsius === null ? "-" : status.temperature_celsius + "°C"], ["활성 세션", status.active_sessions], ["활성 알람", status.active_alarms] ]); } function renderPortUsage(ports) { if (document.querySelector("[data-dashboard]")) { window.teraDashboard?.renderPorts(ports, formatBytes); return; } let activePorts = 0; let totalRX = 0; let totalTX = 0; const ranking = ports.map(function (port) { const runtime = port.runtime || {}; const rxBytes = Number(runtime.rx_bytes) || 0; const txBytes = Number(runtime.tx_bytes) || 0; const usedBytes = rxBytes + txBytes; totalRX += rxBytes; totalTX += txBytes; if (portIsUp(port)) { activePorts += 1; } return { channel: String(port.channel || "-"), bytes: usedBytes }; }).sort(function (left, right) { if (right.bytes !== left.bytes) { return right.bytes - left.bytes; } return portNumber(left.channel) - portNumber(right.channel); }).slice(0, 5); const rows = [ ["활성 포트", activePorts], ["누적 수신", formatBytes(totalRX)], ["누적 송신", formatBytes(totalTX)] ]; ranking.forEach(function (item, index) { rows.push([(index + 1) + "위", item.channel + " / " + formatBytes(item.bytes)]); }); renderKeyValues("포트 사용 현황", rows); } async function synchronizeDashboard() { if (dashboardSyncInFlight || document.hidden || currentPath !== "/") { return; } dashboardSyncInFlight = true; try { const results = await Promise.all([api("/system/status"), api("/network")]); renderDashboardPanels(results[0], results[1]); } catch (error) { console.error("dashboard synchronization failed", error); window.teraDashboard?.syncState("system", "error"); } finally { dashboardSyncInFlight = false; } } function startDashboardSynchronization() { if (currentPath !== "/") { return; } if (dashboardSyncTimer !== null) { return; } dashboardSyncTimer = window.setInterval(synchronizeDashboard, dashboardSyncIntervalMs); document.addEventListener("visibilitychange", function () { if (!document.hidden) { synchronizeDashboard(); } }); window.addEventListener("beforeunload", function () { if (dashboardSyncTimer !== null) { window.clearInterval(dashboardSyncTimer); } }); } async function dashboard() { // Each section can recover independently when one endpoint is unavailable. await Promise.all([synchronizeDashboard(), synchronizePorts()]); } function prefixToMask(prefix) { const bits = Math.max(0, Math.min(32, Number(prefix) || 0)); const octets = []; for (let index = 0; index < 4; index += 1) { const used = Math.max(0, Math.min(8, bits - index * 8)); octets.push(used === 0 ? 0 : 256 - Math.pow(2, 8 - used)); } return octets.join("."); } function maskToPrefix(mask) { return String(mask || "").split(".").reduce(function (count, octet) { const bits = (Number(octet) >>> 0).toString(2).padStart(8, "0"); return count + bits.replace(/0/g, "").length; }, 0); } function fieldGroupRows(root, group) { return Array.from(root.querySelectorAll('[data-field-group="' + group + '"]')); } function setFieldGroupVisible(root, group, visible) { fieldGroupRows(root, group).forEach(function (row) { row.hidden = !visible; }); } function renderInterfaceModeContext(root, state, interfaceName) { const grid = root.querySelector(".form-grid"); if (!grid) { return; } const mode = String(state.operating_mode || "single").toLowerCase(); const context = document.createElement("div"); context.className = "network-mode-context"; context.dataset.mode = mode; const title = document.createElement("strong"); const description = document.createElement("span"); if (mode === "oob") { const primary = interfaceName === "Interface-1"; title.textContent = primary ? "OOB 주 인터페이스" : "OOB 예비 인터페이스"; description.textContent = primary ? "Interface 1(eth0)을 우선 사용합니다." : "Interface 1 장애 시 Interface 2(eth1)로 전환합니다."; } else { title.textContent = "Single 독립 인터페이스"; description.textContent = interfaceName + "을 다른 인터페이스와 독립적으로 사용합니다."; } context.append(title, description); grid.prepend(context); } function bindIPv6Disclosure(root, item) { const rows = fieldGroupRows(root, "ipv6"); const grid = root.querySelector(".form-grid"); if (rows.length === 0 || !grid) { return; } if (grid.querySelector(":scope > .network-interface-columns")) { return; } const columns = document.createElement("div"); columns.className = "network-interface-columns"; const ipv4Column = document.createElement("div"); ipv4Column.className = "network-interface-column ipv4-column"; const ipv6Column = document.createElement("div"); ipv6Column.className = "network-interface-column ipv6-column"; Array.from(grid.children).filter(function (child) { return child.classList.contains("form-row") && child.dataset.fieldGroup !== "ipv6"; }).forEach(function (row) { ipv4Column.appendChild(row); }); rows.forEach(function (row) { row.hidden = false; ipv6Column.appendChild(row); }); const disclosure = document.createElement("div"); disclosure.className = "network-ipv6-disclosure"; const copy = document.createElement("div"); const title = document.createElement("strong"); const detail = document.createElement("span"); const mode = String(item.ipv6_mode || "auto").toUpperCase(); title.textContent = "IPv6 설정"; detail.textContent = "기본 숨김 · 현재 모드 " + mode; copy.append(title, detail); const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "btn secondary compact ipv6-visibility-button"; toggle.setAttribute("aria-expanded", "false"); let open = false; function render() { ipv6Column.hidden = !open; columns.classList.toggle("ipv6-open", open); toggle.textContent = open ? "IPv6 설정 숨기기" : "IPv6 설정 보기"; toggle.setAttribute("aria-expanded", String(open)); } toggle.addEventListener("click", function () { open = !open; render(); }); disclosure.append(copy, toggle); ipv4Column.appendChild(disclosure); columns.append(ipv4Column, ipv6Column); grid.appendChild(columns); render(); } function bindOperatingModeLayout(root, oobRuntime) { const modeSelect = field(root, "mode"); const advancedSelect = field(root, "advancedMode"); const modeRow = modeSelect ? modeSelect.closest(".form-row") : null; if (!modeSelect || !advancedSelect || !modeRow) { return; } const context = document.createElement("div"); context.className = "operating-mode-context"; modeRow.after(context); let runtime = oobRuntime || {}; function render() { const oobMode = value(root, "mode") === "oob"; if (oobMode && advancedSelect.value !== "single") { advancedSelect.value = "single"; } advancedSelect.disabled = oobMode; const advancedMode = value(root, "advancedMode") || "single"; setFieldGroupVisible(root, "oob-settings", oobMode); setFieldGroupVisible(root, "bond-settings", !oobMode && advancedMode === "bonding"); setFieldGroupVisible(root, "bridge-settings", !oobMode && advancedMode === "bridge"); context.dataset.mode = oobMode ? "oob" : advancedMode; context.replaceChildren(); const heading = document.createElement("strong"); const description = document.createElement("p"); const roles = document.createElement("div"); roles.className = "operating-mode-roles"; if (oobMode) { heading.textContent = "OOB 자동 전환 모드"; description.textContent = "Interface 1을 항상 우선 사용하고 장애 감지 시 Interface 2로 전환합니다. 두 인터페이스는 서로 다른 IPv4 대역과 게이트웨이가 필요합니다."; } else if (advancedMode === "bonding") { heading.textContent = "Bonding 통합 인터페이스"; description.textContent = "eth0과 eth1을 bond0로 묶습니다. IP, 게이트웨이, DNS는 Interface 1 설정을 사용합니다."; } else if (advancedMode === "bridge") { heading.textContent = "Bridge 통합 인터페이스"; description.textContent = "eth0과 eth1을 br0에 연결합니다. IP, 게이트웨이, DNS는 Interface 1 설정을 사용합니다."; } else { heading.textContent = "Single 독립 사용 모드"; description.textContent = "Interface 1과 Interface 2를 각각 독립된 네트워크 인터페이스로 사용하며 자동 전환하지 않습니다."; } const roleItems = oobMode ? [["Interface 1", "주 회선 · 우선순위 1"], ["Interface 2", "예비 회선 · 장애 시 전환"]] : (advancedMode === "bonding" ? [["eth0", "bond0 구성 포트"], ["eth1", "bond0 구성 포트"]] : (advancedMode === "bridge" ? [["eth0", "br0 브리지 포트"], ["eth1", "br0 브리지 포트"]] : [["Interface 1", "독립 인터페이스"], ["Interface 2", "독립 인터페이스"]])); roleItems.forEach(function (role) { const card = document.createElement("div"); const name = document.createElement("strong"); const purpose = document.createElement("span"); name.textContent = role[0]; purpose.textContent = role[1]; card.append(name, purpose); roles.appendChild(card); }); context.append(heading, description, roles); if (oobMode) { const live = document.createElement("div"); live.className = "oob-runtime-summary"; const primaryLink = String(runtime.primary_link || "unknown").toUpperCase(); const activePath = runtime.fallback_active ? "Interface 2 (예비 회선)" : "Interface 1 (주 회선)"; const reason = String(runtime.failover_reason || "none"); live.textContent = "현재 경로: " + activePath + " · Interface 1 링크: " + primaryLink + " · 상태: " + reason; live.dataset.fallbackActive = String(Boolean(runtime.fallback_active)); context.appendChild(live); } } modeSelect.addEventListener("change", render); advancedSelect.addEventListener("change", render); render(); return { updateRuntime: function (next) { runtime = next || {}; render(); } }; } async function networkInterface() { const root = form(); const state = await api("/network"); const wanted = currentPath.endsWith("interface-2") ? "Interface-2" : "Interface-1"; const item = (state.interfaces || []).find(function (candidate) { return candidate.name === wanted; }); if (!item) { throw new Error(wanted + " 정보를 찾을 수 없습니다."); } const addressParts = String(item.address || "").split("/"); setValue(root, "dhcp", item.address_mode === "dhcp"); setValue(root, "address", addressParts[0]); setValue(root, "netmask", prefixToMask(addressParts[1] || 24)); setValue(root, "gateway", item.gateway || ""); setValue(root, "dns1", (item.dns_servers || [])[0] || ""); setValue(root, "dns2", (item.dns_servers || [])[1] || ""); setValue(root, "ipv6mode", item.ipv6_mode || "auto"); setValue(root, "ipv6address", item.ipv6_address || ""); setValue(root, "prefix", String(item.ipv6_address || "").split("/")[1] || "64"); setValue(root, "ipv6gateway", item.ipv6_gateway || ""); setValue(root, "ipv6dns1", (item.ipv6_dns_servers || [])[0] || ""); setValue(root, "ipv6dns2", (item.ipv6_dns_servers || [])[1] || ""); renderInterfaceModeContext(root, state, wanted); bindIPv6Disclosure(root, item); bindNetworkForm(root, function () { const address = value(root, "address"); const ipv6Address = value(root, "ipv6address"); return api("/network/running", { method: "PATCH", body: { interfaces: [{ name: wanted, address_mode: checked(root, "dhcp") ? "dhcp" : "static", address: address ? address + "/" + maskToPrefix(value(root, "netmask")) : "", gateway: value(root, "gateway"), dns_servers: [value(root, "dns1"), value(root, "dns2")].filter(Boolean), ipv6_mode: value(root, "ipv6mode").toLowerCase(), ipv6_address: ipv6Address ? ipv6Address + (ipv6Address.includes("/") ? "" : "/" + integer(root, "prefix", 64)) : "", ipv6_gateway: value(root, "ipv6gateway"), ipv6_dns_servers: [value(root, "ipv6dns1"), value(root, "ipv6dns2")].filter(Boolean) }] } }); }); } async function hostSettings() { const root = form(); const state = await api("/network"); setValue(root, "hostname", state.host_name); bindNetworkForm(root, function () { return api("/network/running", { method: "PATCH", body: { host_name: value(root, "hostname") } }); }); } async function advancedSettings() { const root = form(); const state = await api("/network"); const oob = state.oob || {}; const advanced = state.advanced || {}; setValue(root, "mode", state.operating_mode || "single"); setValue(root, "advancedMode", advanced.mode || "single"); setValue(root, "bondMode", advanced.bond_mode || "active-backup"); setValue(root, "bondMii", advanced.bond_mii || 50); setValue(root, "bridgeSTP", advanced.bridge_stp || false); setValue(root, "healthTarget", oob.health_check_target || "1.1.1.1"); setValue(root, "healthInterval", oob.health_check_interval_seconds || 10); setValue(root, "failureThreshold", oob.failure_threshold || 3); const modeLayout = bindOperatingModeLayout(root, oob); if (networkModeTimer !== null) { window.clearInterval(networkModeTimer); } networkModeTimer = window.setInterval(async function () { if (!modeLayout || value(root, "mode") !== "oob") { return; } try { const latest = await api("/network"); modeLayout.updateRuntime(latest.oob || {}); } catch (_) { // The regular apply/status surfaces report actionable network errors. } }, 2000); bindNetworkForm(root, function () { const operatingMode = value(root, "mode"); const advancedMode = operatingMode === "oob" ? "single" : value(root, "advancedMode"); const body = { operating_mode: operatingMode, advanced: { mode: advancedMode, bond_mode: value(root, "bondMode"), bond_mii: integer(root, "bondMii", 50), bridge_stp: checked(root, "bridgeSTP") } }; if (operatingMode === "oob") { const incomplete = ["Interface-1", "Interface-2"].map(function (name) { const item = (state.interfaces || []).find(function (candidate) { return candidate.name === name; }); const missing = []; if (!item || item.address_mode !== "static" || !item.address) { missing.push("Static IPv4"); } if (!item || !item.gateway) { missing.push("게이트웨이"); } return missing.length ? name + "(" + missing.join(", ") + ")" : ""; }).filter(Boolean); if (incomplete.length) { throw new Error("OOB 저장 전 인터페이스 1과 인터페이스 2에 Static IPv4와 게이트웨이를 설정하세요. 미완료: " + incomplete.join(", ")); } body.oob = { primary_interface: "Interface-1", fallback_interface: "Interface-2", health_check_target: value(root, "healthTarget"), health_check_interval_seconds: integer(root, "healthInterval", 10), failure_threshold: integer(root, "failureThreshold", 3) }; } return api("/network/running", { method: "PATCH", body: body }); }); } async function ddnsSettings() { const root = form(); const state = await api("/network"); const ddns = state.ddns || {}; setValue(root, "ddns", ddns.enabled || false); setValue(root, "provider", ddns.provider || "dnspod"); setValue(root, "ipv4Enabled", ddns.ipv4_enabled !== false); setValue(root, "username", ddns.username || ""); setValue(root, "password", ddns.password || ""); setValue(root, "ipv4Domain", ddns.ipv4_domain || ""); setValue(root, "checkIp", ddns.check_ip_url || ""); setValue(root, "customProvider", ddns.custom_provider || ""); setValue(root, "customPath", ddns.custom_path || ""); bindNetworkForm(root, function () { return api("/network/running", { method: "PATCH", body: { ddns: { enabled: checked(root, "ddns"), provider: value(root, "provider"), ipv4_enabled: checked(root, "ipv4Enabled"), ipv6_enabled: false, username: value(root, "username"), password: value(root, "password"), ipv4_domain: value(root, "ipv4Domain"), ipv6_domain: "", check_ip_url: value(root, "checkIp"), custom_provider: value(root, "customProvider"), custom_path: value(root, "customPath") } } }); }); } async function managementAccess() { const root = form(); const settings = await api("/security/management-access"); setValue(root, "http", settings.http_enabled); setValue(root, "https", settings.https_enabled); setValue(root, "httpPort", settings.http_port); setValue(root, "httpsPort", settings.https_port); setValue(root, "ssh", settings.ssh_enabled); bindForm(root, function () { return api("/security/management-access", { method: "PATCH", body: { http_enabled: checked(root, "http"), https_enabled: checked(root, "https"), http_port: integer(root, "httpPort", 80), https_port: integer(root, "httpsPort", 443), ssh_enabled: checked(root, "ssh") } }); }); } async function externalAuthSettings(kind) { const root = form(); const settings = await api("/auth/external"); const listKey = kind === "radius" ? "radius_servers" : "tacacs_servers"; const serverID = kind + "-primary"; const servers = Array.isArray(settings[listKey]) ? settings[listKey] : []; const server = servers.find(function (item) { return item.server_id === serverID; }) || servers[0] || {}; const selected = settings.enabled && settings.primary_method === kind && server.enabled; setValue(root, "enabled", selected); setValue(root, "host", server.host || ""); setValue(root, "port", server.port || (kind === "radius" ? 1645 : 49)); setValue(root, "timeout", settings.timeout_seconds || 5); setValue(root, "defaultRole", settings.default_role || "port_operator"); function updateExternalAuthFields() { setAutomatedFieldVisibility(root, ["host", "port", "secret", "timeout", "defaultRole"], checked(root, "enabled")); } updateExternalAuthFields(); field(root, "enabled")?.addEventListener("change", updateExternalAuthFields); bindForm(root, function () { const enabled = checked(root, "enabled"); const secret = value(root, "secret"); if (enabled && !secret && !server.secret_stored) { throw new Error("외부 인증을 사용하려면 공유 Secret을 입력하세요."); } const updatedServer = { server_id: server.server_id || serverID, host: value(root, "host"), port: integer(root, "port", kind === "radius" ? 1645 : 49), secret_stored: Boolean(server.secret_stored || secret), enabled: enabled }; if (secret) { updatedServer.secret = secret; } const updatedServers = servers.filter(function (item) { return item.server_id !== updatedServer.server_id; }); if (enabled || server.server_id || updatedServer.host) { updatedServers.unshift(updatedServer); } const body = { enabled: enabled || (settings.enabled && settings.primary_method !== kind), primary_method: enabled ? kind : (settings.primary_method === kind ? "local" : settings.primary_method), local_fallback: true, timeout_seconds: integer(root, "timeout", 5), default_role: value(root, "defaultRole"), radius_servers: kind === "radius" ? updatedServers : (settings.radius_servers || []), tacacs_servers: kind === "tacacs" ? updatedServers : (settings.tacacs_servers || []) }; return api("/auth/external", { method: "PATCH", body: body }); }); } function addressFilterType() { if (currentPath.includes("/ipv6")) { return "ipv6"; } if (currentPath.includes("/mac")) { return "mac"; } return "ipv4"; } async function addressFilterPolicy() { const root = form(); const settings = await api("/security/address-filters"); setValue(root, "enabled", settings.enabled); setValue(root, "mode", settings.mode || "blacklist"); markIntegrated(root); root.addEventListener("submit", async function (event) { event.preventDefault(); try { const enabled = checked(root, "enabled"); const mode = value(root, "mode"); if (enabled && mode === "whitelist") { const usableRules = (settings.rules || []).filter(function (rule) { return rule.enabled; }); if (!usableRules.length) { throw new Error("허용 목록을 사용하려면 먼저 현재 관리 PC를 허용하는 규칙을 추가하세요."); } if (!window.confirm("허용 목록에 현재 관리 PC가 없으면 웹 접속이 즉시 차단될 수 있습니다. 적용하시겠습니까?")) { return; } } await api("/security/address-filters", { method: "PUT", body: { enabled: enabled, mode: mode, rules: settings.rules || [] }}); toast("주소 필터를 장비에 적용했습니다.", "success"); } catch (error) { report(error); } }); } function addressFilterTableTitle(type) { if (type === "ipv6") { return "IPv6 필터"; } if (type === "mac") { return "MAC 필터"; } return "IPv4 필터"; } async function addressFilterList() { const type = addressFilterType(); const title = addressFilterTableTitle(type); const panel = panelByTitle(title); const body = panel?.querySelector("tbody"); const addButton = panel?.querySelector('[data-ui-action="primary-action"]'); if (!body) { throw new Error("주소 필터 목록을 초기화할 수 없습니다."); } if (addButton) { addButton.dataset.integrated = "true"; addButton.addEventListener("click", function () { const target = "/admin/address-filter/" + type + "/new"; if (!requestConsoleNavigation(target, "push")) { window.location.assign(target); } }); } async function refresh() { const settings = await api("/security/address-filters"); const rules = (settings.rules || []).filter(function (rule) { return rule.address_type === type; }); body.replaceChildren(); if (!rules.length) { const row = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = 6; cell.className = "empty-state"; cell.textContent = "등록된 " + title + " 규칙이 없습니다."; row.appendChild(cell); body.appendChild(row); return; } rules.forEach(function (rule) { const row = document.createElement("tr"); [rule.rule_id, rule.address, rule.protocol || "all", rule.destination_port || "전체", rule.enabled ? "사용" : "중지"].forEach(function (text) { const cell = document.createElement("td"); cell.textContent = String(text); row.appendChild(cell); }); const actions = document.createElement("td"); const remove = document.createElement("button"); remove.type = "button"; remove.className = "link-action danger"; remove.textContent = "삭제"; remove.addEventListener("click", async function () { if (!window.confirm(rule.rule_id + " 규칙을 삭제하시겠습니까?")) { return; } try { const nextRules = (settings.rules || []).filter(function (item) { return item.rule_id !== rule.rule_id; }); await api("/security/address-filters", { method: "PUT", body: { enabled: settings.enabled, mode: settings.mode, rules: nextRules }}); toast("주소 필터 규칙을 삭제했습니다.", "success"); await refresh(); } catch (error) { report(error); } }); actions.appendChild(remove); row.appendChild(actions); body.appendChild(row); }); } await refresh(); } async function addressFilterAdd() { const root = form(); const type = addressFilterType(); let managementAccess = {}; try { managementAccess = await api("/security/management-access"); } catch (error) { console.warn("management access defaults are used for address filter automation", error); } function automatedDestinationPort() { const protocol = value(root, "protocol"); if (protocol === "http") { return Number(managementAccess.http_port) || 80; } if (protocol === "https") { return Number(managementAccess.https_port) || 443; } if (protocol === "ssh") { return Number(managementAccess.ssh_port) || 22; } if (protocol === "telnet") { return Number(managementAccess.telnet_port) || 23; } return 0; } function generatedRuleID() { return safeGeneratedID([type, value(root, "protocol"), value(root, "address")], type + "-rule"); } function updateAddressFilterAutomation() { setValue(root, "name", generatedRuleID()); setValue(root, "port", automatedDestinationPort()); } updateAddressFilterAutomation(); field(root, "address")?.addEventListener("input", updateAddressFilterAutomation); field(root, "protocol")?.addEventListener("change", updateAddressFilterAutomation); markIntegrated(root); root.addEventListener("submit", async function (event) { event.preventDefault(); try { const settings = await api("/security/address-filters"); const existingIDs = new Set((settings.rules || []).map(function (rule) { return rule.rule_id; })); const ruleID = uniqueGeneratedID(generatedRuleID(), existingIDs); setValue(root, "name", ruleID); setValue(root, "port", automatedDestinationPort()); const rules = (settings.rules || []).concat([{ rule_id: ruleID, address_type: type, address: value(root, "address"), protocol: value(root, "protocol"), destination_port: integer(root, "port", 0), enabled: checked(root, "enabled") }]); await api("/security/address-filters", { method: "PUT", body: { enabled: settings.enabled, mode: settings.mode, rules: rules }}); toast("주소 필터 규칙을 추가했습니다.", "success"); const target = "/admin/address-filter/" + type; if (!requestConsoleNavigation(target, "push")) { window.location.assign(target); } } catch (error) { report(error); } }); } async function snmpAgentSettings() { const root = form(); const settings = await api("/snmp/agent"); const users = Array.isArray(settings.v3_users) ? settings.v3_users : []; const user = users[0] || {}; setValue(root, "enabled", settings.enabled); setValue(root, "location", settings.location || ""); setValue(root, "contact", settings.contact || ""); setValue(root, "v1", settings.v1_enabled); setValue(root, "v2c", settings.v2c_enabled); setValue(root, "v3", settings.v3_enabled); setValue(root, "v3Username", user.username || ""); setValue(root, "v3Access", user.access || "read"); setValue(root, "v3Auth", user.auth_protocol || "sha256"); setValue(root, "v3Privacy", user.privacy_protocol || "aes"); setValue(root, "v3UserEnabled", user.enabled || false); function updateSNMPFields() { const enabled = checked(root, "enabled"); const communityMode = enabled && (checked(root, "v1") || checked(root, "v2c")); const v3Mode = enabled && checked(root, "v3"); setAutomatedFieldVisibility(root, ["location", "contact", "v1", "v2c", "v3"], enabled); setAutomatedFieldVisibility(root, ["roCommunity", "rwCommunity"], communityMode); setAutomatedFieldVisibility(root, ["v3Username", "v3Access", "v3Auth", "v3Privacy", "v3UserEnabled"], v3Mode); setAutomatedFieldVisibility(root, ["v3AuthKey"], v3Mode && value(root, "v3Auth") !== "none"); setAutomatedFieldVisibility(root, ["v3PrivacyKey"], v3Mode && value(root, "v3Privacy") !== "none"); } updateSNMPFields(); ["enabled", "v1", "v2c", "v3", "v3Auth", "v3Privacy"].forEach(function (name) { field(root, name)?.addEventListener("change", updateSNMPFields); }); bindForm(root, function () { const roCommunity = value(root, "roCommunity"); const rwCommunity = value(root, "rwCommunity"); const username = value(root, "v3Username"); const authKey = value(root, "v3AuthKey"); const privacyKey = value(root, "v3PrivacyKey"); let nextUsers = users.slice(); if (username) { const nextUser = { username: username, access: value(root, "v3Access"), auth_protocol: value(root, "v3Auth"), privacy_protocol: value(root, "v3Privacy"), auth_key_stored: Boolean(user.auth_key_stored || authKey), privacy_key_stored: Boolean(user.privacy_key_stored || privacyKey), enabled: checked(root, "v3UserEnabled") }; if (authKey) { nextUser.auth_key = authKey; } if (privacyKey) { nextUser.privacy_key = privacyKey; } nextUsers = users.filter(function (item) { return item.username !== user.username; }); nextUsers.unshift(nextUser); } if (checked(root, "enabled") && !checked(root, "v1") && !checked(root, "v2c") && !checked(root, "v3")) { throw new Error("SNMP를 사용하려면 v1, v2c, v3 중 하나를 선택하세요."); } if (checked(root, "v2c") && !roCommunity && !settings.read_only_community_stored) { throw new Error("SNMP v2c를 사용하려면 Read Community를 입력하세요."); } if (checked(root, "v3") && !nextUsers.some(function (item) { return item.enabled; })) { throw new Error("SNMP v3를 사용하려면 활성 v3 사용자를 설정하세요."); } const update = { enabled: checked(root, "enabled"), location: value(root, "location"), contact: value(root, "contact"), v1_enabled: checked(root, "v1"), v2c_enabled: checked(root, "v2c"), v3_enabled: checked(root, "v3"), read_only_community_stored: Boolean(settings.read_only_community_stored || roCommunity), read_write_community_stored: Boolean(settings.read_write_community_stored || rwCommunity), v3_users: nextUsers }; if (roCommunity) { update.read_only_community = roCommunity; } if (rwCommunity) { update.read_write_community = rwCommunity; } return api("/snmp/agent", { method: "PATCH", body: update }); }); } function certificateSubject(root) { return { country: value(root, "country"), province: value(root, "province"), locality: value(root, "locality"), organization: value(root, "organization"), organizational_unit: value(root, "organizationalUnit"), common_name: value(root, "commonName"), email: value(root, "email") }; } function populateCertificateSelect(root, inventory) { const select = field(root, "activeCertificate") || field(root, "certificateID"); if (!select) { return; } select.replaceChildren(); (inventory.certificates || []).forEach(function (certificate) { const option = document.createElement("option"); option.value = certificate.certificate_id; option.textContent = certificate.certificate_id + " - " + (certificate.common_name || certificate.subject || "인증서"); option.selected = certificate.certificate_id === inventory.active_certificate_id; select.appendChild(option); }); } function renderCertificateInventory(inventory, onRefresh) { const body = panelByTitle("인증서 목록")?.querySelector("tbody"); if (!body) { return; } body.replaceChildren(); (inventory.certificates || []).forEach(function (certificate) { const row = document.createElement("tr"); [ certificate.certificate_id, certificate.common_name || certificate.subject, formatTime(certificate.not_before) + " ~ " + formatTime(certificate.not_after), certificate.private_key_stored ? String(certificate.key_bits || "-") + " bit" : "키 없음", certificate.active ? "사용 중" : "대기" ].forEach(function (text) { const cell = document.createElement("td"); cell.textContent = String(text || "-"); row.appendChild(cell); }); const actions = document.createElement("td"); if (certificate.active) { actions.textContent = "활성 인증서"; } else { const remove = document.createElement("button"); remove.type = "button"; remove.className = "link-action danger"; remove.textContent = "삭제"; remove.addEventListener("click", async function () { if (!window.confirm(certificate.certificate_id + " 인증서를 삭제하시겠습니까?")) { return; } try { await api("/security/tls-certificates/" + encodeURIComponent(certificate.certificate_id), { method: "DELETE" }); toast("인증서를 삭제했습니다.", "success"); await onRefresh(); } catch (error) { report(error); } }); actions.appendChild(remove); } row.appendChild(actions); body.appendChild(row); }); } async function tlsCertificateSettings() { const root = form(); async function refresh() { const inventory = await api("/security/tls-certificates"); populateCertificateSelect(root, inventory); renderCertificateInventory(inventory, refresh); return inventory; } await refresh(); bindForm(root, function () { return api("/security/tls-certificate", { method: "PATCH", body: { active_certificate_id: value(root, "activeCertificate") }}); }); } async function tlsCertificateCreate() { const root = form(); const inventory = await api("/security/tls-certificates"); const existingIDs = new Set((inventory.certificates || []).map(function (certificate) { return certificate.certificate_id; })); setValue(root, "commonName", window.location.hostname || "tera-device"); function updateCertificateID() { const base = safeGeneratedID(["device", value(root, "commonName")], "device-certificate"); setValue(root, "certificateID", uniqueGeneratedID(base, existingIDs)); } updateCertificateID(); field(root, "commonName")?.addEventListener("input", updateCertificateID); bindForm(root, async function () { updateCertificateID(); const entry = await api("/security/tls-certificates", { method: "POST", body: Object.assign({ certificate_id: value(root, "certificateID") }, certificateSubject(root)) }); return api("/security/tls-certificate", { method: "PATCH", body: { active_certificate_id: entry.certificate_id }}); }); } async function tlsCertificateImport() { const root = form(); const inventory = await api("/security/tls-certificates"); const existingIDs = new Set((inventory.certificates || []).map(function (certificate) { return certificate.certificate_id; })); function updateImportedCertificateID() { const file = selectedFile(root, "certificateFile"); const filename = file ? file.name.replace(/\.(pem|crt|cer)$/i, "") : "imported-certificate"; const base = safeGeneratedID([filename], "imported-certificate"); setValue(root, "certificateID", uniqueGeneratedID(base, existingIDs)); } updateImportedCertificateID(); field(root, "certificateFile")?.addEventListener("change", updateImportedCertificateID); bindForm(root, async function () { updateImportedCertificateID(); const certificatePEM = await readTextFile(selectedFile(root, "certificateFile"), 1024 * 1024); const keyFile = selectedFile(root, "privateKeyFile"); const privateKeyPEM = keyFile ? await readTextFile(keyFile, 1024 * 1024) : ""; const entry = await api("/security/tls-certificates/import", { method: "POST", body: { certificate_id: value(root, "certificateID"), certificate_pem: certificatePEM, private_key_pem: privateKeyPEM, password: value(root, "password") }}); return api("/security/tls-certificate", { method: "PATCH", body: { active_certificate_id: entry.certificate_id }}); }); } async function tlsCertificateCSR() { const root = form(); const inventory = await api("/security/tls-certificates"); populateCertificateSelect(root, inventory); markIntegrated(root); root.addEventListener("submit", async function (event) { event.preventDefault(); try { const certificateID = value(root, "certificateID"); const artifact = await api("/security/tls-certificates/" + encodeURIComponent(certificateID) + "/csr", { method: "POST", body: { common_name: value(root, "commonName"), organization: value(root, "organization"), email: value(root, "email") } }); downloadText(artifact.filename || certificateID + ".csr.pem", artifact.pem, "application/pkcs10"); toast("CSR 파일을 생성했습니다.", "success"); } catch (error) { report(error); } }); } async function encryptedConfigExport() { const root = form(); markIntegrated(root); root.addEventListener("submit", async function (event) { event.preventDefault(); try { const key = value(root, "key"); if (key.length < 8 || key.length > 64) { throw new Error("백업 파일 암호는 8~64자로 입력하세요."); } if (key !== value(root, "confirmKey")) { throw new Error("백업 파일 암호와 확인 값이 일치하지 않습니다."); } const exported = await api("/config/export/encrypted", { method: "POST", body: { key: key } }); downloadText(exported.filename, exported.content, "application/json"); setValue(root, "key", ""); setValue(root, "confirmKey", ""); toast("암호화된 구성 백업을 내려받았습니다.", "success"); } catch (error) { report(error); } }); } async function encryptedConfigRestore() { const root = form(); markIntegrated(root); root.addEventListener("submit", async function (event) { event.preventDefault(); try { const includeNetwork = checked(root, "includeNetwork"); if (!window.confirm(includeNetwork ? "네트워크 설정까지 복원하면 현재 웹 연결이 끊길 수 있습니다. 계속하시겠습니까?" : "선택한 백업으로 장비 설정을 복원하시겠습니까?")) { return; } const content = await readTextFile(selectedFile(root, "backupFile"), 2 * 1024 * 1024); await api("/config/restore/encrypted", { method: "POST", body: { key: value(root, "key"), content: content, include_network: includeNetwork }}); await applyPendingConfiguration({ includeNetwork: includeNetwork }); await api("/system/reboot", { method: "POST" }); toast("복원 완료. 시스템을 자동 재시작합니다.", "success"); window.setTimeout(function () { window.location.assign("/login"); }, 2000); } catch (error) { report(error); } }); } async function firmwareUpgrade() { const root = form(); markIntegrated(root); window.teraFirmwareProgress.mount(root); } function selectedChannels(fallbackNumber) { try { const stored = JSON.parse(window.localStorage.getItem("teraclone.portTargets") || "[]"); const numbers = Array.isArray(stored) ? stored : []; const normalized = numbers.map(function (item) { return "CH" + String(Number(item)).padStart(2, "0"); }).filter(function (item) { return /^CH(?:0[1-9]|[1-3][0-9]|4[0-8])$/.test(item); }); if (normalized.length) { return Array.from(new Set(normalized)); } } catch (error) { console.error(error); } return fallbackNumber ? ["CH" + String(fallbackNumber).padStart(2, "0")] : []; } async function savePort(port, update) { const number = portNumber(port.channel); const channels = selectedChannels(number); if (channels.length === 1 && channels[0] === port.channel) { return api("/ports/" + port.channel, { method: "PATCH", body: update }); } return api("/ports", { method: "PATCH", body: { channels: channels, update: update } }); } function fillOperation(root, operation) { setValue(root, "mode", operation.mode); setValue(root, "port", operation.port); setValue(root, "keepalive", operation.keep_alive_seconds); setValue(root, "timeout", operation.timeout_ms); setValue(root, "maxClients", operation.max_clients); setValue(root, "authentication", operation.authentication); setValue(root, "newline", operation.newline); setValue(root, "controlShortcut", operation.control_shortcut || "^]"); setValue(root, "switchShortcut", operation.switch_shortcut || "^T"); setValue(root, "quitShortcut", operation.quit_shortcut || "^Q"); setValue(root, "breakShortcut", operation.break_shortcut || ""); setValue(root, "interruptShortcut", operation.interrupt_shortcut || ""); } function readOperation(root, existing) { return Object.assign({}, existing, { mode: value(root, "mode"), port: integer(root, "port", existing.port || 0), host: "", keep_alive_seconds: 3, timeout_ms: 10000, inactivity_timeout_ms: 0, max_clients: integer(root, "maxClients", 1), authentication: value(root, "authentication"), newline: value(root, "newline"), control_shortcut: value(root, "controlShortcut") || "^]", switch_shortcut: value(root, "switchShortcut") || "^T", quit_shortcut: value(root, "quitShortcut") || "^Q", break_shortcut: value(root, "breakShortcut"), interrupt_shortcut: value(root, "interruptShortcut"), credential: "", clear_credential: false }); } async function portSettings(match) { const number = Number(match[1]); const section = match[2]; const channel = "CH" + String(number).padStart(2, "0"); const root = form(); // Populate editable settings without waiting for live serial telemetry. // The existing runtime poll updates link indicators independently. const ports = await api("/ports?limit=48&runtime=false"); const port = ports.find(function (item) { return item.channel === channel; }); if (!port) { throw new Error(channel + " 포트 정보를 찾을 수 없습니다."); } if (section === "parameters") { setValue(root, "alias", port.alias); setValue(root, "serialType", port.serial_type); setValue(root, "baudrate", port.baud_rate); setValue(root, "databit", port.data_bits); setValue(root, "parity", port.parity); setValue(root, "stopbit", port.stop_bits_mode || port.stop_bits); setValue(root, "flow", port.flow_control); setValue(root, "rts", port.rts_control); setValue(root, "dtr", port.dtr_control); setValue(root, "ignore", port.ignore_jammed); bindForm(root, function () { return savePort(port, { serial_type: value(root, "serialType"), baud_rate: integer(root, "baudrate", 115200), data_bits: integer(root, "databit", 8), parity: value(root, "parity"), stop_bits_mode: value(root, "stopbit"), flow_control: value(root, "flow"), rts_control: value(root, "rts"), dtr_control: value(root, "dtr"), ignore_jammed: checked(root, "ignore") }); }); return; } if (section === "operation-1" || section === "operation-2") { const property = section === "operation-1" ? "operation_1" : "operation_2"; const operation = port[property] || {}; fillOperation(root, operation); setValue(root, "port", 10000 + portNumber(channel)); if (property === "operation_2") { setValue(root, "maxClients", port.operation_1.max_clients); } bindForm(root, function () { const update = {}; update[property] = readOperation(root, operation); return savePort(port, update); }); return; } if (section === "data-log") { const log = port.data_log || {}; setValue(root, "read", log.read); setValue(root, "send", log.send); setValue(root, "showTimestamp", log.show_timestamp); setValue(root, "showDirection", log.show_direction); setValue(root, "showUsername", log.show_username); setValue(root, "showIP", log.show_ip_address); setValue(root, "showPort", log.show_port); setValue(root, "format", log.format); bindForm(root, function () { return savePort(port, { data_log: Object.assign({}, log, { read: checked(root, "read"), send: checked(root, "send"), show_timestamp: checked(root, "showTimestamp"), show_direction: checked(root, "showDirection"), show_username: checked(root, "showUsername"), show_ip_address: checked(root, "showIP"), show_port: checked(root, "showPort"), format: value(root, "format") }) }); }); return; } if (section === "advanced") { setValue(root, "alarmKeyword", port.alarm_keyword || ""); bindForm(root, function () { return savePort(port, { alarm_keyword: value(root, "alarmKeyword") }); }); } } async function timeSettings() { const root = form(); const results = await Promise.allSettled([api("/time/settings"), api("/system/status")]); if (results[0].status !== "fulfilled") { throw results[0].reason; } const settings = results[0].value; const systemStatus = results[1].status === "fulfilled" ? results[1].value : null; setValue(root, "timezone", settings.timezone); setValue(root, "ntp", settings.mode === "ntp"); setValue(root, "ntp1", (settings.ntp_servers || [])[0] || ""); setValue(root, "ntp2", (settings.ntp_servers || [])[1] || ""); const deviceTime = systemStatus?.system_time ? new Date(systemStatus.system_time) : null; const initialTime = deviceTime && !Number.isNaN(deviceTime.getTime()) && deviceTime.getUTCFullYear() >= 2000 ? deviceTime : new Date(); const pad = function (number) { return String(number).padStart(2, "0"); }; setValue(root, "date", [initialTime.getFullYear(), pad(initialTime.getMonth() + 1), pad(initialTime.getDate())].join("-")); setValue(root, "time", [pad(initialTime.getHours()), pad(initialTime.getMinutes()), pad(initialTime.getSeconds())].join(":")); function updateModeFields() { const ntpEnabled = checked(root, "ntp"); setAutomatedFieldVisibility(root, ["ntp1", "ntp2"], ntpEnabled); setAutomatedFieldVisibility(root, ["date", "time"], !ntpEnabled); } field(root, "ntp")?.addEventListener("change", updateModeFields); updateModeFields(); const applyButton = root.querySelector('[data-ui-action="apply"]'); const actionArea = root.querySelector(".panel-actions"); const syncButton = document.createElement("button"); syncButton.type = "button"; syncButton.className = "btn secondary"; syncButton.dataset.uiAction = "sync-browser-time"; syncButton.textContent = "현재 PC 시간으로 동기화"; actionArea?.appendChild(syncButton); function setTimeBusy(busy) { if (applyButton) { applyButton.disabled = busy; } syncButton.disabled = busy; } markIntegrated(root); syncButton.addEventListener("click", async function () { if (syncButton.disabled) { return; } try { setTimeBusy(true); const currentTime = new Date(); await api("/time/sync-browser", { method: "POST", body: { browser_time: currentTime.toISOString(), timezone: value(root, "timezone") } }); setValue(root, "date", [currentTime.getFullYear(), pad(currentTime.getMonth() + 1), pad(currentTime.getDate())].join("-")); setValue(root, "time", [pad(currentTime.getHours()), pad(currentTime.getMinutes()), pad(currentTime.getSeconds())].join(":")); toast("장비 시간을 현재 PC 시간으로 맞췄습니다. NTP 설정은 변경하지 않았습니다.", "success"); } catch (error) { report(error); } finally { setTimeBusy(false); } }); root.addEventListener("submit", async function (event) { event.preventDefault(); if (applyButton?.disabled) { return; } try { setTimeBusy(true); const ntpEnabled = checked(root, "ntp"); const ntpServers = [value(root, "ntp1"), value(root, "ntp2")].filter(Boolean); if (ntpEnabled && ntpServers.length === 0) { throw new Error("NTP 서버를 한 개 이상 입력하세요."); } await api("/time/settings", { method: "PATCH", body: { mode: ntpEnabled ? "ntp" : "manual", timezone: value(root, "timezone"), ntp_servers: ntpServers } }); await applyPendingConfiguration(); if (ntpEnabled) { await api("/time/sync-ntp", { method: "POST" }); toast("NTP 서버로 장비 시간을 동기화하고 설정을 영구 저장했습니다.", "success"); } else { const manualDate = value(root, "date"); const manualTime = value(root, "time"); const requestedTime = new Date(manualDate + "T" + manualTime); if (!manualDate || !manualTime || Number.isNaN(requestedTime.getTime())) { throw new Error("수동 날짜와 시간을 올바르게 입력하세요."); } await api("/time/sync-browser", { method: "POST", body: { browser_time: requestedTime.toISOString(), timezone: value(root, "timezone") } }); toast("입력한 시각으로 장비 시간을 동기화하고 설정을 영구 저장했습니다.", "success"); } } catch (error) { report(error); } finally { setTimeBusy(false); } }); } async function remoteSyslog() { const root = form(); const settings = await api("/logs/remote-syslog"); setValue(root, "remote", settings.enabled); const host = String(settings.host || "").trim(); const endpointHost = host.includes(":") && !host.startsWith("[") ? "[" + host + "]" : host; setValue(root, "server", endpointHost ? endpointHost + (Number(settings.port) === 514 ? "" : ":" + settings.port) : ""); setValue(root, "protocol", String(settings.protocol || "udp").toUpperCase()); const categories = document.createElement("fieldset");categories.innerHTML=''; const retention=await api('/logs/retention'); ['system','configuration','operation','status'].forEach(function(kind){const label=document.createElement('label');const input=document.createElement('input');input.type='checkbox';input.name='remote_'+kind;input.checked=retention['remote_'+kind+'_enabled'];label.append(input,document.createTextNode(({system:'시스템',configuration:'설정 변경',operation:'운영·포트',status:'상태'})[kind]));categories.appendChild(label);}); root.appendChild(categories); const deliveryStatus=document.createElement('p');deliveryStatus.setAttribute('role','status');root.appendChild(deliveryStatus); const refreshStatus=document.createElement('button');refreshStatus.type='button';refreshStatus.className='btn secondary';refreshStatus.textContent='전송 상태 새로고침';root.appendChild(refreshStatus); async function updateStatus(){const status=await api('/logs/remote-syslog/status');const d=status.delivery||{};deliveryStatus.textContent=!status.enabled?'원격 전송 꺼짐':(!status.observed?'아직 확인된 전송 내역이 없습니다.':('최근 전송: '+(({queued:'대기',delivered:'전송 완료',failed:'실패',retrying:'재시도 중'})[d.state]||d.state||'확인 불가')+' · 시도 '+(d.attempts||0)+'회 · '+(status.error||d.last_error||'오류 없음')));} refreshStatus.addEventListener('click',function(){updateStatus().catch(report);});updateStatus().catch(report); bindForm(root, async function () { const endpoint = value(root, "server"); const parsed = parseSyslogEndpoint(endpoint); if (checked(root, "remote") && !parsed.host) { throw new Error("원격 로그를 사용하려면 로그 서버를 입력하세요."); } const selection={};['system','configuration','operation','status'].forEach(function(kind){selection['remote_'+kind+'_enabled']=root.querySelector('[name="remote_'+kind+'"]').checked;}); await api('/logs/retention',{method:'PATCH',body:selection}); return api("/logs/remote-syslog", { method: "PATCH", body: { enabled: checked(root, "remote"), host: parsed.host, port: parsed.port, protocol: value(root, "protocol").toLowerCase() } }); }); } function parseSyslogEndpoint(endpoint) { const text = String(endpoint || "").trim(); if (!text) { return { host: "", port: 514 }; } let host = text; let port = 514; const bracketed = text.match(/^\[([^\]]+)\](?::(\d+))?$/); if (bracketed) { host = bracketed[1]; port = bracketed[2] ? Number.parseInt(bracketed[2], 10) : 514; } else if ((text.match(/:/g) || []).length === 1) { const withPort = text.match(/^(.+):(\d+)$/); if (withPort) { host = withPort[1].trim(); port = Number.parseInt(withPort[2], 10); } } if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("로그 서버는 도메인 또는 IP[:포트] 형식으로 입력하세요."); } return { host: host, port: port }; } async function logEventSettings() { const root = form(); const settings = await api("/logs/retention"); setValue(root, "localSystem", settings.local_system_enabled); setValue(root, "remoteSystem", settings.remote_system_enabled); setValue(root, "localConfiguration", settings.local_configuration_enabled); setValue(root, "remoteConfiguration", settings.remote_configuration_enabled); setValue(root, "localOperation", settings.local_operation_enabled); setValue(root, "remoteOperation", settings.remote_operation_enabled); bindForm(root, function () { return api("/logs/retention", { method: "PATCH", body: { local_system_enabled: checked(root, "localSystem"), remote_system_enabled: checked(root, "remoteSystem"), local_configuration_enabled: checked(root, "localConfiguration"), remote_configuration_enabled: checked(root, "remoteConfiguration"), local_operation_enabled: checked(root, "localOperation"), remote_operation_enabled: checked(root, "remoteOperation") } }); }); } function alarmDelivery(root) { const delivery = ["web"]; if (checked(root, "deliveryEmail")) { delivery.push("email"); } if (checked(root, "deliverySNMP")) { delivery.push("snmp_trap"); } if (checked(root, "deliverySyslog")) { delivery.push("syslog"); } return delivery; } function fillAlarmDelivery(root, rules) { const delivery = new Set((rules || []).flatMap(function (rule) { return rule.delivery || []; })); setValue(root, "deliveryEmail", delivery.has("email")); setValue(root, "deliverySNMP", delivery.has("snmp_trap")); setValue(root, "deliverySyslog", delivery.has("syslog")); } async function alarmSystemEvents() { const root = form(); const rules = await api("/alarms/rules?limit=200"); const ruleByID = new Map(rules.map(function (rule) { return [rule.rule_id, rule]; })); const bindings = { coldStart: "cold_start", warmStart: "warm_start", authFailures: "auth_failures", linkFailover: "link_failover", serialLinkDown: "serial_link_down", ethernetLinkDown: "ethernet_link_down", storagePressure: "storage_pressure" }; Object.entries(bindings).forEach(function (entry) { setValue(root, entry[0], Boolean(ruleByID.get(entry[1])?.enabled)); }); fillAlarmDelivery(root, Object.values(bindings).map(function (id) { return ruleByID.get(id); }).filter(Boolean)); bindForm(root, function () { const delivery = alarmDelivery(root); return Promise.all(Object.entries(bindings).map(function (entry) { return api("/alarms/rules/" + entry[1], { method: "PATCH", body: { enabled: checked(root, entry[0]), delivery: delivery }}); })); }); } async function alarmSerialEvents() { const root = form(); const rules = await api("/alarms/rules?limit=200"); const ruleByID = new Map(rules.map(function (rule) { return [rule.rule_id, rule]; })); function IDs() { const channel = value(root, "channel").toLowerCase(); return { keyword: "port_" + channel + "_keyword", dcd: "port_" + channel + "_dcd", dsr: "port_" + channel + "_dsr" }; } function fillChannel() { const ids = IDs(); const selected = [ruleByID.get(ids.keyword), ruleByID.get(ids.dcd), ruleByID.get(ids.dsr)].filter(Boolean); setValue(root, "keyword", Boolean(ruleByID.get(ids.keyword)?.enabled)); setValue(root, "dcd", Boolean(ruleByID.get(ids.dcd)?.enabled)); setValue(root, "dsr", Boolean(ruleByID.get(ids.dsr)?.enabled)); const trigger = ruleByID.get(ids.keyword)?.trigger || ""; setValue(root, "keywordValue", trigger === "(not configured)" ? "" : trigger); fillAlarmDelivery(root, selected); } fillChannel(); field(root, "channel")?.addEventListener("change", fillChannel); bindForm(root, async function () { const ids = IDs(); const delivery = alarmDelivery(root); const trigger = value(root, "keywordValue").trim(); if (checked(root, "keyword") && !trigger) { throw new Error("감지 키워드를 입력하세요."); } const updates = Object.entries(ids).map(function (entry) { const body = { enabled: checked(root, entry[0]), delivery: delivery }; if (entry[0] === "keyword") { body.trigger = trigger; } return { id: entry[1], body: body }; }); const results = await Promise.all(updates.map(function (update) { return api("/alarms/rules/" + update.id, { method: "PATCH", body: update.body }); })); updates.forEach(function (update) { ruleByID.set(update.id, Object.assign({}, ruleByID.get(update.id), update.body)); }); return results; }); } async function alarmEmailSettings() { const root = form(); const settings = await api("/alarms/email-settings"); setValue(root, "enabled", settings.enabled); setValue(root, "smtp", settings.smtp_host || ""); setValue(root, "port", settings.smtp_port || 587); setValue(root, "starttls", settings.starttls_enabled); setValue(root, "auth", settings.auth_enabled); setValue(root, "username", settings.username || ""); setValue(root, "from", settings.from || ""); setValue(root, "to", (settings.to || []).join(", ")); setValue(root, "cc", (settings.cc || []).join(", ")); setValue(root, "title", settings.custom_title || "TERA 장비 알림"); setValue(root, "content", settings.custom_content || "Alarm: {{message}}"); function updateEmailFields() { const enabled = checked(root, "enabled"); const authenticated = enabled && checked(root, "auth"); setAutomatedFieldVisibility(root, ["smtp", "port", "starttls", "auth", "from", "to", "cc", "title", "content"], enabled); setAutomatedFieldVisibility(root, ["username", "password"], authenticated); const testButton = root.querySelector('[data-ui-action="secondary"]'); if (testButton) { testButton.disabled = !enabled; } } updateEmailFields(); field(root, "enabled")?.addEventListener("change", updateEmailFields); field(root, "auth")?.addEventListener("change", updateEmailFields); bindForm(root, function () { const password = value(root, "password"); if (checked(root, "auth") && !password && !settings.password_stored) { throw new Error("SMTP 인증을 사용하려면 비밀번호를 입력하세요."); } const update = { enabled: checked(root, "enabled"), smtp_host: value(root, "smtp"), smtp_port: integer(root, "port", 587), starttls_enabled: checked(root, "starttls"), auth_enabled: checked(root, "auth"), username: value(root, "username"), password_stored: Boolean(settings.password_stored || password), from: value(root, "from"), to: csvValues(value(root, "to")), cc: csvValues(value(root, "cc")), custom_title: value(root, "title"), custom_content: value(root, "content") }; if (password) { update.password = password; } return api("/alarms/email-settings", { method: "PATCH", body: update }); }); root.querySelector('[data-ui-action="secondary"]')?.addEventListener("click", async function () { try { await api("/alarms/delivery-test", { method: "POST", body: { delivery: ["email"], message: "TERA 메일 알림 테스트" } }); toast("메일 알림 테스트를 전송했습니다.", "success"); } catch (error) { report(error); } }); } async function alarmSNMPTrapSettings() { const root = form(); const settings = await api("/alarms/snmp-trap-targets"); const targets = Array.isArray(settings.targets) ? settings.targets : []; const target = targets[0] || {}; setValue(root, "enabled", target.enabled || false); setValue(root, "targetID", target.target_id || "primary-trap"); setValue(root, "host", target.host || ""); setValue(root, "port", target.port || 162); setValue(root, "version", target.version || "v2c"); setValue(root, "username", target.username || ""); setValue(root, "authProtocol", target.auth_protocol || "none"); setValue(root, "privacyProtocol", target.privacy_protocol || "none"); setValue(root, "engineID", target.engine_id || ""); function updateTrapFields() { const enabled = checked(root, "enabled"); const v3 = enabled && value(root, "version") === "v3"; const authentication = v3 && value(root, "authProtocol") !== "none"; const privacy = v3 && value(root, "privacyProtocol") !== "none"; setAutomatedFieldVisibility(root, ["targetID", "host", "port", "version"], enabled); setAutomatedFieldVisibility(root, ["username", "authProtocol", "privacyProtocol", "engineID"], v3); setAutomatedFieldVisibility(root, ["credential"], enabled && (!v3 || authentication)); setAutomatedFieldVisibility(root, ["privacyKey"], privacy); const testButton = root.querySelector('[data-ui-action="secondary"]'); if (testButton) { testButton.disabled = !enabled; } } updateTrapFields(); ["enabled", "version", "authProtocol", "privacyProtocol"].forEach(function (name) { field(root, name)?.addEventListener("change", updateTrapFields); }); bindForm(root, function () { const credential = value(root, "credential"); const privacyKey = value(root, "privacyKey"); const next = { target_id: value(root, "targetID"), host: value(root, "host"), port: integer(root, "port", 162), version: value(root, "version"), username: value(root, "username"), auth_protocol: value(root, "authProtocol"), privacy_protocol: value(root, "privacyProtocol"), engine_id: value(root, "engineID"), credential_stored: Boolean(target.credential_stored || credential), privacy_key_stored: Boolean(target.privacy_key_stored || privacyKey), enabled: checked(root, "enabled") }; if (credential) { next.credential = credential; } if (privacyKey) { next.privacy_key = privacyKey; } if (next.enabled && !next.credential_stored) { throw new Error("Trap 전송을 사용하려면 Community 또는 인증 키를 입력하세요."); } const remaining = targets.filter(function (item) { return item.target_id !== target.target_id; }); return api("/alarms/snmp-trap-targets", { method: "PATCH", body: { targets: [next].concat(remaining) } }); }); root.querySelector('[data-ui-action="secondary"]')?.addEventListener("click", async function () { try { await api("/alarms/delivery-test", { method: "POST", body: { delivery: ["snmp_trap"], message: "TERA SNMP Trap 테스트" } }); toast("SNMP Trap 테스트를 전송했습니다.", "success"); } catch (error) { report(error); } }); } async function diagnostics() { const root = form(); const result = root?.closest(".panel")?.querySelector(".result-box"); const kind = "network-test"; let activeRun = 0; function commandText(target) { return "ping " + target; } function writeConsole(lines, state) { if (!result) { return; } result.dataset.state = state || "running"; result.textContent = lines.filter(function (line) { return line !== undefined && line !== null; }).join("\n"); result.scrollTop = result.scrollHeight; } async function waitForResult(operation, command, timeoutSeconds, runID) { const deadline = Date.now() + (Math.max(timeoutSeconds, 1) + 15) * 1000; let current = operation; while (current && ["queued", "running"].includes(String(current.state || "").toLowerCase())) { if (runID !== activeRun) { return false; } const liveOutput = String(current?.result?.output || "").trimEnd(); writeConsole([ "C:\\> " + command, "", liveOutput || "명령을 실행하고 있습니다..." ], "running"); if (Date.now() >= deadline) { throw new Error("진단 결과 대기 시간이 초과되었습니다."); } await new Promise(function (resolve) { window.setTimeout(resolve, 400); }); if (runID !== activeRun) { return false; } current = await api("/operations/" + encodeURIComponent(current.operation_id)); } if (runID !== activeRun) { return false; } const operationResult = current?.result || {}; const output = String(operationResult.output || current?.message || "출력 결과가 없습니다.").trim(); const operationSucceeded = String(current?.state || "").toLowerCase() === "succeeded"; const exitCodePresent = operationResult.exit_code !== undefined && operationResult.exit_code !== null; const exitSucceeded = !exitCodePresent || Number(operationResult.exit_code) === 0; const reachable = operationResult.reachable; const unreachableOutput = /(?:network is unreachable|no route to host|destination host unreachable|100%\s+packet loss|(?:^|\s))/i.test(output); const succeeded = operationSucceeded && exitSucceeded && reachable !== false && !unreachableOutput; writeConsole([ "C:\\> " + command, "", output, "", "C:\\>" ], succeeded ? "succeeded" : "failed"); if (!succeeded) { throw new Error(operationSucceeded ? "대상에 연결되지 않았습니다." : (current?.message || "네트워크 진단에 실패했습니다.")); } return true; } markIntegrated(root); root?.addEventListener("submit", async function (event) { event.preventDefault(); const runID = ++activeRun; const target = value(root, "address"); const count = integer(root, "count", 4); const timeoutSeconds = count + 2; const command = commandText(target); writeConsole(["C:\\> " + command, "", "[TERA] 진단 작업을 요청하고 있습니다..."], "running"); try { const payload = await api("/diagnostics/" + kind, { method: "POST", body: { target: target, count: count, timeout_seconds: timeoutSeconds, max_output_bytes: 8192 } }); if (runID !== activeRun) { return; } const completed = await waitForResult(payload, command, timeoutSeconds, runID); if (!completed || runID !== activeRun) { return; } toast("네트워크 진단이 완료되었습니다.", "success"); } catch (error) { if (runID !== activeRun) { return; } if (result && result.dataset.state !== "failed") { writeConsole(["C:\\> " + command, "", "[ERROR] " + (error instanceof Error ? error.message : "진단 요청 실패"), "C:\\>"], "failed"); } report(error); } }); } async function aliases() { const root = form(); const ports = await api("/ports?limit=48"); const channelInput = field(root, "channel"); const portByChannel = new Map(ports.map(function (port) { return [String(port.channel || ""), port]; })); function refreshPortOptions() { Array.from(channelInput?.options || []).forEach(function (option) { const port = portByChannel.get(option.value); const number = portNumber(option.value); const alias = String(port?.alias || "").trim(); option.textContent = "Port " + number + " (" + option.value + ")" + (alias ? " - " + alias : ""); }); } function loadSelectedAlias() { const selected = portByChannel.get(value(root, "channel")); setValue(root, "alias", selected?.alias || ""); } refreshPortOptions(); loadSelectedAlias(); channelInput?.addEventListener("change", loadSelectedAlias); bindForm(root, async function () { const channel = value(root, "channel"); const alias = value(root, "alias").trim(); const updated = await api("/ports/" + encodeURIComponent(channel), { method: "PATCH", body: { alias: alias } }); portByChannel.set(channel, Object.assign({}, portByChannel.get(channel), updated, { alias: alias })); refreshPortOptions(); return updated; }); } async function mountLogManagement(parent) { if (document.body.dataset.currentRole !== "administrator") { return; } const section=document.createElement("section");section.className="panel"; section.innerHTML='
저장 공간을 확인하고 있습니다.
'; parent.after(section); const status=section.querySelector('[data-storage-status]');const retentionForm=section.querySelector('form'); let previousSample = null; async function refresh(preserveInputs) { if(!section.isConnected){return;} const [retention, response]=await Promise.all([api('/logs/retention'),api('/logs/storage').catch(function(){return null;})]); if(!section.isConnected){return;} ['system_days','audit_days','port_days','protected_usage_warning_pct'].forEach(function(key){if(!preserveInputs){retentionForm.elements[key].value=retention[key];}}); if(!response){status.textContent='저장 공간 상태를 확인할 수 없습니다.';return;} const storage=response.storage; status.textContent=['audit','system','port'].map(function(key){return ({audit:'감사',system:'시스템',port:'포트'})[key]+': '+formatBytes(storage.usage_bytes[key]||0)+' / '+formatBytes(storage.cap_bytes[key]||0);}).join(' · ')+' · 남은 장비 공간 '+formatBytes(storage.free_bytes)+' · '+(response.warning?'용량 부족 경고':'정상')+' · 보존 가능 기간은 실제 발생량에 따라 달라집니다.'; const sample={at:performance.now(),bytes:storage.usage_bytes}; let estimate='증가량 측정 중'; if(previousSample && sample.at-previousSample.at>=30000){ const elapsed=(sample.at-previousSample.at)/86400000; const days=['audit','system','port'].map(function(key){const delta=(sample.bytes[key]||0)-(previousSample.bytes[key]||0);return delta>0?Math.max(0,((storage.cap_bytes[key]||0)-(sample.bytes[key]||0))*elapsed/delta):Infinity;}); const shortest=Math.min.apply(null,days); estimate=Number.isFinite(shortest)?shortest.toFixed(1)+'일 (최근 증가량 기준, 보장값 아님)':'증가량이 없어 추정 불가'; previousSample=sample; } else if(!previousSample){previousSample=sample;} status.textContent+=' · 추가 저장 가능 추정: '+estimate; const dates=Object.values(storage.oldest||{}).filter(function(v){return v && !String(v).startsWith('0001');}).sort(); if(dates.length){status.textContent+=' · 가장 오래된 기록 '+dates[0];} } section.querySelector('[data-storage-refresh]').addEventListener('click',function(){refresh().catch(report);}); retentionForm.addEventListener('submit',async function(event){event.preventDefault();const button=retentionForm.querySelector('[type=submit]');button.disabled=true;try{const body={};['system_days','audit_days','port_days','protected_usage_warning_pct'].forEach(function(key){body[key]=Number(retentionForm.elements[key].value);});await api('/logs/retention',{method:'PATCH',body:body});await applyPendingConfiguration();await refresh();toast('로그 보존 설정을 저장했습니다.','success');}catch(error){report(error);}finally{button.disabled=false;}}); await refresh(); async function poll(){if(!section.isConnected){return;}try{await refresh(true);}catch(_){}if(section.isConnected){window.setTimeout(poll,30000);}} window.setTimeout(poll,30000); } async function logs() { const root = form(); const logKind = currentPath === "/logs/system" ? "system" : (currentPath === "/logs/links" ? "link" : "port"); const systemLog = logKind === "system"; const portLog = logKind === "port"; const linkLog = logKind === "link"; const title = systemLog ? "시스템 로그" : (linkLog ? "포트 상태 이벤트" : "포트 로그"); const tablePanel = panelByTitle(title); const tableBody = tablePanel?.querySelector("tbody"); const refreshButton = tablePanel?.querySelector('[data-ui-action="primary-action"]'); const exportButton = tablePanel?.querySelector('[data-ui-action="secondary-action"]'); const resetButton = root?.querySelector('[data-ui-action="secondary"]'); const autoRefresh = field(root, "auto_refresh"); if (!root || !tablePanel || !tableBody || !refreshButton || !exportButton) { return; } markIntegrated(root); markIntegrated(tablePanel); mountLogManagement(tablePanel).catch(report); if (document.body.dataset.currentRole === "port_operator") { const ports = await api("/ports?limit=48"); const allowed = new Set(ports.map(function(p){return p.channel;})); field(root,"channel")?.querySelectorAll("option").forEach(function(option){if(option.value && !allowed.has(option.value)){option.remove();}}); } const logPortAliases = new Map(); if (!systemLog) { (await api("/ports?limit=48&runtime=false")).forEach(function (port) { logPortAliases.set(port.channel, port.alias); }); } function channelWithAlias(channel) { const alias = logPortAliases.get(channel); return channel ? channel + (alias ? " · " + alias : "") : "-"; } const logView = window.teraLogView.mount(logKind, root, tablePanel); const logPagePath = currentPath; const logRequests = new AbortController(); let disposed = false; let appliedFilters = null; let pageOffset = 0; const pagination = document.createElement("div"); pagination.className = "panel-actions"; const previousPage = document.createElement("button"); const nextPage = document.createElement("button"); const pageLabel = document.createElement("span"); previousPage.type = nextPage.type = "button"; previousPage.className = nextPage.className = "btn secondary"; previousPage.textContent = "이전 페이지"; nextPage.textContent = "다음 페이지"; previousPage.disabled = nextPage.disabled = true; pagination.append(previousPage, pageLabel, nextPage); tablePanel.appendChild(pagination); previousPage.addEventListener("click", function () {pageOffset=Math.max(0,pageOffset-Number(value(root,"limit")||100));refreshLogs(false);}); nextPage.addEventListener("click", function () {pageOffset+=Number(value(root,"limit")||100);refreshLogs(false);}); let requestQueued = false; let clearInFlight = false; let resultsGeneration = 0; const liveStatus = document.createElement("span"); liveStatus.className = "log-live-status"; liveStatus.setAttribute("role", "status"); liveStatus.textContent = "조회 준비"; tablePanel.querySelector(".panel-actions.compact")?.prepend(liveStatus); let logTimezone = "Asia/Seoul"; const clockBanner = document.createElement("div"); clockBanner.className = "log-clock-banner"; clockBanner.setAttribute("role", "status"); clockBanner.textContent = "장비 로그 시각을 확인하고 있습니다."; tablePanel.querySelector(".log-result-footer")?.prepend(clockBanner); function formatLogTime(input) { const date = new Date(input); if (Number.isNaN(date.getTime())) { return String(input || "-"); } let output; try { output = new Intl.DateTimeFormat("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, timeZone: logTimezone }).format(date); } catch (_) { output = date.toLocaleString("ko-KR"); } return date.getUTCFullYear() < 2000 ? output + " · 시간 미설정" : output; } async function loadClockContext() { const results = await Promise.allSettled([api("/system/status", { signal: logRequests.signal }), api("/time/settings", { signal: logRequests.signal })]); if (disposed) { return; } const status = results[0].status === "fulfilled" ? results[0].value : null; const timeSettings = results[1].status === "fulfilled" ? results[1].value : null; if (timeSettings?.timezone) { try { new Intl.DateTimeFormat("ko-KR", { timeZone: timeSettings.timezone }); logTimezone = timeSettings.timezone; } catch (_) { clockBanner.className = "log-clock-banner warning"; clockBanner.textContent = "장비 시간대를 확인할 수 없습니다. 표시 시간대: " + logTimezone; return; } } const deviceTime = status?.system_time ? new Date(status.system_time) : null; if (deviceTime && !Number.isNaN(deviceTime.getTime()) && deviceTime.getUTCFullYear() < 2000) { clockBanner.className = "log-clock-banner warning"; clockBanner.textContent = "장비 시스템 시간이 설정되지 않았습니다 (" + formatLogTime(status.system_time) + "). 로그 날짜는 API에 저장된 원본 시각이며 브라우저 현재 날짜로 교체하지 않습니다."; return; } clockBanner.className = "log-clock-banner success"; clockBanner.textContent = "로그 원본 타임스탬프 · 표시 시간대: " + logTimezone; if (!timeSettings?.timezone || !deviceTime) { clockBanner.className = "log-clock-banner warning"; clockBanner.textContent = "장비 시각 정보를 일부 확인할 수 없습니다. 표시 시간대: " + logTimezone; } } const typeFieldName = systemLog ? "category" : "event_type"; const typeOptions = systemLog ? [["", "전체"], ["system", "시스템"], ["configuration", "설정"], ["operation", "운영"], ["status", "상태"]] : (linkLog ? [] : [["", "전체"], ["traffic", "트래픽"], ["command", "명령"], ["session", "세션"]]); const typeTabs = document.createElement("div"); typeTabs.className = "log-type-tabs"; typeTabs.setAttribute("role", "group"); typeTabs.setAttribute("aria-label", "로그 종류"); const typeHeading = document.createElement("strong"); typeHeading.textContent = "로그 종류"; typeTabs.appendChild(typeHeading); const typeButtons = typeOptions.map(function (option) { const button = document.createElement("button"); button.type = "button"; button.className = "log-type-tab"; button.dataset.logType = option[0]; button.textContent = option[1]; button.addEventListener("click", function () { pageOffset = 0; setValue(root, typeFieldName, option[0]); renderTypeTabs(); refreshLogs(false); }); typeTabs.appendChild(button); return button; }); if (!linkLog) { tablePanel.querySelector(".table-wrap")?.before(typeTabs); } function renderTypeTabs() { const selected = value(root, typeFieldName); typeButtons.forEach(function (button) { const active = button.dataset.logType === selected; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); } renderTypeTabs(); let refreshTimer = null; let requestInFlight = false; function timestampValue(name) { const raw = value(root, name); if (!raw) { return ""; } const parsed = new Date(raw); if (Number.isNaN(parsed.getTime())) { throw new Error(name === "from" ? "시작 시간을 확인하세요." : "종료 시간을 확인하세요."); } return parsed.toISOString(); } function filters() { const result = { keyword: value(root, "keyword"), from: timestampValue("from"), to: timestampValue("to"), limit: value(root, "limit") || "100", offset: String(pageOffset) }; if (result.from && result.to && new Date(result.from) > new Date(result.to)) { throw new Error("시작 시간은 종료 시간보다 늦을 수 없습니다."); } if (systemLog) { result.severity = value(root, "severity"); result.category = value(root, "category"); } else if (portLog) { result.channel = value(root, "channel"); result.event_type = value(root, "event_type"); result.direction = value(root, "direction"); result.actor = value(root, "actor"); } else { result.kind = "serial"; result.resource = value(root, "resource"); result.state = value(root, "state"); result.event_type = "transition"; } return result; } function queryPath(filter) { const params = new URLSearchParams(); Object.entries(filter).forEach(function (entry) { if (entry[1] !== "") { params.set(entry[0], entry[1]); } }); const endpoint = systemLog ? "/logs/system" : (linkLog ? "/logs/links" : "/logs/port"); return endpoint + "?" + params.toString(); } function eventTypeLabel(type) { return ({ traffic: "트래픽", command: "명령", session: "세션", operation: "작업" })[String(type || "").toLowerCase()] || type || "-"; } function severityLabel(severity) { return ({ info: "정보", warning: "경고", critical: "치명적" })[String(severity || "").toLowerCase()] || severity || "-"; } function categoryLabel(category) { return ({ system: "시스템 이벤트", configuration: "설정 변경", operation: "운영 모드", status: "상태" })[String(category || "").toLowerCase()] || category || "-"; } function sourceLabel(source) { const normalized = String(source || "").toLowerCase(); return ({ "tera-api": "관리 API", "tera-logd": "로그 서비스", "tera-netd": "네트워크 서비스", "tera-seriald": "시리얼 서비스", "tera-sessiond": "포트 세션 서비스", "system-lifecycle": "장비 시작/종료", network: "네트워크", configuration: "장비 설정", time: "날짜와 시간", logs: "로그 설정", alarm: "알림 설정", snmp: "SNMP 설정", security: "보안 설정", storage: "로그 저장소", serial: "시리얼 포트", authentication: "사용자 인증", auth: "사용자 인증", system: "시스템" })[normalized] || source || "-"; } function configurationTargetLabel(target) { const normalized = String(target || "").toLowerCase(); if (normalized.startsWith("network/")) { return "네트워크 적용"; } return ({ network: "네트워크", config: "전체 설정", time: "날짜와 시간", logs: "로그 정책", remote_syslog: "원격 로그", management_access: "관리 접속", tls_certificate: "SSL 인증서", address_filters: "주소 필터", external_auth: "외부 인증", alarm_delivery: "알림 전달", email_notification: "메일 알림", snmp_agent: "SNMP", snmp_trap_targets: "SNMP Trap" })[normalized] || target || ""; } function readableConfigurationAction(event) { const action = String(event.action || "").toLowerCase(); if (!action || String(event.category || "").toLowerCase() !== "configuration") { return ""; } const exact = { "network.update": "네트워크 설정 변경을 준비했습니다.", "network.apply": "네트워크 설정을 장비에 적용했습니다.", "network.confirm": "네트워크 설정을 확인하고 영구 저장했습니다.", "config.apply": "보류된 설정을 장비에 적용했습니다.", "config.save": "현재 설정을 영구 저장했습니다.", "config.save_and_apply": "설정을 장비에 적용하고 영구 저장했습니다.", "config.discard": "보류된 설정 변경을 취소했습니다.", "config.restore": "백업 설정을 복원 대상으로 불러왔습니다.", "port.update": "시리얼 포트 설정 변경을 준비했습니다.", "port.bulk_update": "여러 시리얼 포트의 설정 변경을 준비했습니다.", "time.settings.update": "날짜와 시간 설정을 변경했습니다.", "log.retention.update": "로그 저장 정책을 변경했습니다.", "log.remote_syslog.update": "원격 로그 전송 설정을 변경했습니다.", "security.management_access.update": "관리 접속 보안 설정을 변경했습니다.", "security.tls_certificate.create": "SSL 인증서를 생성했습니다.", "security.tls_certificate.import": "SSL 인증서를 가져왔습니다.", "security.tls_certificate.update": "SSL 인증서 설정을 변경했습니다.", "security.tls_certificate.delete": "SSL 인증서를 삭제했습니다.", "security.tls_certificate.csr": "SSL 인증서 서명 요청을 만들었습니다.", "auth.address_filter.update": "관리 주소 필터를 변경했습니다.", "auth.external.update": "외부 인증 설정을 변경했습니다.", "alarm.rule.update": "알림 규칙을 변경했습니다.", "alarm.delivery_policy.update": "알림 전달 정책을 변경했습니다.", "alarm.email.update": "메일 알림 설정을 변경했습니다.", "alarm.snmp_trap.update": "SNMP Trap 설정을 변경했습니다.", "snmp.agent.update": "SNMP 설정을 변경했습니다." }; let headline = exact[action] || ""; if (!headline && action.startsWith("network.rollback")) { headline = "네트워크 설정을 이전 상태로 복구했습니다."; } if (!headline && action.startsWith("auth.group.")) { headline = "사용자 권한 그룹 설정을 변경했습니다."; } if (!headline && action.startsWith("auth.user.")) { headline = "사용자 계정 설정을 변경했습니다."; } if (!headline) { headline = "장비 설정을 변경했습니다."; } const details = []; const target = configurationTargetLabel(event.target); if (target) { details.push("대상: " + target); } if (event.actor) { details.push("작업자: " + event.actor); } return headline + (details.length ? " · " + details.join(" · ") : ""); } function readableSystemMessage(event) { const configurationMessage = readableConfigurationAction(event); if (configurationMessage) { return configurationMessage; } const original = String(event.message || "").trim(); const normalized = original.toLowerCase(); const exact = { "configuration apply completed": "설정 적용이 완료되었습니다.", "primary link degraded; monitoring failover path": "주 네트워크 연결이 불안정하여 예비 경로를 확인하고 있습니다.", "protected log usage exceeded threshold": "로그 저장 공간 사용량이 경고 기준을 초과했습니다.", "log and link observer keepalive": "로그 및 연결 상태 감시가 정상 동작 중입니다.", "device cold start detected": "장비 전원이 켜져 새로 시작되었습니다.", "device warm restart scheduled": "장비 재시작이 예약되었습니다.", "login rejected": "로그인에 실패했습니다.", "login rejected; temporary lock applied": "로그인 실패 횟수가 기준을 초과하여 계정이 일시 잠겼습니다.", "login temporarily locked": "일시 잠긴 계정으로 로그인을 시도했습니다." }; if (exact[normalized]) { return exact[normalized]; } let matched = normalized.match(/^protected log usage reached (\d+)% \(warning threshold (\d+)%\)$/); if (matched) { return "로그 저장 공간 사용률이 " + matched[1] + "%로 경고 기준 " + matched[2] + "%에 도달했습니다."; } matched = normalized.match(/^(.+) interface (.+) link (up|down)$/); if (matched) { return matched[2] + " 연결이 " + (matched[3] === "up" ? "정상화되었습니다." : "끊어졌습니다."); } if (normalized === "interface healthy") { return "네트워크 인터페이스가 정상입니다."; } if (normalized === "interface degraded") { return "네트워크 인터페이스의 연결 상태가 불안정합니다."; } if (normalized === "storage exhausted") { return "로그 저장 공간이 부족합니다."; } return original || "-"; } function readablePortEventMessage(event) { const original = String(event.message || "").trim(); const normalized = original.toLowerCase(); if (normalized === "command completed") { return "명령 실행 완료"; } if (normalized === "command rejected") { return "명령 실행이 거부되었습니다."; } if (normalized === "completed") { return "작업이 완료되었습니다."; } let matched = normalized.match(/^(\d+) bytes received$/); if (matched) { return matched[1] + "바이트 수신"; } matched = normalized.match(/^(\d+) bytes transmitted$/); if (matched) { return matched[1] + "바이트 전송"; } matched = original.match(/^(RX|TX)\s+(\d+)\s+serial payload byte\(s\)$/i); if (matched) { return "시리얼 데이터 " + matched[2] + "바이트 " + (matched[1].toUpperCase() === "RX" ? "수신" : "전송"); } if (normalized === "serial payload") { return "시리얼 데이터를 처리했습니다."; } const remote = original.match(/Connected from:\s*([^\r\n]+)/i); if (normalized.includes("session") && (normalized.includes("closed") || normalized.includes("disconnected"))) { return "포트 접속 세션이 종료되었습니다." + (remote ? " · 원격: " + remote[1].trim() : ""); } if ((normalized.includes("welcome to tera") || normalized.includes("session")) && (normalized.includes("opened") || normalized.includes("connected from:"))) { return "포트 접속 세션이 시작되었습니다." + (remote ? " · 원격: " + remote[1].trim() : ""); } return original || "-"; } function directionLabel(direction) { return ({ RX: "수신(RX)", TX: "전송(TX)", CONNECTED: "연결", DISCONNECTED: "연결 종료", ERROR: "오류" })[String(direction || "").toUpperCase()] || direction || "-"; } function portMessage(event) { const details = []; const message = event.display_text || readablePortEventMessage(event); if (message) { details.push(message); } if (event.command) { details.push("명령: " + event.command); } if (event.return_value) { details.push("결과: " + event.return_value); } if (event.remote_address) { details.push("원격: " + event.remote_address + (event.remote_port ? ":" + event.remote_port : "")); } return details.join("\n") || "-"; } function rowsFor(events) { const ordered = events; if (systemLog) { return ordered.map(function (event) { return [formatLogTime(event.timestamp), severityLabel(event.severity), categoryLabel(event.category), sourceLabel(event.source), readableSystemMessage(event)]; }); } if (portLog) { return ordered.map(function (event) { return [formatLogTime(event.timestamp), channelWithAlias(event.channel), eventTypeLabel(event.event_type), directionLabel(event.direction), event.actor || "-", portMessage(event)]; }); } return ordered.map(function (event) { const previous = event.previous_state ? String(event.previous_state).toUpperCase() + " → " : ""; const state = previous + String(event.state || "unknown").toUpperCase(); const eventLabel = "상태 변경"; return [formatLogTime(event.timestamp), channelWithAlias(event.resource), String(event.signal || "-").toUpperCase(), state, eventLabel, event.message || "-"]; }); } function renderLogRows(events) { const ordered = window.teraLogView.orderedEvents(linkLog ? window.teraLogView.transitionsOnly(events) : events); logView.render(ordered, rowsFor(ordered), logTimezone, appliedFilters); } async function refreshLogs(silent) { if (disposed || !root.isConnected || currentPath !== logPagePath || document.hidden) { return; } if (clearInFlight) { return; } if (requestInFlight) { if (!silent) { requestQueued = true; } return; } if (silent && logView.busyReading()) { liveStatus.className = "log-live-status paused"; liveStatus.textContent = "읽는 중 · 갱신 대기"; logView.state("reading"); return; } requestInFlight = true; const requestGeneration = resultsGeneration; liveStatus.className = "log-live-status loading"; liveStatus.textContent = "조회 중"; logView.state("loading"); refreshButton.disabled = true; try { const nextFilters = silent && appliedFilters ? appliedFilters : filters(); const events = await api(queryPath(nextFilters), { signal: logRequests.signal }); if (disposed || !root.isConnected || requestQueued || requestGeneration !== resultsGeneration) { return; } if (!Array.isArray(events)) { throw new Error("로그 응답을 확인할 수 없습니다."); } appliedFilters = nextFilters; previousPage.disabled = pageOffset === 0; nextPage.disabled = events.length < Number(nextFilters.limit); pageLabel.textContent = (Math.floor(pageOffset/Number(nextFilters.limit))+1) + " 페이지 · " + events.length + "건"; renderLogRows(events); logView.state("ready"); liveStatus.className = "log-live-status success"; liveStatus.textContent = autoRefresh?.checked ? "자동 갱신 중 · 5초" : "자동 갱신 꺼짐"; } catch (error) { if (disposed || error.name === "AbortError") { return; } liveStatus.className = "log-live-status error"; liveStatus.textContent = "조회 실패"; logView.state("error"); if (silent) { console.error("log synchronization failed", error); } else { report(error); } } finally { requestInFlight = false; refreshButton.disabled = false; if (requestQueued && !disposed) { requestQueued = false; refreshLogs(false); } } } function scheduleRefresh() { if (refreshTimer !== null) { window.clearInterval(refreshTimer); refreshTimer = null; } if (autoRefresh?.checked) { refreshTimer = window.setInterval(function () { refreshLogs(true); }, 5000); } if (!["error", "loading"].includes(tablePanel.dataset.loadState)) { if (tablePanel.dataset.loadState === "reading" && autoRefresh?.checked) { return; } logView.state("ready"); liveStatus.className = autoRefresh?.checked ? "log-live-status success" : "log-live-status paused"; liveStatus.textContent = autoRefresh?.checked ? "자동 갱신 중 · 5초" : "자동 갱신 꺼짐"; } } async function downloadLogs() { const filter = filters(); const payload = { log_type: logKind, keyword: filter.keyword || undefined, from: filter.from || undefined, to: filter.to || undefined, max_rows: Number(value(root,"export_limit")) || 1000, format: (value(root,"export_format") || "CSV").toLowerCase() }; if (systemLog) { payload.severity = filter.severity || undefined; payload.category = filter.category || undefined; } else if (portLog) { payload.channel = filter.channel || undefined; payload.event_type = filter.event_type || undefined; payload.direction = filter.direction || undefined; payload.actor = filter.actor || undefined; } else { payload.kind = filter.kind || undefined; payload.resource = filter.resource || undefined; payload.state = filter.state || undefined; payload.event_type = filter.event_type || undefined; } exportButton.disabled = true; try { const response = await fetch("/api/v1/logs/export/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (response.status === 401) { window.location.assign("/login?next=" + encodeURIComponent(currentPath)); throw new Error("로그인 세션이 종료되었습니다. 다시 로그인하세요."); } if (!response.ok) { const contentType = response.headers.get("Content-Type") || ""; const errorPayload = contentType.includes("application/json") ? await response.json().catch(function () { return {}; }) : {}; throw new Error(errorPayload?.error?.message || errorPayload?.message || "로그 내보내기에 실패했습니다."); } const blob = await response.blob(); const disposition = response.headers.get("Content-Disposition") || ""; const filenameMatch = disposition.match(/filename="?([^";]+)"?/i); const filename = filenameMatch ? filenameMatch[1] : "tera-" + payload.log_type + "-logs.jsonl"; const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(function () { URL.revokeObjectURL(url); }, 1000); toast("로그 파일을 내려받았습니다.", "success"); } catch (error) { report(error); } finally { exportButton.disabled = false; } } root.addEventListener("submit", function (event) { event.preventDefault(); pageOffset = 0; refreshLogs(false); }); resetButton?.addEventListener("click", function () { root.reset(); pageOffset = 0; portLogClear?.update(); renderTypeTabs(); scheduleRefresh(); refreshLogs(false); }); refreshButton.addEventListener("click", function () { refreshLogs(false); }); exportButton.addEventListener("click", function () { downloadLogs().catch(report); }); const portLogClear = window.teraPortLogClear.mount({ kind: logKind, role: document.body.dataset.currentRole, document: document, parent: tablePanel.querySelector(".panel-header"), channelField: field(root, "channel"), isDisposed: function () { return disposed; }, confirm: function (message) { return window.prompt(window.teraI18n?.t ? window.teraI18n.t(message) : message); }, send: function (body) { return api(systemLog ? "/logs/system/clear" : linkLog ? "/logs/links/clear" : "/logs/port/clear", { method: "POST", body: body }); }, onBusy: function () { clearInFlight = true; ++resultsGeneration; }, onSuccess: function (channel, result) { toast(linkLog ? "포트 상태 이벤트 " + result.deleted + "건을 삭제했습니다. 현재 링크 상태는 유지됩니다." : systemLog ? "시스템 로그 " + result.deleted + "건을 삭제했습니다. 이후 발생하는 로그는 계속 저장됩니다." : (channel === "ALL" ? "전체" : channel) + " 포트 로그 " + result.deleted + "건을 삭제했습니다. 이후 발생하는 로그는 계속 저장됩니다.", "success"); renderLogRows([]); }, onError: report, onSettled: async function () { clearInFlight = false; pageOffset = 0; if (!disposed) { await refreshLogs(false); } } }); autoRefresh?.addEventListener("change", scheduleRefresh); function onVisibilityChange() { if (!document.hidden && autoRefresh?.checked) { refreshLogs(true); } } function cleanupLogs() { disposed = true; portLogClear?.dispose(); logRequests.abort(); if (refreshTimer !== null) { window.clearInterval(refreshTimer); } document.removeEventListener("visibilitychange", onVisibilityChange); window.removeEventListener("beforeunload", cleanupLogs); } disposeLogPage = cleanupLogs; document.addEventListener("visibilitychange", onVisibilityChange); window.addEventListener("beforeunload", cleanupLogs); await loadClockContext().catch(function (error) { if (disposed) { return; } console.error("log clock context failed", error); clockBanner.className = "log-clock-banner warning"; clockBanner.textContent = "장비 시각 정보를 확인할 수 없습니다. 로그 행에는 API 원본 타임스탬프를 표시합니다."; }); if (disposed) { return; } await refreshLogs(false); scheduleRefresh(); } async function portStatus() { const ports = decoratePortStates(await api(portRuntimeStatusPath)); updatePortMap(ports); renderPortStatusRows(ports); await aliases(); } function serialTerminal() { const root = document.querySelector("[data-serial-terminal]"); if (!root) { return; } const portSelect = root.querySelector("[data-terminal-port]"); const toggleButton = root.querySelector("[data-terminal-toggle]"); const clearButton = root.querySelector("[data-terminal-clear]"); const advancedToggle = root.querySelector("[data-terminal-advanced-toggle]"); const advancedPanel = root.querySelector("[data-terminal-advanced-panel]"); const batchFormat = root.querySelector("[data-terminal-send-format]"); const batchValue = root.querySelector("[data-terminal-send-value]"); const batchEnding = root.querySelector("[data-terminal-send-ending]"); const batchSend = root.querySelector("[data-terminal-send]"); const logDownload = root.querySelector("[data-terminal-log-download]"); const logClear = root.querySelector("[data-terminal-log-clear]"); const logState = root.querySelector("[data-terminal-log-state]"); const screen = root.querySelector("[data-terminal-screen]"); const output = root.querySelector("[data-terminal-output]"); const commandLine = root.querySelector("[data-terminal-command-line]"); const input = root.querySelector("[data-terminal-input]"); const title = root.querySelector("[data-terminal-title]"); const state = root.querySelector("[data-terminal-state]"); if (!portSelect || !toggleButton || !clearButton || !advancedToggle || !advancedPanel || !batchFormat || !batchValue || !batchEnding || !batchSend || !logDownload || !logClear || !logState || !screen || !output || !commandLine || !input || !title || !state) { return; } const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: false }); const maxOutputCharacters = 200000; const availabilityRefreshMilliseconds = 1000; const readinessRefreshMilliseconds = 1000; const defaultPortNumber = Number.parseInt(root.dataset.defaultPort || "", 10); const portStates = new Map(); let pendingOutput = ""; let outputFlushFrame = null; let socket = null; let sessionID = ""; let authenticated = false; let operatorClose = false; let connectionState = "checking"; let selectedPortAvailable = false; let availabilityInFlight = false; let availabilityTimer = null; let portSelectInteracting = false; let connectionAttempt = 0; let servicesReady = false; let readinessInFlight = false; let readinessTimer = null; let localInput = ""; let localCursor = 0; let localSelectionAnchor = 0; let expectedEcho = ""; let committedEcho = ""; let expectedCursorEcho = ""; let expectedCursorEchoUntil = 0; let remoteEditEchoState = 0; let suppressRemoteEraseUntil = 0; let pointerSelecting = false; let trafficBytes = 0; const trafficLog = []; function uiText(value) { return window.teraI18n?.t ? window.teraI18n.t(value) : String(value); } function renderInitialTerminalMessage() { if (output.dataset.terminalInitial === "true") { output.textContent = uiText("TERA Serial Console\n포트 연결 상태를 확인하고 있습니다.\n"); } } function bytesOf(payload) { if (payload instanceof Uint8Array) { return payload; } if (payload instanceof ArrayBuffer) { return new Uint8Array(payload); } return encoder.encode(String(payload)); } function readableBytes(value) { if (value < 1024) { return value + " B"; } if (value < 1024 * 1024) { return (value / 1024).toFixed(1) + " KiB"; } return (value / (1024 * 1024)).toFixed(1) + " MiB"; } function recordTraffic(direction, payload) { const bytes = bytesOf(payload); trafficBytes += bytes.byteLength; const hex = Array.from(bytes).map(function (value) { return value.toString(16).padStart(2, "0").toUpperCase(); }).join(" "); const ascii = Array.from(bytes).map(function (value) { if (value === 13) { return "\\r"; } if (value === 10) { return "\\n"; } if (value === 9) { return "\\t"; } return value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : "."; }).join(""); trafficLog.push(new Date().toISOString() + " " + direction + " [" + bytes.byteLength + " B] ASCII=" + ascii + " HEX=" + hex); if (trafficLog.length > 20000) { trafficLog.splice(0, trafficLog.length - 20000); } logState.textContent = uiText("로그") + " " + readableBytes(trafficBytes); } function channel() { const number = Number.parseInt(portSelect.value, 10); if (!Number.isInteger(number) || number < 1 || number > 48) { return ""; } return "CH" + String(number).padStart(2, "0"); } function setState(next, label) { state.dataset.state = next; state.lastChild.textContent = uiText(label); } function scrollToCommandLine() { window.requestAnimationFrame(function () { screen.scrollTop = screen.scrollHeight; }); } function stripANSI(value) { return String(value || "") .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); } function eraseLastTerminalCharacter() { if (pendingOutput) { if (!pendingOutput.endsWith("\n")) { pendingOutput = pendingOutput.slice(0, -1); } return; } if (output.textContent && !output.textContent.endsWith("\n")) { output.textContent = output.textContent.slice(0, -1); } } function normalizeTerminalOutput(value) { const source = stripANSI(value); let normalized = ""; for (let index = 0; index < source.length; index += 1) { const character = source[index]; if (remoteEditEchoState === 1) { if (character === " ") { remoteEditEchoState = 2; continue; } remoteEditEchoState = 0; } else if (remoteEditEchoState === 2) { remoteEditEchoState = 0; if (character === "\b" || character === "\x7f") { continue; } } if (character === "\b" || character === "\x7f") { if (Date.now() <= suppressRemoteEraseUntil) { remoteEditEchoState = 1; continue; } if (normalized) { if (!normalized.endsWith("\n")) { normalized = normalized.slice(0, -1); } } else { eraseLastTerminalCharacter(); } continue; } if (character === "\r") { if (source[index + 1] === "\n") { index += 1; } normalized += "\n"; continue; } if (character === "\n" || character === "\t" || character >= " ") { normalized += character; } } return normalized; } function flushOutput() { outputFlushFrame = null; if (!pendingOutput) { return; } const nextOutput = output.textContent + pendingOutput; pendingOutput = ""; output.textContent = nextOutput.length > maxOutputCharacters ? nextOutput.slice(-maxOutputCharacters) : nextOutput; renderLocalInput(); scrollToCommandLine(); } function appendOutput(value) { const normalized = normalizeTerminalOutput(value); if (!normalized) { return; } if (output.dataset.terminalInitial === "true") { output.dataset.terminalInitial = "false"; output.textContent = ""; } pendingOutput += normalized; if (pendingOutput.length > maxOutputCharacters) { pendingOutput = pendingOutput.slice(-maxOutputCharacters); } if (outputFlushFrame === null) { outputFlushFrame = window.requestAnimationFrame(flushOutput); } } function appendSystem(value) { appendOutput("\n[TERA] " + uiText(value) + "\n"); } function updateChannelLabels() { const selected = channel(); title.textContent = selected ? ("TERA Serial Console - " + selected) : "TERA Serial Console - UP 채널 없음"; } function localSelectionBounds() { return { start: Math.min(localSelectionAnchor, localCursor), end: Math.max(localSelectionAnchor, localCursor) }; } function hasLocalSelection() { return localSelectionAnchor !== localCursor; } function commandSelectionPoint(offset) { let remaining = Math.max(0, Math.min(offset, localInput.length)); const walker = document.createTreeWalker(input, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { const logicalLength = node.textContent.length; if (remaining <= logicalLength) { return { node: node, offset: remaining }; } remaining -= logicalLength; node = walker.nextNode(); } return { node: input, offset: input.childNodes.length }; } function applyCommandSelection() { if (document.activeElement !== input) { return; } const selection = window.getSelection(); if (!selection) { return; } const anchor = commandSelectionPoint(localSelectionAnchor); const focus = commandSelectionPoint(localCursor); if (typeof selection.setBaseAndExtent === "function") { selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset); return; } const range = document.createRange(); const start = commandSelectionPoint(Math.min(localSelectionAnchor, localCursor)); const end = commandSelectionPoint(Math.max(localSelectionAnchor, localCursor)); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); selection.removeAllRanges(); selection.addRange(range); } function moveCommandCursor(position) { localCursor = Math.max(0, Math.min(position, localInput.length)); localSelectionAnchor = localCursor; applyCommandSelection(); } function selectionOffset(node, offset) { const range = document.createRange(); range.selectNodeContents(input); range.setEnd(node, offset); return Math.max(0, Math.min(range.toString().length, localInput.length)); } function updateLocalCursorFromSelection() { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || !input.contains(selection.anchorNode) || !input.contains(selection.focusNode)) { return false; } localSelectionAnchor = selectionOffset(selection.anchorNode, selection.anchorOffset); localCursor = selectionOffset(selection.focusNode, selection.focusOffset); return true; } function renderLocalInput() { localCursor = Math.max(0, Math.min(localCursor, localInput.length)); localSelectionAnchor = Math.max(0, Math.min(localSelectionAnchor, localInput.length)); const cursorKey = String(localCursor); const anchorKey = String(localSelectionAnchor); const changed = input.dataset.terminalValue !== localInput || input.dataset.terminalCursor !== cursorKey || input.dataset.terminalAnchor !== anchorKey; if (changed) { const before = localInput.slice(0, localCursor); const after = localInput.slice(localCursor); const cursor = document.createElement("span"); cursor.className = "terminal-block-cursor"; cursor.setAttribute("aria-hidden", "true"); input.replaceChildren(); if (before) { input.append(document.createTextNode(before)); } input.append(cursor); if (after) { input.append(document.createTextNode(after)); } input.dataset.terminalValue = localInput; input.dataset.terminalCursor = cursorKey; input.dataset.terminalAnchor = anchorKey; } input.classList.toggle("has-terminal-selection", hasLocalSelection()); if (connectionState === "connected" && document.activeElement === input && !pointerSelecting) { applyCommandSelection(); if (changed) { scrollToCommandLine(); } } } function queueLocalInput(value) { if (!value) { return; } deleteLocalSelection(); localInput = localInput.slice(0, localCursor) + value + localInput.slice(localCursor); localCursor += value.length; localSelectionAnchor = localCursor; expectedEcho += value; renderLocalInput(); } function commitLocalInput(newLine) { if (!localInput && !newLine) { return; } const pending = localInput; if (pending) { appendOutput(pending); } if (newLine) { appendOutput("\n"); } committedEcho = expectedEcho + (newLine ? "\r\n" : ""); localInput = ""; localCursor = 0; localSelectionAnchor = 0; expectedEcho = ""; expectedCursorEcho = ""; renderLocalInput(); } function queueExpectedCursorEcho(value) { if (!value) { return; } expectedCursorEcho += value; expectedCursorEchoUntil = Date.now() + 1500; } function sendLocalCursorMovement(from, to) { if (from === to) { return; } suppressRemoteEraseUntil = Date.now() + 1500; if (to > from) { queueExpectedCursorEcho(localInput.slice(from, to)); sendTerminalBytes("\x1b[C".repeat(to - from)); } else { sendTerminalBytes("\x1b[D".repeat(from - to)); } } function deleteLocalSelection() { const bounds = localSelectionBounds(); if (bounds.start === bounds.end) { return false; } const count = bounds.end - bounds.start; suppressRemoteEraseUntil = Date.now() + 1500; if (localCursor === bounds.end) { sendTerminalBytes("\x08".repeat(count)); } else { sendTerminalBytes("\x1b[3~".repeat(count)); } localInput = localInput.slice(0, bounds.start) + localInput.slice(bounds.end); localCursor = bounds.start; localSelectionAnchor = bounds.start; queueExpectedCursorEcho(localInput.slice(localCursor) + " ".repeat(count)); return true; } function reconcileRemoteEcho(value) { let rendered = ""; if (expectedCursorEcho && Date.now() > expectedCursorEchoUntil) { expectedCursorEcho = ""; } for (const character of stripANSI(value)) { if (committedEcho) { const expected = committedEcho[0]; if (character === expected || (character === "\n" && expected === "\r")) { committedEcho = committedEcho.slice(character === "\n" && committedEcho.startsWith("\r\n") ? 2 : 1); continue; } committedEcho = ""; } if (expectedEcho && character === expectedEcho[0]) { expectedEcho = expectedEcho.slice(1); continue; } if (expectedCursorEcho && character === expectedCursorEcho[0]) { expectedCursorEcho = expectedCursorEcho.slice(1); continue; } rendered += character; } return rendered; } function selectedView() { return portStates.get(channel()) || { active: false, label: "Unknown", summary: "포트 연결 상태가 확인되지 않음" }; } function rememberPortState(port) { const view = portView(decoratePortStates([port])[0]); const selectedChannel = String(port.channel || ""); portStates.set(selectedChannel, view); if (selectedChannel === channel()) { selectedPortAvailable = view.active; } return view; } function renderAvailablePorts(ports) { const previousValue = portSelect.value; const available = ports .filter(function (port) { return portStates.get(String(port.channel || ""))?.active === true; }) .sort(function (left, right) { return portNumber(left.channel) - portNumber(right.channel); }); const availableValues = available.map(function (port) { return String(portNumber(port.channel)); }); const renderedValues = Array.from(portSelect.options).map(function (option) { return option.value; }).filter(Boolean); if (portSelect.dataset.availabilityLoaded === "true" && availableValues.join(",") === renderedValues.join(",")) { selectedPortAvailable = availableValues.includes(previousValue); updateChannelLabels(); return; } portSelect.replaceChildren(); portSelect.dataset.availabilityLoaded = "true"; if (!available.length) { const empty = document.createElement("option"); empty.value = ""; empty.textContent = "UP 상태 채널 없음"; empty.disabled = true; empty.selected = true; portSelect.appendChild(empty); selectedPortAvailable = false; updateChannelLabels(); return; } available.forEach(function (port) { const number = portNumber(port.channel); const option = document.createElement("option"); option.value = String(number); option.textContent = "CH" + String(number).padStart(2, "0") + " / Port " + number; option.title = portStates.get(String(port.channel || ""))?.summary || "UP"; portSelect.appendChild(option); }); const preferredDefault = Number.isInteger(defaultPortNumber) ? String(defaultPortNumber) : ""; portSelect.value = availableValues.includes(previousValue) ? previousValue : (availableValues.includes(preferredDefault) ? preferredDefault : availableValues[0]); selectedPortAvailable = true; updateChannelLabels(); } function applyControlState() { const busy = connectionState === "connecting" || connectionState === "connected"; persistentTerminalNavigation.active = busy; document.body.dataset.terminalSessionActive = busy ? "true" : "false"; if (busy) { persistentTerminalNavigation.terminalContent = root.closest(".content"); persistentTerminalNavigation.terminalPath = currentPath; } toggleButton.disabled = !busy && (!servicesReady || !selectedPortAvailable); toggleButton.textContent = busy ? "연결 종료" : "접속"; toggleButton.classList.toggle("connect", !busy); toggleButton.classList.toggle("disconnect", busy); toggleButton.title = busy ? "현재 시리얼 연결을 종료합니다." : (!servicesReady ? "시리얼 포트 서비스가 준비될 때까지 접속할 수 없습니다." : (selectedPortAvailable ? "선택한 채널에 접속합니다." : "UP 상태의 채널만 접속할 수 있습니다.")); portSelect.disabled = busy; input.contentEditable = connectionState === "connected" ? "true" : "false"; input.setAttribute("aria-disabled", connectionState === "connected" ? "false" : "true"); commandLine.hidden = connectionState !== "connected"; batchSend.disabled = connectionState !== "connected"; batchValue.disabled = connectionState !== "connected"; batchFormat.disabled = connectionState !== "connected"; batchEnding.disabled = connectionState !== "connected"; if (connectionState === "connected") { localCursor = localInput.length; localSelectionAnchor = localCursor; input.focus(); moveCommandCursor(localCursor); scrollToCommandLine(); } else { localInput = ""; localCursor = 0; localSelectionAnchor = 0; expectedEcho = ""; committedEcho = ""; expectedCursorEcho = ""; remoteEditEchoState = 0; renderLocalInput(); } } function resetControls(nextState, label) { connectionState = nextState; setState(nextState, label); applyControlState(); } function showSelectedAvailability() { if (connectionState === "connecting" || connectionState === "connected") { return; } if (!servicesReady) { resetControls("starting", "시리얼 서비스 준비 중"); return; } const selectedChannel = channel(); if (!selectedChannel) { selectedPortAvailable = false; resetControls("unavailable", "UP 상태 채널 없음"); return; } const view = selectedView(); selectedPortAvailable = view.active; if (view.active) { resetControls("ready", selectedChannel + " 접속 가능"); } else { resetControls("unavailable", selectedChannel + " " + view.label); } } async function readServiceReadiness() { const response = await fetch("/port-debug/readiness", { method: "GET", headers: { Accept: "application/json" }, cache: "no-store", credentials: "same-origin" }); if (response.status === 401) { window.location.assign("/login?return_to=" + encodeURIComponent(window.location.pathname)); throw new Error("로그인이 필요합니다."); } if (!response.ok) { throw new Error("시리얼 서비스 준비 상태를 확인하지 못했습니다."); } const payload = await response.json(); return payload && payload.ready === true; } async function refreshServiceReadiness(silent) { if (readinessInFlight || connectionState === "connecting" || connectionState === "connected") { return servicesReady; } readinessInFlight = true; try { servicesReady = await readServiceReadiness(); showSelectedAvailability(); return servicesReady; } catch (error) { servicesReady = false; resetControls("starting", "시리얼 서비스 준비 중"); if (!silent) { appendSystem("시리얼 포트 서비스가 준비될 때까지 접속이 비활성화됩니다."); } return false; } finally { readinessInFlight = false; } } async function verifyServiceReadiness() { servicesReady = await readServiceReadiness(); if (!servicesReady) { throw new Error("시리얼 포트 서비스가 아직 준비되지 않았습니다. 잠시 후 다시 시도하세요."); } } async function refreshPortAvailability(silent) { if (portSelectInteracting || availabilityInFlight || connectionState === "connecting" || connectionState === "connected") { return; } availabilityInFlight = true; try { const ports = await api(portRuntimeStatusPath); if (portSelectInteracting || connectionState === "connecting" || connectionState === "connected") { return; } ports.forEach(rememberPortState); renderAvailablePorts(ports); showSelectedAvailability(); } catch (error) { selectedPortAvailable = false; resetControls("error", "포트 상태 확인 실패"); if (!silent) { report(error); appendSystem("포트 연결 상태를 확인하지 못했습니다."); } } finally { availabilityInFlight = false; } } async function verifySelectedPort(selectedChannel) { const port = await api("/ports/" + selectedChannel); const view = rememberPortState(port); if (!view.active) { throw new Error(selectedChannel + "은 " + view.label + " 상태입니다. UP 채널만 접속할 수 있습니다."); } selectedPortAvailable = true; } function closeSocket() { connectionAttempt += 1; operatorClose = true; if (socket && (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN)) { socket.close(1000, "operator disconnected"); } else { socket = null; sessionID = ""; authenticated = false; resetControls("disconnected", "연결 안 됨"); showSelectedAvailability(); } } toggleButton.addEventListener("click", async function () { if (connectionState === "connecting" || connectionState === "connected") { closeSocket(); return; } const selectedChannel = channel(); if (!selectedChannel) { report(new Error("접속 가능한 UP 채널이 없습니다.")); return; } operatorClose = false; authenticated = false; const attempt = ++connectionAttempt; resetControls("connecting", selectedChannel + " 상태 확인 중"); try { await verifyServiceReadiness(); await verifySelectedPort(selectedChannel); if (attempt !== connectionAttempt) { return; } setState("connecting", "연결 중"); appendSystem(selectedChannel + " writer 세션을 요청합니다."); const session = await api("/direct/" + selectedChannel + "/sessions", { method: "POST", body: { access: "writer" } }); if (attempt !== connectionAttempt) { if (session && session.session_id) { api("/direct/" + selectedChannel + "/sessions/" + encodeURIComponent(session.session_id), { method: "DELETE" }).catch(function () {}); } return; } if (!session || !session.session_id || !session.stream_url || !session.stream_ticket) { throw new Error("시리얼 스트림 세션 응답이 올바르지 않습니다."); } sessionID = session.session_id; const streamURL = new URL(session.stream_url, window.location.origin); streamURL.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const activeSocket = new WebSocket(streamURL.href, "tera.direct.v1"); activeSocket.binaryType = "arraybuffer"; socket = activeSocket; activeSocket.addEventListener("open", function () { if (socket !== activeSocket) { return; } setState("connecting", "인증 중"); activeSocket.send(JSON.stringify({ type: "authenticate", ticket: session.stream_ticket })); }); activeSocket.addEventListener("message", function (event) { if (socket !== activeSocket) { return; } if (typeof event.data === "string") { try { const frame = JSON.parse(event.data); if (frame.type === "session.status") { authenticated = true; if (frame.writer === true) { resetControls("connected", selectedChannel + " 연결됨"); appendSystem(selectedChannel + "에 접속했습니다. 터미널에 직접 입력하세요."); } else { resetControls("connecting", selectedChannel + " 쓰기 권한 대기"); appendSystem(selectedChannel + "의 출력은 수신 중이지만 쓰기 권한을 아직 얻지 못했습니다."); } return; } if (frame.type === "error") { setState("error", "스트림 오류"); appendSystem(frame.message || frame.code || "스트림 오류가 발생했습니다."); } } catch (error) { appendSystem("상태 메시지를 해석하지 못했습니다."); } return; } const payload = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : event.data; recordTraffic("RX", payload); appendOutput(reconcileRemoteEcho(decoder.decode(payload, { stream: true }))); }); activeSocket.addEventListener("error", function () { if (socket !== activeSocket) { return; } setState("error", "연결 오류"); appendSystem("시리얼 스트림에 연결하지 못했습니다."); }); activeSocket.addEventListener("close", function (event) { if (socket !== activeSocket) { return; } socket = null; const wasOperatorClose = operatorClose; const wasAuthenticated = authenticated; const closingSessionID = sessionID; sessionID = ""; authenticated = false; operatorClose = false; if (!wasAuthenticated && closingSessionID) { api("/direct/" + selectedChannel + "/sessions/" + encodeURIComponent(closingSessionID), { method: "DELETE" }).catch(function () {}); } const sessionLimitReached = event.reason === "channel session limit reached"; resetControls(wasOperatorClose ? "disconnected" : (wasAuthenticated ? "disconnected" : "error"), sessionLimitReached ? "최대 세션 도달" : (wasOperatorClose ? "연결 종료" : "연결 끊김")); appendSystem(sessionLimitReached ? "이 포트의 최대 세션 수에 도달했습니다. 웹 직접 연결과 동작 1·2의 접속 수를 합산합니다." : (wasOperatorClose ? "운영자가 연결을 종료했습니다." : ("연결이 종료되었습니다. (code " + event.code + ")"))); refreshPortAvailability(true); }); } catch (error) { if (attempt !== connectionAttempt) { return; } if (sessionID && !authenticated) { api("/direct/" + selectedChannel + "/sessions/" + encodeURIComponent(sessionID), { method: "DELETE" }).catch(function () {}); } socket = null; sessionID = ""; authenticated = false; const view = selectedView(); selectedPortAvailable = view.active; resetControls(view.active ? "error" : "unavailable", view.active ? "접속 실패" : (selectedChannel + " " + view.label)); report(error); appendSystem(error instanceof Error ? error.message : "접속에 실패했습니다."); } }); clearButton.addEventListener("click", function () { pendingOutput = ""; if (outputFlushFrame !== null) { window.cancelAnimationFrame(outputFlushFrame); outputFlushFrame = null; } output.textContent = "TERA Serial Console\n"; localInput = ""; localCursor = 0; localSelectionAnchor = 0; expectedEcho = ""; committedEcho = ""; expectedCursorEcho = ""; remoteEditEchoState = 0; renderLocalInput(); appendSystem((channel() || "선택 채널") + " 화면을 지웠습니다."); if (connectionState === "connected") { input.focus(); moveCommandCursor(localCursor); } }); function resumePortAvailability() { portSelectInteracting = false; refreshPortAvailability(true); } portSelect.addEventListener("pointerdown", function () { portSelectInteracting = true; }); portSelect.addEventListener("focus", function () { portSelectInteracting = true; }); portSelect.addEventListener("blur", resumePortAvailability); portSelect.addEventListener("keydown", function (event) { if (event.key === "Escape") { window.setTimeout(resumePortAvailability, 0); } }); portSelect.addEventListener("change", function () { portSelectInteracting = false; updateChannelLabels(); output.textContent = "TERA Serial Console\n선택 채널: " + (channel() || "없음") + "\n"; showSelectedAvailability(); window.setTimeout(function () { refreshPortAvailability(true); }, 250); }); function sendTerminalBytes(payload) { if (!socket || socket.readyState !== WebSocket.OPEN || !authenticated) { return false; } const bytes = bytesOf(payload); if (bytes.byteLength === 0) { return false; } socket.send(bytes); recordTraffic("TX", bytes); return true; } advancedToggle.addEventListener("click", function () { const opening = advancedToggle.getAttribute("aria-expanded") !== "true"; advancedToggle.setAttribute("aria-expanded", opening ? "true" : "false"); advancedPanel.hidden = !opening; if (opening && connectionState === "connected") { batchValue.focus(); } else if (!opening && connectionState === "connected") { input.focus(); moveCommandCursor(localCursor); } }); function parseHex(value) { const compact = String(value || "").replace(/0x/gi, "").replace(/[\s,;:_-]+/g, ""); if (!compact || compact.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(compact)) { throw new Error("HEX 값은 2자리 바이트 단위로 입력하세요. 예: 48 65 6C 6C 6F 0D"); } const bytes = new Uint8Array(compact.length / 2); for (let index = 0; index < compact.length; index += 2) { bytes[index / 2] = Number.parseInt(compact.slice(index, index + 2), 16); } return bytes; } function endingBytes(kind) { if (kind === "cr") { return new Uint8Array([13]); } if (kind === "lf") { return new Uint8Array([10]); } if (kind === "crlf") { return new Uint8Array([13, 10]); } return new Uint8Array(0); } function joinBytes(left, right) { const joined = new Uint8Array(left.byteLength + right.byteLength); joined.set(left, 0); joined.set(right, left.byteLength); return joined; } function updateBatchPlaceholder() { batchValue.placeholder = batchFormat.value === "hex" ? "HEX 바이트 입력 (예: 48 65 6C 6C 6F 0D)" : "ASCII 문자열 입력"; } batchFormat.addEventListener("change", updateBatchPlaceholder); batchSend.addEventListener("click", function () { try { const format = batchFormat.value; const source = batchValue.value; const body = format === "hex" ? parseHex(source) : encoder.encode(source); const suffix = endingBytes(batchEnding.value); const payload = joinBytes(body, suffix); if (payload.byteLength === 0) { throw new Error("전송할 값 또는 끝 문자를 선택하세요."); } if (format === "ascii" && source) { queueLocalInput(source); } if (suffix.byteLength > 0 && format === "ascii") { commitLocalInput(true); } if (!sendTerminalBytes(payload)) { throw new Error("시리얼 포트가 연결된 상태에서 전송하세요."); } if (format === "hex") { appendSystem("HEX " + Array.from(body).map(function (value) { return value.toString(16).padStart(2, "0").toUpperCase(); }).join(" ") + " 전송"); } batchValue.select(); } catch (error) { report(error); } }); batchValue.addEventListener("keydown", function (event) { if (event.key === "Enter") { event.preventDefault(); batchSend.click(); } }); logDownload.addEventListener("click", function () { if (!trafficLog.length) { report(new Error("저장할 시리얼 통신 로그가 없습니다.")); return; } const blob = new Blob([trafficLog.join("\n") + "\n"], { type: "text/plain;charset=utf-8" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "tera-serial-" + (channel() || "channel") + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".log"; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(function () { URL.revokeObjectURL(link.href); }, 0); }); logClear.addEventListener("click", function () { trafficLog.length = 0; trafficBytes = 0; logState.textContent = "로그 0 B"; }); updateBatchPlaceholder(); function physicalASCII(event) { const code = String(event.code || ""); if (/^Key[A-Z]$/.test(code)) { const letter = code.slice(3).toLowerCase(); const capsLock = Boolean(event.getModifierState && event.getModifierState("CapsLock")); return event.shiftKey !== capsLock ? letter.toUpperCase() : letter; } const normal = { Digit0: "0", Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5", Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Backquote: "`", Minus: "-", Equal: "=", BracketLeft: "[", BracketRight: "]", Backslash: "\\", Semicolon: ";", Quote: "'", Comma: ",", Period: ".", Slash: "/", Space: " ", Numpad0: "0", Numpad1: "1", Numpad2: "2", Numpad3: "3", Numpad4: "4", Numpad5: "5", Numpad6: "6", Numpad7: "7", Numpad8: "8", Numpad9: "9", NumpadDecimal: ".", NumpadAdd: "+", NumpadSubtract: "-", NumpadMultiply: "*", NumpadDivide: "/" }; const shifted = { Digit0: ")", Digit1: "!", Digit2: "@", Digit3: "#", Digit4: "$", Digit5: "%", Digit6: "^", Digit7: "&", Digit8: "*", Digit9: "(", Backquote: "~", Minus: "_", Equal: "+", BracketLeft: "{", BracketRight: "}", Backslash: "|", Semicolon: ":", Quote: "\"", Comma: "<", Period: ">", Slash: "?" }; return event.shiftKey && Object.prototype.hasOwnProperty.call(shifted, code) ? shifted[code] : (normal[code] || ""); } input.addEventListener("keydown", function (event) { if (!socket || socket.readyState !== WebSocket.OPEN || !authenticated || connectionState !== "connected") { event.preventDefault(); return; } if (event.key === "Enter" && event.repeat) { event.preventDefault(); return; } if (event.ctrlKey && !event.altKey && !event.metaKey && (event.code === "KeyV" || String(event.key).toLowerCase() === "v")) { return; } if (event.ctrlKey && !event.altKey && !event.metaKey && (event.code === "KeyC" || String(event.key).toLowerCase() === "c") && hasLocalSelection()) { return; } if (event.ctrlKey && !event.altKey && !event.metaKey) { const physicalLetter = /^Key[A-Z]$/.test(event.code || "") ? event.code.slice(3) : ""; const controlCode = physicalLetter ? physicalLetter.charCodeAt(0) : (event.key.length === 1 ? event.key.toUpperCase().charCodeAt(0) : 0); if (controlCode >= 64 && controlCode <= 95) { event.preventDefault(); sendTerminalBytes(new Uint8Array([controlCode & 0x1f])); } return; } const specialKeys = { Enter: "\r", Backspace: "\x08", Tab: "\t", Escape: "\x1b", ArrowUp: "\x1b[A", ArrowDown: "\x1b[B", ArrowRight: "\x1b[C", ArrowLeft: "\x1b[D", Home: "\x1b[H", End: "\x1b[F", Delete: "\x1b[3~", PageUp: "\x1b[5~", PageDown: "\x1b[6~" }; if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { event.preventDefault(); const previousCursor = localCursor; const bounds = localSelectionBounds(); let nextCursor = previousCursor; if (!event.shiftKey && bounds.start !== bounds.end) { nextCursor = event.key === "ArrowLeft" || event.key === "Home" ? bounds.start : bounds.end; } else if (event.key === "ArrowLeft") { nextCursor = Math.max(0, previousCursor - 1); } else if (event.key === "ArrowRight") { nextCursor = Math.min(localInput.length, previousCursor + 1); } else if (event.key === "Home") { nextCursor = 0; } else { nextCursor = localInput.length; } if (!event.shiftKey) { localSelectionAnchor = nextCursor; } localCursor = nextCursor; sendLocalCursorMovement(previousCursor, nextCursor); renderLocalInput(); return; } if (Object.prototype.hasOwnProperty.call(specialKeys, event.key)) { event.preventDefault(); if (["Backspace", "Delete"].includes(event.key)) { suppressRemoteEraseUntil = Date.now() + 1500; } let sentBySelectionDelete = false; if (event.key === "Enter") { commitLocalInput(true); } else if (event.key === "Backspace") { sentBySelectionDelete = deleteLocalSelection(); if (!sentBySelectionDelete && localCursor > 0) { const removed = localInput.slice(localCursor - 1, localCursor); localInput = localInput.slice(0, localCursor - 1) + localInput.slice(localCursor); localCursor -= 1; localSelectionAnchor = localCursor; queueExpectedCursorEcho(localInput.slice(localCursor) + " "); if (expectedEcho.endsWith(removed)) { expectedEcho = expectedEcho.slice(0, -1); } } renderLocalInput(); } else if (event.key === "Delete") { sentBySelectionDelete = deleteLocalSelection(); if (!sentBySelectionDelete && localCursor < localInput.length) { localInput = localInput.slice(0, localCursor) + localInput.slice(localCursor + 1); localSelectionAnchor = localCursor; queueExpectedCursorEcho(localInput.slice(localCursor) + " "); } renderLocalInput(); } if (!sentBySelectionDelete) { sendTerminalBytes(specialKeys[event.key]); } return; } const physicalCharacter = !event.ctrlKey && !event.altKey && !event.metaKey ? physicalASCII(event) : ""; if (physicalCharacter) { event.preventDefault(); queueLocalInput(physicalCharacter); sendTerminalBytes(physicalCharacter); return; } if (event.isComposing || event.key === "Process") { event.preventDefault(); return; } if (!event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) { event.preventDefault(); const asciiCharacter = event.key.charCodeAt(0) <= 0x7e ? event.key : ""; if (asciiCharacter) { queueLocalInput(asciiCharacter); sendTerminalBytes(asciiCharacter); } } }); function discardComposedInput() { renderLocalInput(); window.requestAnimationFrame(renderLocalInput); } input.addEventListener("beforeinput", function (event) { const inputType = String(event.inputType || ""); const data = typeof event.data === "string" ? event.data : ""; event.preventDefault(); if (!event.isComposing && inputType === "insertText" && data && !/[^\x09\x0a\x0d\x20-\x7e]/.test(data)) { queueLocalInput(data); sendTerminalBytes(data); return; } discardComposedInput(); }); input.addEventListener("compositionend", discardComposedInput); input.addEventListener("input", discardComposedInput); input.addEventListener("paste", function (event) { event.preventDefault(); const value = (event.clipboardData?.getData("text/plain") || "").replace(/[^\x09\x0a\x0d\x20-\x7e]/g, ""); if (value) { const normalized = value.replace(/\r?\n/g, "\r"); const display = normalized.replace(/\r/g, "\n"); const parts = display.split("\n"); parts.forEach(function (part, index) { if (part) { queueLocalInput(part); } if (index < parts.length - 1) { commitLocalInput(true); } }); sendTerminalBytes(normalized); } }); input.addEventListener("mousedown", function () { pointerSelecting = true; }); function finishPointerSelection() { if (!pointerSelecting) { return; } pointerSelecting = false; const previousCursor = localCursor; if (updateLocalCursorFromSelection()) { sendLocalCursorMovement(previousCursor, localCursor); renderLocalInput(); } } screen.addEventListener("mouseup", finishPointerSelection); window.addEventListener("mouseup", finishPointerSelection); screen.addEventListener("click", function (event) { if (connectionState === "connected") { if (event.target === input || input.contains(event.target)) { return; } const selection = window.getSelection(); if (selection && !selection.isCollapsed && screen.contains(selection.anchorNode) && screen.contains(selection.focusNode)) { return; } localCursor = localInput.length; input.focus(); moveCommandCursor(localCursor); } }); window.addEventListener("beforeunload", function () { if (socket && socket.readyState <= WebSocket.OPEN) { operatorClose = true; socket.close(1000, "page closed"); } if (availabilityTimer) { window.clearInterval(availabilityTimer); } if (readinessTimer) { window.clearInterval(readinessTimer); } }); updateChannelLabels(); renderInitialTerminalMessage(); window.addEventListener("teraclone:languagechange", renderInitialTerminalMessage); resetControls("starting", "시리얼 서비스 준비 중"); refreshServiceReadiness(false); refreshPortAvailability(false); availabilityTimer = window.setInterval(function () { refreshPortAvailability(true); }, availabilityRefreshMilliseconds); readinessTimer = window.setInterval(function () { refreshServiceReadiness(true); }, readinessRefreshMilliseconds); } async function password() { const root = form(); const userPill = document.querySelector(".user-pill"); setValue(root, "username", userPill?.textContent.trim() || ""); markIntegrated(root); root.addEventListener("submit", async function (event) { event.preventDefault(); const nextPassword = value(root, "newPassword"); const confirmation = value(root, "confirmPassword"); try { if (nextPassword.length < 8) { throw new Error("새 비밀번호는 최소 8자 이상이어야 합니다."); } if (nextPassword !== confirmation) { throw new Error("새 비밀번호와 확인 값이 일치하지 않습니다."); } await api("/auth/password", { method: "PATCH", body: { current_password: value(root, "oldPassword"), new_password: nextPassword, confirm_password: confirmation } }); toast("비밀번호를 변경했습니다. 새 비밀번호로 다시 로그인하세요.", "success"); await fetch("/logout", { method: "POST" }); window.setTimeout(function () { window.location.assign("/login"); }, 500); } catch (error) { report(error); } }); } function actionButton(name) { return document.querySelector('[data-ui-action="' + name + '"]'); } function factoryReset() { const root = form(); if (!root) { return; } markIntegrated(root); const button = root.querySelector("button[type='submit']"); const confirmation = field(root, "password"); let busy = false; button.className = "btn danger"; function update() { button.disabled = busy || !confirmation.value; } confirmation.addEventListener("input", update); update(); root.addEventListener("submit", async function (event) { event.preventDefault(); if (busy || !confirmation.value) { return; } if (!window.confirm("사용자·설정·로그·세션·인증서 등 저장 데이터를 모두 삭제하고 자동 재부팅하시겠습니까? 기본 계정 terauser / terauser만 복원됩니다. 이 작업은 되돌릴 수 없습니다.")) { return; } busy = true; update(); try { await api("/system/factory-reset", { method: "POST", body: { password: confirmation.value, confirmed: true } }); confirmation.value = ""; toast("공장 초기화가 완료되어 재부팅합니다. 재부팅 후 terauser / terauser로 로그인하세요.", "success"); window.setTimeout(function () { window.location.assign("/login"); }, 1500); } catch (error) { report(error); } finally { busy = false; update(); } }); } async function actionPage() { const action = currentPath === "/config/save" ? "save-config" : (currentPath === "/ports/restart" ? "restart-ports" : "restart-device"); const button = actionButton(action); if (!button) { return; } button.dataset.integrated = "true"; button.addEventListener("click", async function () { try { if (currentPath === "/config/save") { await api("/config/save", { method: "POST" }); toast("장비 구성을 영구 저장했습니다.", "success"); return; } if (currentPath === "/ports/restart") { const channels = selectedChannels(0); if (!channels.length) { throw new Error("포트 설정 화면에서 재시작할 적용 대상을 먼저 선택하세요."); } if (!window.confirm(channels.join(", ") + " 포트를 재시작하시겠습니까?")) { return; } await api("/ports/restart", { method: "POST", body: { channels: channels } }); toast("선택한 포트 재시작을 요청했습니다.", "success"); return; } if (currentPath === "/device/restart") { if (!window.confirm("장치를 재시작하시겠습니까?")) { return; } await api("/system/reboot", { method: "POST" }); toast("장치 재시작 작업을 요청했습니다.", "success"); return; } } catch (error) { report(error); } }); } function authRoleLabel(role) { if (role === "administrator") { return "관리자"; } if (role === "port_operator") { return "포트 운영자"; } return "조회 사용자"; } function isAssignableAuthRole(role) { return role === "administrator" || role === "port_operator"; } function authError(error) { const original = error instanceof Error ? error.message : String(error || ""); const mappings = [ [/auth user already exists/i, "이미 등록된 사용자 이름입니다."], [/auth user not found/i, "사용자를 찾을 수 없습니다."], [/auth user limit reached/i, "등록 가능한 사용자 수를 초과했습니다."], [/cannot modify last enabled administrator/i, "마지막 활성 관리자 계정은 중지하거나 역할을 변경할 수 없습니다."], [/cannot delete last enabled administrator/i, "마지막 활성 관리자 계정은 삭제할 수 없습니다."], [/password must be at least (\d+) characters/i, "비밀번호는 최소 $1자 이상이어야 합니다."], [/username must contain only lowercase letters, numbers, dot, hyphen, or underscore/i, "사용자 이름은 영문 소문자, 숫자, 점, 하이픈, 밑줄만 사용할 수 있습니다."], [/username must be at most (\d+) characters/i, "사용자 이름은 최대 $1자까지 입력할 수 있습니다."], [/auth group still has members/i, "사용자가 포함된 그룹은 삭제할 수 없습니다."], [/auth group already exists/i, "이미 등록된 그룹 ID입니다."], [/auth group not found/i, "그룹을 찾을 수 없습니다."] ]; for (const mapping of mappings) { if (mapping[0].test(original)) { return new Error(original.replace(mapping[0], mapping[1])); } } return error instanceof Error ? error : new Error(original || "요청 처리 중 오류가 발생했습니다."); } function closeManagedModal(modal) { modal.hidden = true; document.body.classList.remove("modal-open"); } function portPermissionSummary(permissions) { const items = Array.isArray(permissions) ? permissions : []; if (!items.length) { return "역할 기본값"; } const writes = items.filter(function (item) { return item.access === "write"; }).length; const reads = items.filter(function (item) { return item.access === "read"; }).length; if (writes === 48) { return "전체 쓰기"; } if (reads === 48) { return "전체 읽기"; } return [writes ? writes + "개 쓰기" : "", reads ? reads + "개 읽기" : ""].filter(Boolean).join(" / ") || "권한 없음"; } async function users() { const panel = panelByTitle("사용자 계정"); const body = panel?.querySelector("tbody"); const modal = document.querySelector('[data-modal-root="user-create"]'); const userForm = modal?.querySelector("[data-user-form]"); if (!body || !modal || !userForm) { throw new Error("사용자 관리 화면을 초기화할 수 없습니다."); } markIntegrated(userForm); const title = modal.querySelector(".modal-header h2"); const message = modal.querySelector(".modal-message"); const usernameInput = field(userForm, "username"); const passwordInput = field(userForm, "password"); const groupInput = field(userForm, "group"); const enabledInput = field(userForm, "enabled"); usernameInput.maxLength = 64; // Chromium compiles the pattern attribute with the UnicodeSets `v` flag. // Older embedded pages escaped the underscore instead of the hyphen and // produced an invalid regular expression. Validation is performed below // with a normal JavaScript RegExp, so do not install a pattern attribute. usernameInput.removeAttribute("pattern"); passwordInput.minLength = 8; const groups = await api("/auth/groups?limit=100"); const groupByID = new Map(groups.map(function (group) { return [group.group_id, group]; })); const assignableGroups = groups.filter(function (group) { return isAssignableAuthRole(group.role); }); groupInput.replaceChildren(); assignableGroups.forEach(function (group) { const option = document.createElement("option"); option.value = group.group_id; option.textContent = group.name + " (" + authRoleLabel(group.role) + ")"; groupInput.appendChild(option); }); function resetUserForm() { userForm.dataset.mode = "create"; setValue(userForm, "id", ""); setValue(userForm, "username", ""); setValue(userForm, "password", ""); setValue(userForm, "group", assignableGroups.some(function (group) { return group.group_id === "administrators"; }) ? "administrators" : (assignableGroups[0]?.group_id || "")); setValue(userForm, "enabled", "true"); usernameInput.disabled = false; usernameInput.required = true; passwordInput.required = true; groupInput.disabled = false; enabledInput.disabled = false; title.textContent = "사용자 추가"; message.textContent = "장비의 Local 인증 저장소에 사용자를 추가합니다. 새 계정은 선택한 그룹의 역할과 포트 권한을 적용받으며 첫 로그인 시 비밀번호를 변경해야 합니다."; } function openUserModal(user, mode) { userForm.dataset.mode = mode; setValue(userForm, "id", user.username); setValue(userForm, "username", user.username); setValue(userForm, "password", ""); setValue(userForm, "group", (user.groups || [])[0] || ""); setValue(userForm, "enabled", String(user.enabled)); usernameInput.disabled = true; passwordInput.required = mode === "password"; groupInput.disabled = mode === "password"; enabledInput.disabled = mode === "password"; title.textContent = mode === "password" ? "비밀번호 재설정" : "사용자 수정"; message.textContent = mode === "password" ? user.username + " 사용자의 새 비밀번호를 설정합니다. 저장하면 기존 로그인 세션이 해제됩니다." : "그룹, 역할과 계정 상태를 수정합니다. 비밀번호를 비워두면 기존 값이 유지됩니다."; modal.hidden = false; document.body.classList.add("modal-open"); (mode === "password" ? passwordInput : groupInput).focus(); } document.querySelectorAll('[data-open-modal="user-create"]').forEach(function (button) { button.addEventListener("click", resetUserForm); }); function appendTextCell(row, text) { const cell = document.createElement("td"); cell.textContent = text === undefined || text === null || text === "" ? "-" : String(text); row.appendChild(cell); return cell; } function renderUsers(items) { items = items.filter(function (user) { return user.actor !== "mock-port-operator" && user.actor !== "mock-viewer"; }); body.replaceChildren(); if (!items.length) { const row = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = 7; cell.className = "empty-state"; cell.textContent = "등록된 사용자가 없습니다."; row.appendChild(cell); body.appendChild(row); return; } items.forEach(function (user) { const row = document.createElement("tr"); const statusCell = document.createElement("td"); const status = document.createElement("span"); status.className = "account-status-badge " + (user.enabled ? "enabled" : "disabled"); status.innerHTML = '' + (user.enabled ? "사용" : "중지") + ""; statusCell.appendChild(status); row.appendChild(statusCell); appendTextCell(row, user.username); appendTextCell(row, authRoleLabel(user.role)); appendTextCell(row, (user.groups || []).map(function (id) { return groupByID.get(id)?.name || id; }).join(", ")); const passwordCell = document.createElement("td"); const passwordState = document.createElement("span"); passwordState.className = "password-state-badge " + (user.must_change_password ? "required" : "normal"); passwordState.textContent = user.must_change_password ? "변경 필요" : "정상"; passwordCell.appendChild(passwordState); row.appendChild(passwordCell); appendTextCell(row, portPermissionSummary(user.port_permissions)); const actions = document.createElement("td"); actions.className = "row-actions user-row-actions"; const edit = document.createElement("button"); edit.type = "button"; edit.className = "link-action primary"; edit.textContent = "수정"; edit.addEventListener("click", function () { openUserModal(user, "edit"); }); const password = document.createElement("button"); password.type = "button"; password.className = "link-action secondary"; password.textContent = "비밀번호"; password.addEventListener("click", function () { openUserModal(user, "password"); }); const remove = document.createElement("button"); remove.type = "button"; remove.className = "link-action danger"; remove.textContent = "삭제"; remove.addEventListener("click", async function () { if (!window.confirm(user.username + " 사용자를 삭제하시겠습니까? 이 사용자의 기존 로그인 세션도 해제됩니다.")) { return; } try { await api("/auth/users/" + encodeURIComponent(user.username), { method: "DELETE" }); toast(user.username + " 사용자를 삭제했습니다.", "success"); await refreshUsers(); } catch (error) { report(authError(error)); } }); actions.append(edit, password, remove); row.appendChild(actions); body.appendChild(row); }); } async function refreshUsers() { renderUsers(await api("/auth/users?limit=100")); } await refreshUsers(); resetUserForm(); userForm.addEventListener("submit", async function (event) { event.preventDefault(); const mode = userForm.dataset.mode || "create"; const original = value(userForm, "id"); const username = usernameInput.value.trim().toLowerCase(); const passwordValue = passwordInput.value; const groupID = groupInput.value; const selectedGroup = groupByID.get(groupID); try { if (!original && !/^[a-z0-9._-]{1,64}$/.test(username)) { throw new Error("사용자 이름은 영문 소문자, 숫자, 점, 하이픈, 밑줄만 사용할 수 있습니다."); } if ((mode === "create" || mode === "password" || passwordValue) && passwordValue.length < 8) { throw new Error("비밀번호는 최소 8자 이상이어야 합니다."); } if (mode !== "password" && !isAssignableAuthRole(selectedGroup?.role)) { throw new Error("관리자 또는 포트 운영자 그룹을 선택하세요."); } if (mode === "create") { await api("/auth/users", { method: "POST", body: { username: username, password: passwordValue, role: selectedGroup.role, groups: [groupID] }}); toast(username + " 사용자를 추가했습니다.", "success"); } else if (mode === "password") { await api("/auth/users/" + encodeURIComponent(original) + "/password", { method: "PATCH", body: { password: passwordValue } }); toast(original + " 사용자의 비밀번호를 재설정했습니다.", "success"); } else { await api("/auth/users/" + encodeURIComponent(original), { method: "PATCH", body: { enabled: enabledInput.value !== "false", role: selectedGroup.role, groups: [groupID] }}); if (passwordValue) { await api("/auth/users/" + encodeURIComponent(original) + "/password", { method: "PATCH", body: { password: passwordValue } }); } toast(original + " 사용자 정보를 수정했습니다.", "success"); } closeManagedModal(modal); await refreshUsers(); } catch (error) { report(authError(error)); } }); } const permissionMap = { login: [], network: ["network.read", "network.write"], ports: ["port.read", "port.write", "port.restart"], tools: ["diagnostics.run"], admin: ["auth.read", "auth.write", "config.read", "config.write"], common: ["time.sync"], status: ["system.read", "log.read"], password: [], operate: ["direct.view", "direct.write"] }; async function groups() { const body = panelByTitle("그룹")?.querySelector("tbody"); const modal = document.querySelector('[data-modal-root="group-create"]'); const groupForm = modal?.querySelector("[data-group-form]"); if (!body || !modal || !groupForm) { throw new Error("그룹 관리 화면을 초기화할 수 없습니다."); } markIntegrated(groupForm); const originalIDInput = field(groupForm, "id"); const groupIDInput = field(groupForm, "group_id"); const nameInput = field(groupForm, "name"); const roleInput = field(groupForm, "description"); const title = modal.querySelector(".modal-header h2"); const message = modal.querySelector(".modal-message"); groupIDInput.maxLength = 64; groupIDInput.removeAttribute("pattern"); function syncPermissionVisuals() { groupForm.querySelectorAll('[name="permissions"]').forEach(function (input) { input.closest(".permission-chip")?.classList.toggle("selected", input.checked); }); } function resetGroupForm() { setValue(groupForm, "id", ""); setValue(groupForm, "group_id", ""); setValue(groupForm, "name", ""); setValue(groupForm, "description", "port_operator"); groupIDInput.disabled = false; groupForm.querySelectorAll('[name="permissions"]').forEach(function (input) { input.checked = false; }); title.textContent = "그룹 추가"; message.textContent = "사용자에게 적용할 Local 인증 그룹과 권한을 추가합니다."; syncPermissionVisuals(); } document.querySelectorAll('[data-open-modal="group-create"]').forEach(function (button) { button.addEventListener("click", resetGroupForm); }); function renderGroups(items) { body.replaceChildren(); // Keep populated legacy groups visible for deliberate migration, without // offering an unused viewer group as a new account role. items.filter(function (group) { return isAssignableAuthRole(group.role) || (group.members || []).length > 0; }).forEach(function (group) { const row = document.createElement("tr"); [group.group_id, group.name, authRoleLabel(group.role), (group.permissions || []).length + "개", (group.members || []).length + "명"].forEach(function (text) { const cell = document.createElement("td"); cell.textContent = text; row.appendChild(cell); }); const actions = document.createElement("td"); actions.className = "row-actions user-row-actions"; if (group.system) { const fixed = document.createElement("span"); fixed.className = "system-group-label"; fixed.textContent = "기본 그룹"; actions.appendChild(fixed); } else { const edit = document.createElement("button"); edit.type = "button"; edit.className = "link-action primary"; edit.textContent = "수정"; edit.addEventListener("click", function () { setValue(groupForm, "id", group.group_id); setValue(groupForm, "group_id", group.group_id); setValue(groupForm, "name", group.name); setValue(groupForm, "description", group.role); groupIDInput.disabled = true; groupForm.querySelectorAll('[name="permissions"]').forEach(function (input) { input.checked = (permissionMap[input.value] || []).some(function (permission) { return (group.permissions || []).includes(permission); }); }); title.textContent = "그룹 수정"; message.textContent = group.name + " 그룹의 역할과 권한을 수정합니다."; syncPermissionVisuals(); modal.hidden = false; document.body.classList.add("modal-open"); }); const remove = document.createElement("button"); remove.type = "button"; remove.className = "link-action danger"; remove.textContent = "삭제"; remove.addEventListener("click", async function () { if (!window.confirm(group.name + " 그룹을 삭제하시겠습니까?")) { return; } try { await api("/auth/groups/" + encodeURIComponent(group.group_id), { method: "DELETE" }); toast(group.name + " 그룹을 삭제했습니다.", "success"); await refreshGroups(); } catch (error) { report(authError(error)); } }); actions.append(edit, remove); } row.appendChild(actions); body.appendChild(row); }); } async function refreshGroups() { renderGroups(await api("/auth/groups?limit=100")); } await refreshGroups(); resetGroupForm(); groupForm.addEventListener("submit", async function (event) { event.preventDefault(); const originalID = originalIDInput.value.trim(); const groupID = groupIDInput.value.trim().toLowerCase(); const name = nameInput.value.trim(); const role = roleInput.value; const selected = Array.from(groupForm.querySelectorAll('[name="permissions"]:checked')).flatMap(function (input) { return permissionMap[input.value] || []; }); const permissions = Array.from(new Set(selected)); try { if (!/^[a-z0-9_-]{1,64}$/.test(groupID)) { throw new Error("그룹 ID는 영문 소문자, 숫자, 하이픈, 밑줄만 사용할 수 있습니다."); } if (!name) { throw new Error("그룹 이름을 입력하세요."); } if (!isAssignableAuthRole(role)) { throw new Error("관리자 또는 포트 운영자 역할을 선택하세요."); } if (originalID) { await api("/auth/groups/" + encodeURIComponent(originalID), { method: "PATCH", body: { name: name, role: role, permissions: permissions } }); toast(name + " 그룹을 수정했습니다.", "success"); } else { await api("/auth/groups", { method: "POST", body: { group_id: groupID, name: name, role: role, permissions: permissions } }); toast(name + " 그룹을 추가했습니다.", "success"); } closeManagedModal(modal); await refreshGroups(); } catch (error) { report(authError(error)); } }); } async function initialize() { if (currentPath === "/") { return dashboard(); } if (/^\/network\/interface-[12]$/.test(currentPath)) { return networkInterface(); } if (currentPath === "/network/host") { return hostSettings(); } if (currentPath === "/network/advanced") { return advancedSettings(); } if (currentPath === "/network/ddns") { return ddnsSettings(); } const portMatch = currentPath.match(/^\/ports\/(\d+)\/(parameters|operation-1|operation-2|data-log|advanced)$/); if (portMatch) { return portSettings(portMatch); } if (currentPath === "/settings/datetime") { return timeSettings(); } if (currentPath === "/settings/alert/event") { return alarmSystemEvents(); } if (currentPath === "/settings/alert/serial-event") { return alarmSerialEvents(); } if (currentPath === "/settings/alert/mail") { return alarmEmailSettings(); } if (currentPath === "/settings/alert/snmp-trap-filter") { return alarmSNMPTrapSettings(); } if (currentPath === "/settings/system-log/remote") { return remoteSyslog(); } if (currentPath === "/settings/system-log/events" || currentPath === "/settings/system-log/status-event") { return logEventSettings(); } if (currentPath.startsWith("/tools/nettest/")) { return diagnostics(); } if (currentPath === "/tools/alias") { return aliases(); } if (currentPath === "/logs/system" || currentPath === "/logs/ports" || currentPath === "/logs/links") { return logs(); } if (currentPath === "/port-status") { return portStatus(); } if (currentPath === "/port-debug" || /^\/port-debug\/(?:[1-9]|[1-3][0-9]|4[0-8])$/.test(currentPath)) { return serialTerminal(); } if (currentPath === "/password") { return password(); } if (["/config/save", "/ports/restart", "/device/restart"].includes(currentPath)) { return actionPage(); } if (currentPath === "/device/factory-reset") { return factoryReset(); } if (currentPath === "/admin/users") { return users(); } if (currentPath === "/admin/groups") { return groups(); } if (currentPath === "/admin/auth/radius") { return externalAuthSettings("radius"); } if (currentPath === "/admin/auth/tacacs") { return externalAuthSettings("tacacs"); } if (currentPath === "/admin/address-filter") { return addressFilterPolicy(); } if (/^\/admin\/address-filter\/(ipv4|mac)$/.test(currentPath)) { return addressFilterList(); } if (/^\/admin\/address-filter\/(ipv4|mac)\/new$/.test(currentPath)) { return addressFilterAdd(); } if (currentPath === "/admin/snmp") { return snmpAgentSettings(); } if (currentPath === "/admin/ssl") { return tlsCertificateSettings(); } if (currentPath === "/admin/ssl/new") { return tlsCertificateCreate(); } if (currentPath === "/admin/ssl/import") { return tlsCertificateImport(); } if (currentPath === "/admin/ssl/import-csr") { return tlsCertificateCSR(); } if (currentPath === "/admin/backup/export") { return encryptedConfigExport(); } if (currentPath === "/admin/backup/import" || currentPath === "/admin/backup/restore") { return encryptedConfigRestore(); } if (currentPath === "/admin/firmware") { return firmwareUpgrade(); } } async function initializeCurrentPage() { await initialize(); if (portSettingsPath) { document.body.dataset.portLoadState = "ready"; } startPortSynchronization(); startDashboardSynchronization(); if (portSettingsPath) { synchronizePorts(); } } document.querySelector("[data-global-reboot]")?.addEventListener("click", async function () { if (!window.confirm("전체 메뉴의 변경 내용을 저장한 뒤 재시작하시겠습니까?")) { return; } try { await api("/config/save-and-restart", { method: "POST" }); toast("전체 설정 저장 및 재시작을 요청했습니다.", "success"); } catch (error) { report(error); } }); initializeCurrentPage().catch(function (error) { if (portSettingsPath) { document.body.dataset.portLoadState = "error"; } report(error); }); })();