You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

4852 lines
208 KiB

(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 portCounterSnapshots = new Map();
const portVisualStates = new Map();
const portSyncIntervalMs = 500;
const dashboardSyncIntervalMs = 5000;
const portRuntimeStatusPath = "/ports/runtime-status";
const recentRXWindowMs = 10000;
const crProbeDownMisses = 2;
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) {
const runtime = port.runtime || {};
const channel = String(port.channel || "");
const rxBytes = Number(runtime.rx_bytes) || 0;
const txBytes = Number(runtime.tx_bytes) || 0;
const previous = portCounterSnapshots.get(channel);
// Local TX (including the CR probe itself) is never proof of a remote endpoint.
const rxAgeMillis = Number(runtime.rx_age_ms);
const receivedTraffic = Boolean(previous && rxBytes > previous.rxBytes) ||
(Number.isFinite(rxAgeMillis) && rxAgeMillis >= 0 && rxAgeMillis <= recentRXWindowMs);
const crProbeState = String(runtime.cr_probe_state || "").toLowerCase();
const status = String(port.status || "").toLowerCase();
const connectedUsers = Number(port.connected_users) || 0;
const crProbeUp = runtime.cr_probe_up === true;
const crProbeEverUp = runtime.cr_probe_ever_up === true;
const crProbeMisses = Number(runtime.cr_probe_consecutive_misses) || 0;
const crProbeDown = crProbeEverUp && !crProbeUp &&
crProbeState === "no_response" && crProbeMisses >= crProbeDownMisses;
const sessionReported = runtime.session_active === true ||
status === "session" || status === "active" || connectedUsers > 0;
const sessionActive = sessionReported && (crProbeEverUp || crProbeUp || status === "session");
const sessionCount = sessionActive ? Math.max(connectedUsers, 1) : 0;
portCounterSnapshots.set(channel, { rxBytes: rxBytes, txBytes: txBytes });
if (sessionActive) {
return { active: true, tone: "session", label: "Session_" + sessionCount, sessionCount: sessionCount, summary: "포트 세션 " + sessionCount + "명 연결 중" };
}
// A confirmed probe failure must override stale RX history. Otherwise a
// disconnected port can remain visually UP until the RX history expires.
if (crProbeDown) {
return { active: false, tone: "error", label: "DOWN", summary: "이전에 확인된 장비의 통신 응답이 끊어짐" };
}
if (receivedTraffic || crProbeUp) {
return { active: true, tone: "connected", label: "UP", summary: "상대 장비 데이터 수신" };
}
// DOWN is only reachable after this port has previously been confirmed UP.
// Transient probe states must not introduce extra user-facing states.
if (crProbeEverUp) {
return { active: true, tone: "connected", label: "UP", summary: runtime.cr_probe_message || "마지막으로 확인된 연결 상태 유지" };
}
return { active: false, tone: "waiting", label: "Unknown", summary: "RX 또는 CR 응답 대기" };
}
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;
}
});
}
function renderPortStatusRows(ports) {
const rows = ports.map(function (port) {
const runtime = port.runtime || {};
return [port.channel, portView(port).label, port.mode, Number(port.connected_users) || 0, formatBytes(runtime.rx_bytes) + " / " + formatBytes(runtime.tx_bytes)];
});
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 === 5;
});
if (!reusable) {
renderRows("포트 상태 요약", rows, 5);
} 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) {
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, "ipv6Enabled", ddns.ipv6_enabled || false);
setValue(root, "username", ddns.username || "");
setValue(root, "password", ddns.password || "");
setValue(root, "ipv4Domain", ddns.ipv4_domain || "");
setValue(root, "ipv6Domain", ddns.ipv6_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: checked(root, "ipv6Enabled"),
username: value(root, "username"),
password: value(root, "password"),
ipv4_domain: value(root, "ipv4Domain"),
ipv6_domain: value(root, "ipv6Domain"),
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 });
toast("백업 구성을 장비에 복원하고 영구 저장했습니다.", "success");
} 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, {
alias: value(root, "alias"), 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);
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='<legend>원격 전송할 로그</legend>';
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));
setValue(root, "keywordValue", ruleByID.get(ids.keyword)?.trigger || "(not configured)");
fillAlarmDelivery(root, selected);
}
fillChannel();
field(root, "channel")?.addEventListener("change", fillChannel);
bindForm(root, function () {
const ids = IDs();
const delivery = alarmDelivery(root);
return Promise.all(Object.entries(ids).map(function (entry) {
return api("/alarms/rules/" + entry[1], { method: "PATCH", body: {
enabled: checked(root, entry[0]), delivery: delivery
}});
}));
});
}
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)![HNPX](?:\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='<h3>로그 저장 공간 · 보존 설정</h3><p data-storage-status role="status">저장 공간을 확인하고 있습니다.</p><form data-retention-form><div class="form-row"><label>시스템 보존일<input name="system_days" type="number" min="1" max="365" required></label><label>감사 보존일<input name="audit_days" type="number" min="1" max="365" required></label><label>포트 보존일<input name="port_days" type="number" min="1" max="365" required></label><label>용량 경고 (%)<input name="protected_usage_warning_pct" type="number" min="50" max="95" required></label></div><p>보존기간은 1~365일입니다. 기간을 줄이면 만료 로그가 삭제될 수 있습니다. 용량 부족 시 설정 기간보다 먼저 회수될 수 있습니다. 공장초기화 시 전체 로그를 삭제합니다.</p><button class="btn primary" type="submit">보존 설정 저장</button><button class="btn secondary" type="button" data-storage-refresh>상태 새로고침</button></form>';
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 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 = 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(" · ") || "-";
}
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), 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), 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" : "/logs/port/clear", { method: "POST", body: body }); },
onBusy: function () { clearInFlight = true; ++resultsGeneration; },
onSuccess: function (channel, result) {
toast(systemLog ? "시스템 이벤트 " + result.deleted + "건을 삭제했습니다. 감사 기록은 보존됩니다." : channel + " 포트 로그 " + result.deleted + "건을 삭제했습니다. 이후 발생하는 로그는 계속 저장됩니다.", "success");
renderLogRows([]);
},
onError: report,
onSettled: async function () { clearInFlight = false; 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);
}
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] HEX=" + hex + " ASCII=" + ascii);
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 () {});
}
resetControls(wasOperatorClose ? "disconnected" : (wasAuthenticated ? "disconnected" : "error"), wasOperatorClose ? "연결 종료" : "연결 끊김");
appendSystem(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, "confirmation");
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" });
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 = '<span class="account-status-dot" aria-hidden="true"></span><span>' + (user.enabled ? "사용" : "중지") + "</span>";
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);
});
})();