/* --- static/app.js --- */ // ============================================================================ // 📦 模組匯入區 (Imports) // ============================================================================ // 1. 終端機模組 (Terminal) import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, downloadTerminal, isConnected } from './terminal.js'; // 2. API 網路請求模組 (API) import { apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetTreeFilters, apiSaveTreeFilters, apiSyncLeafOptionsStream, apiGetCmtsVersion, apiGetScanStatus, apiGetLockStatus } from './api.js'; // 3. 共用工具模組 (Utils) import { getGlobalConnectionInfo } from './utils.js'; // 4. 樹狀圖 UI 模組 (Tree UI) import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-ui.js'; // 5. 編輯模式與鎖定模組 (Edit Mode) import { releaseAllLocks, startEditFolder, startEditLeaf, cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID } from './edit-mode.js'; // 6. MAC Domain 配置模組 (MAC Domain) import { hasMacDomainData, resetMacDomainData, loadMacDomainList, fetchMacDomainConfig, populateFormFromData, revertFormChanges, toggleGroupVisibility, loadDsGroupData, loadUsGroupData, generateMacDomainCLI, executeBondingConfig } from './mac-domain.js'; // ============================================================================ // 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽 // ============================================================================ let idleTimer; const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) let globalSSE = null; // 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道 function initGlobalSSE(mode = 'running') { const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE if (globalSSE) globalSSE.close(); globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); globalSSE.onmessage = (event) => { const data = JSON.parse(event.data); const statusEl = document.getElementById('scan-status'); // 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定) if (data.event === 'scan_start' || data.event === 'progress') { setAdminButtonsState(true, "⏳ 系統更新中..."); } if (data.event === 'scan_start') { if (statusEl) { statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`; statusEl.style.color = "#f39c12"; } } else if (data.event === 'progress') { if (statusEl) { statusEl.innerHTML = `⏳ 背景掃描進度:${data.current} / ${data.total} (正在處理: ${data.current_path})`; statusEl.style.color = "#f39c12"; } } else if (data.event === 'done') { if (statusEl) { statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`; statusEl.style.color = "#27ae60"; setTimeout(() => statusEl.textContent = "", 5000); } // 掃描完成才解鎖 setAdminButtonsState(false); localStorage.removeItem('scanLockUntil'); } else if (data.event === 'error') { if (statusEl) { statusEl.innerHTML = `❌ 錯誤: ${data.message}`; statusEl.style.color = "#e74c3c"; } // 發生錯誤也要解鎖 setAdminButtonsState(false); localStorage.removeItem('scanLockUntil'); } }; } // 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線 window.addEventListener('beforeunload', () => { if (globalSSE) { globalSSE.close(); globalSSE = null; } }); // 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」 let activeLockState = new Set(); let lockPollingTimer = null; function startLockStatusPolling() { if (lockPollingTimer) clearInterval(lockPollingTimer); lockPollingTimer = setInterval(async () => { const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; try { const result = await apiGetLockStatus(connInfo.host); if (result.status === 'success') { const currentLocks = result.data; const newLockState = new Set(); // 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點 for (const [path, lockInfo] of Object.entries(currentLocks)) { if (lockInfo.user_id !== SESSION_USER_ID) { newLockState.add(path); const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); targetBtns.forEach(btn => { if (btn.innerText !== "🔒") { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; btn.innerText = "🔒"; } }); } } // 🔓 任務 2:處理「解除鎖定」的節點 activeLockState.forEach(oldPath => { if (!newLockState.has(oldPath)) { const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); targetBtns.forEach(btn => { if (btn.innerText === "🔒") { btn.style.pointerEvents = 'auto'; btn.style.opacity = '0.3'; btn.title = "鎖定並編輯此項目"; btn.innerText = "✏️"; } }); } }); // 💾 任務 3:更新特務的記憶 activeLockState = newLockState; } } catch (error) { console.warn("獲取鎖定狀態失敗:", error); } }, 15000); } // 頁面載入與縮放初始化 window.onload = () => { initTerminal(); resetIdleTimer(); // 頁面載入時啟動閒置計時器 initGlobalSSE(); // 啟動全域廣播監聽 startLockStatusPolling(); // 啟動鎖定狀態輪詢 // 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步 const configTaskSelect = document.getElementById('configTask'); if (configTaskSelect) { configTaskSelect.dispatchEvent(new Event('change')); } }; window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); }; // 閒置計時器重置邏輯 (超時將自動釋放所有編輯鎖定) function resetIdleTimer() { clearTimeout(idleTimer); idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT); } // 🌟 效能急救:節流閥 (Throttle) 機制 let throttleTimer = false; function throttledReset() { if (!throttleTimer) { resetIdleTimer(); throttleTimer = true; setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設 } } window.addEventListener('mousemove', throttledReset); window.addEventListener('keydown', throttledReset); window.addEventListener('click', throttledReset); // ============================================================================ // 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State) // ============================================================================ // 切換主頁籤 (終端機 / 設備查詢 / 設備配置 / 系統設定) function switchTab(tabId, element) { document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); const targetTab = document.getElementById(tabId); if (targetTab) targetTab.classList.add('active'); if (element) element.classList.add('active'); if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50); } // 切換「設備查詢」內的子任務表單 function switchQueryTask() { document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active')); const selectedTaskId = document.getElementById('queryTask').value; const targetForm = document.getElementById(selectedTaskId); if (targetForm) targetForm.classList.add('active'); } // 🌟 新增一個變數來記錄上一次的樹狀圖模式 let lastTreeMode = null; // 切換「設備配置」內的子任務表單 function switchConfigTask() { document.querySelectorAll('#config-tab .task-form').forEach(form => { form.classList.remove('active'); form.style.display = 'none'; }); const selectedTaskId = document.getElementById('configTask').value; let targetFormId = selectedTaskId; if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') { targetFormId = 'form-tree-config'; } const targetForm = document.getElementById(targetFormId); if (targetForm) { targetForm.classList.add('active'); targetForm.style.display = 'block'; const titleSpan = document.getElementById('tree-form-title'); const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running'; if (titleSpan) { titleSpan.textContent = currentMode === 'running' ? '🌳 設備配置樹狀圖 (running-config)' : '🌳 完整設備配置樹狀圖 (full configuration)'; } if (targetFormId === 'form-tree-config') { // 🌟 魔法所在:純視覺切換,不銷毀資料! const runningContainer = document.getElementById('tree-container-running'); const fullContainer = document.getElementById('tree-container-full'); if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none'; if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none'; if (lastTreeMode !== currentMode) { const statusEl = document.getElementById('scan-status'); if (statusEl) statusEl.textContent = ''; // 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏! const loadingMsg = document.getElementById('loading-message'); if (loadingMsg) loadingMsg.style.display = 'none'; lastTreeMode = currentMode; } } initGlobalSSE(currentMode); } } // 啟動配置任務 (樹狀圖載入 或 MAC Domain 精靈) function startConfigTask() { if (!isConnected) { alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!"); return; } switchConfigTask(); const selectedTaskId = document.getElementById('configTask').value; if (selectedTaskId === 'form-bonding-config') { if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return; resetAllManagerData(); loadMacDomainList(); } // 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType else if (selectedTaskId === 'form-running-config') { fetchFullConfig('running'); } else if (selectedTaskId === 'form-full-config') { fetchFullConfig('full'); // 🌟 補上 full 參數 } } // 切換查詢輸入框的啟用狀態 (全域指令不需輸入目標) function toggleQueryInputs(mode) { const type = document.getElementById('queryType' + mode).value; const targetInput = document.getElementById('queryTarget' + mode); const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum']; if (globalTypes.includes(type)) { targetInput.disabled = true; targetInput.style.backgroundColor = '#e8ecef'; targetInput.placeholder = "此查詢為全域指令,不需輸入目標"; targetInput.value = ""; } else { targetInput.disabled = false; targetInput.style.backgroundColor = '#f8f9fa'; targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)"; } } // 重置所有管理介面的資料與 UI 狀態 function resetAllManagerData() { resetMacDomainData(); const configArea = document.getElementById('macDomainConfigArea'); if (configArea) configArea.style.display = 'none'; const previewEl = document.getElementById('cliPreviewContainer'); if (previewEl) { previewEl.textContent = ''; previewEl.style.display = 'none'; } const deployBtn = document.getElementById('btnDeployConfig'); if (deployBtn) { deployBtn.style.display = 'none'; deployBtn.disabled = true; } const mdSelect = document.getElementById('cfgMacDomain'); if (mdSelect) mdSelect.innerHTML = ''; const fetchStatus = document.getElementById('fetchStatus'); if (fetchStatus) { fetchStatus.textContent = "請先讀取設備狀態..."; fetchStatus.style.color = "#7f8c8d"; } document.getElementById('queryTargetCm').value = ''; document.getElementById('queryTargetRpd').value = ''; // 🌟 新增:切換設備連線後,自動重整備份歷史紀錄 if (typeof loadBackupHistory === 'function') { loadBackupHistory(); } } // ============================================================================ // 🌟 3. 設備查詢與全域配置載入 (Query & Full Config) // ============================================================================ // 執行綜合查詢 (CM / RPD) async function executeQuery(mode) { if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕!"); const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; const queryType = document.getElementById('queryType' + mode).value; const target = document.getElementById('queryTarget' + mode).value.trim(); const isDebug = document.getElementById('debugQuery' + mode).checked; openModal(connInfo.host); const outputEl = document.getElementById('modalOutput'); outputEl.innerHTML = `正在向 ${connInfo.host} 查詢中,請稍候...`; try { const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass); if (result.status === 'success') { outputEl.style.color = "#e0e0e0"; outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data; } else { outputEl.style.color = "#e74c3c"; outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`; } } catch (error) { outputEl.style.color = "#e74c3c"; outputEl.textContent = `連線失敗: ${error}`; } } // 載入完整設備配置並生成樹狀圖,預設為 running async function fetchFullConfig(configType = 'running') { const loadingMsg = document.getElementById('loading-message'); // 🌟 動態抓取當前模式的專屬容器 const treeContainer = document.getElementById(`tree-container-${configType}`); const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; if (loadingMsg) loadingMsg.style.display = 'inline-block'; if (treeContainer) treeContainer.innerHTML = ''; let needAutoScan = false; // 🌟 標記是否需要自動掃描 try { // --- 1. 版本守門員 --- try { const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); if (versionRes.status === 'success') { const currentVersion = versionRes.version; // 🌟 修正 1:讀取選項快取時,必須加上 config_type 參數 const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`); const optData = await optRes.json(); const cachedVersion = optData.__metadata__?.cmts_version; if (cachedVersion && cachedVersion !== currentVersion) { const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`); if (doClear) { const allKeys = Object.keys(optData); // 🌟 修正 2:清空快取時,必須傳遞 configType 告訴後端清哪一個 await apiClearCache(connInfo.host, allKeys, configType); alert("✅ 舊版快取已清空!系統載入配置後,將自動為您重新掃描選項。"); needAutoScan = true; // 🌟 觸發自動掃描旗標 } } } } catch (vErr) { console.warn("版本檢查失敗,略過", vErr); } // --- 2. 載入完整配置 --- const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass, false, configType); // 🌟 新增:將原始資料存入全域變數,供全域掃描使用 window.currentTreeData = result.data; // 🌟 新增防呆攔截:檢查使用者是否在等待期間切換了下拉選單 const currentSelectedTask = document.getElementById('configTask').value; const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; if (currentSelectedTask !== expectedTask) { console.warn(`[防呆攔截] 正在載入 ${configType},但使用者已切換至 ${currentSelectedTask},捨棄本次結果以防畫面錯亂。`); return; // 🌟 發現模式不符,直接中斷,不要把舊樹狀圖貼上去! } if (result.status === 'success') { if (treeContainer) { // 🌟 傳入 configType,讓 ID 加上前綴 treeContainer.innerHTML = buildTree(result.data, '', false, configType); // 🌟 修正:樹狀圖一畫完,立刻隱藏載入訊息,讓使用者感覺「秒開」 if (loadingMsg) loadingMsg.style.display = 'none'; // 🌟 3. 檢查後端是否已經有人在掃描 const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType); if (isSomeoneScanning) { alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。"); lockScanButton(60000); } else { enableGlobalScanButton(); // 如果剛清空快取,自動啟動掃描 if (needAutoScan) { setTimeout(() => { // 🌟 修正 3:全域掃描也必須知道現在要掃哪一棵樹 scanGlobalMissingOptions(configType); }, 500); } } } } else { if (treeContainer) treeContainer.innerHTML = `
❌ 錯誤: ${result.message}
`; } } catch (error) { if (treeContainer) treeContainer.innerHTML = `
❌ 連線或解析失敗: ${error.message}
`; } finally { if (loadingMsg) loadingMsg.style.display = 'none'; } } // ============================================================================ // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning) // ============================================================================ // 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數 async function enhanceInputWithOptions(path, elementId, mode = 'running') { // 🌟 1. 取得當前連線的 IP const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; try { // 🌟 2. GET 請求:網址加上 host 參數 const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); const optData = await optRes.json(); const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); const leafCache = optData[cacheKey]; if (leafCache && leafCache.options && leafCache.options.length > 0) { const container = document.getElementById(`container-${elementId}`); const input = container.querySelector('.edit-input'); if (input) { const listId = `dl-${elementId}`; input.setAttribute('list', listId); let dataList = document.getElementById(listId); if (!dataList) { dataList = document.createElement('datalist'); dataList.id = listId; container.appendChild(dataList); } dataList.innerHTML = leafCache.options.map(opt => `