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