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