/* --- 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, apiGetCmDiagnostics, apiListCms, apiListRpds } from './api.js'; // 3. 共用工具模組 (Utils) import { getGlobalConnectionInfo } from './utils.js'; // 4. 樹狀圖 UI 模組 (Tree UI) import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js'; // 5. 編輯模式與鎖定模組 (Edit Mode) import { releaseAllLocks, startEditFolder, startEditLeaf, cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId } 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; } }); // 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離) let activeLockState = new Set(); let lockPollingTimer = null; let currentPollingHost = null; // 記錄當前輪詢的設備 IP window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } } window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown) function startLockStatusPolling() { if (lockPollingTimer) clearInterval(lockPollingTimer); lockPollingTimer = setInterval(async () => { const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; const host = connInfo.host; // 🛡️ 跨設備防呆:如果使用者切換了 IP,必須清空舊設備的鎖定記憶,防止幽靈解鎖 if (currentPollingHost !== host) { activeLockState.clear(); currentPollingHost = host; } try { const result = await apiGetLockStatus(host); if (result.status === 'success') { const currentLocks = result.data; window.globalActiveLocks[host] = currentLocks; const newLockState = new Set(); const now = Date.now(); // 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點 for (const [path, lockInfo] of Object.entries(currentLocks)) { const lockKey = `${host}@@${path}`; // 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒) if (window.recentlyReleasedLocks[lockKey]) { if (now - window.recentlyReleasedLocks[lockKey] < 8000) { continue; // 跳過,不當作鎖定 } else { delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶 } } newLockState.add(lockKey); // 記憶體加入 Host 標籤 const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); targetBtns.forEach(btn => { // 略過目前正在編輯的那個實體按鈕 if (btn.id === `edit-btn-${currentEditElementId}`) return; if (btn.innerText !== "🔒") { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; const btnPath = btn.getAttribute('data-path'); if (btnPath === path) { // 判斷是別人鎖的,還是自己在另一個視圖鎖的 if (lockInfo.user_id !== SESSION_USER_ID) { btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; } else { btn.title = `🔒 您正在另一個視圖編輯此項目`; } } else { // 子節點被父節點鎖定 btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; } btn.innerText = "🔒"; } }); } // 🔓 任務 2:處理「解除鎖定」的節點 activeLockState.forEach(oldKey => { const [oldHost, oldPath] = oldKey.split('@@'); // 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點 if (oldHost === host && !newLockState.has(oldKey)) { const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); targetBtns.forEach(btn => { if (btn.innerText === "🔒") { const btnPath = btn.getAttribute('data-path'); let stillLockedByOther = false; // 檢查自己是否還在鎖定清單中 if (currentLocks[btnPath]) { stillLockedByOther = true; } else { // 檢查是否有其他父節點鎖定它 for (const lockedPath of Object.keys(currentLocks)) { if (btnPath.startsWith(lockedPath + "::")) { stillLockedByOther = true; break; } } } if (!stillLockedByOther) { 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); } // 切換「設備查詢」內的子任務表單 // ============================================================================ // 🌟 設備查詢動態清單快取 (新增) // ============================================================================ let cachedDeviceLists = { cms: null, rpds: null, host: null }; // 切換「設備查詢」內的子任務表單 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'); // 🌟 觸發背景動態載入 Datalist loadDynamicDatalist(selectedTaskId); } // 🌟 新增:背景載入 Datalist 邏輯 async function loadDynamicDatalist(taskType) { // 若尚未連線,則不觸發背景抓取 (不跳警告,默默 return) if (!isConnected) return; const host = document.getElementById('cmtsHost').value.trim(); const user = document.getElementById('cmtsUser').value.trim(); const pass = document.getElementById('cmtsPass').value.trim(); if (!host || !user || !pass) return; // 若切換了設備 IP,清空舊快取 if (cachedDeviceLists.host !== host) { cachedDeviceLists = { cms: null, rpds: null, host: host }; } if (taskType === 'form-cm-query' || taskType === 'form-cm-diagnostics') { const inputId = taskType === 'form-cm-query' ? 'queryTargetCm' : 'diagMacInput'; const listId = taskType === 'form-cm-query' ? 'cm-mac-list' : 'diag-cm-mac-list'; const defaultPlaceholder = taskType === 'form-cm-query' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例如: 6467.7240.4076"; await fetchAndBindList('cms', inputId, listId, defaultPlaceholder, host, user, pass); } else if (taskType === 'form-rpd-query') { await fetchAndBindList('rpds', 'queryTargetRpd', 'rpd-vcvs-list', "例: 13:0 (留白則查詢全體)", host, user, pass); } } // 🌟 新增:執行 API 呼叫與 UX 狀態切換 async function fetchAndBindList(type, inputId, listId, defaultPlaceholder, host, user, pass) { const inputEl = document.getElementById(inputId); const datalistEl = document.getElementById(listId); if (!inputEl || !datalistEl) return; // 如果已經有快取,直接綁定並結束 if (cachedDeviceLists[type]) { populateDatalist(datalistEl, cachedDeviceLists[type]); return; } // 狀態 1:發出請求前 (鎖定輸入框,提示載入中) inputEl.disabled = true; inputEl.placeholder = "🔄 載入設備清單中..."; inputEl.style.backgroundColor = "#fdfefe"; try { let data; if (type === 'cms') { data = await apiListCms(host, user, pass); cachedDeviceLists.cms = data.cms || []; } else { data = await apiListRpds(host, user, pass); cachedDeviceLists.rpds = data.rpds || []; } // 狀態 2:請求成功 (塞入選項,恢復輸入框) populateDatalist(datalistEl, cachedDeviceLists[type]); inputEl.placeholder = defaultPlaceholder; } catch (error) { console.error(`[Background Task] Failed to load ${type}:`, error); // 狀態 3:請求失敗 (提示失敗,但仍解鎖讓使用者手動輸入) inputEl.placeholder = "⚠️ 清單載入失敗,請手動輸入"; } finally { inputEl.disabled = false; inputEl.style.backgroundColor = ""; // 恢復預設背景色 } } // 🌟 新增:將陣列轉換為 塞入 function populateDatalist(datalistEl, items) { datalistEl.innerHTML = ''; items.forEach(item => { const option = document.createElement('option'); option.value = item; datalistEl.appendChild(option); }); } // 🌟 新增一個變數來記錄上一次的樹狀圖模式 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(); // 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓取 if (typeof cachedDeviceLists !== 'undefined') { cachedDeviceLists = { cms: null, rpds: null, host: null }; } // 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM clearTreeCache(); // 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體 if (typeof activeLockState !== 'undefined' && activeLockState.clear) { activeLockState.clear(); } 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 mdInput = document.getElementById('cfgMacDomain'); if (mdInput) { mdInput.value = ''; mdInput.placeholder = '請先點擊上方載入任務...'; } const mdDatalist = document.getElementById('mac-domain-list'); if (mdDatalist) mdDatalist.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'); // 🌟 模擬動態訊息流邏輯 const states = [ `🔌 正在建立 SSH 連線至 ${connInfo.host}...`, `📥 正在向設備發送查詢指令...`, `🧩 正在等待設備回傳資料...` ]; let progressIdx = 0; outputEl.innerHTML = `${states[0]}`; const progressInterval = setInterval(() => { progressIdx++; if (progressIdx < states.length) { const textEl = document.getElementById('query-progress-text'); if (textEl) textEl.innerText = states[progressIdx]; } }, 1500); // 每 1.5 秒切換一次狀態 try { const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass); clearInterval(progressInterval); // 收到結果,停止輪播 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) { clearInterval(progressInterval); // 發生錯誤,停止輪播 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'; loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色 loadingMsg.innerHTML = '⏳ 準備連線至設備...'; } if (treeContainer) treeContainer.innerHTML = ''; let needAutoScan = false; let progressInterval = null; try { try { const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); if (versionRes.status === 'success') { const currentVersion = versionRes.version; 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); await apiClearCache(connInfo.host, allKeys, configType); needAutoScan = true; } } } } catch (vErr) { console.warn("版本檢查失敗,略過", vErr); } const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`); if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let finalTreeData = null; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); for (let line of lines) { if (!line.trim()) continue; try { const data = JSON.parse(line); if (data.status === 'progress') { if (data.message.includes('正在下載')) { let progress = 0; if (progressInterval) clearInterval(progressInterval); progressInterval = setInterval(() => { progress += (95 - progress) * 0.06; if (loadingMsg) { loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`; } }, 500); } else { if (progressInterval) { clearInterval(progressInterval); progressInterval = null; if (loadingMsg) { loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`; loadingMsg.style.color = "#27ae60"; } await new Promise(resolve => setTimeout(resolve, 600)); if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色 } if (loadingMsg) { loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色 loadingMsg.innerHTML = data.message; } } } else if (data.status === 'success') { finalTreeData = data.data; } else if (data.status === 'error') { throw new Error(data.message); } } catch (e) { console.warn("解析串流 JSON 失敗:", line); } } } if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。"); window.currentTreeData = finalTreeData; const currentSelectedTask = document.getElementById('configTask').value; const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; if (currentSelectedTask !== expectedTask) return; if (treeContainer) { treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType); if (loadingMsg) loadingMsg.style.display = 'none'; const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType); if (isSomeoneScanning) { alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。"); lockScanButton(60000); } else { enableGlobalScanButton(); if (needAutoScan) { setTimeout(() => scanGlobalMissingOptions(configType), 500); } } } } catch (error) { if (treeContainer) treeContainer.innerHTML = `❌ 連線或解析失敗: ${error.message}`; } finally { if (progressInterval) clearInterval(progressInterval); if (loadingMsg) { loadingMsg.style.display = 'none'; loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色 } } } // ============================================================================ // 🌟 CM 一鍵診斷中心邏輯 (多頻道切換與紅綠燈實作) // ============================================================================ let merChartInstance = null; let globalMerData = {}; // 暫存所有頻道的 MER 資料供切換使用 window.runCmDiagnostics = async function() { const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; const macInput = document.getElementById('diagMacInput').value.trim(); if (!macInput) return alert("請輸入目標 CM 的 MAC Address!"); const btn = document.getElementById('btnRunDiag'); const msg = document.getElementById('diagStatusMsg'); const resultArea = document.getElementById('diagResultArea'); btn.disabled = true; btn.style.opacity = '0.7'; msg.style.display = 'inline-block'; resultArea.style.display = 'none'; try { const result = await apiGetCmDiagnostics(connInfo.host, connInfo.user, connInfo.pass, macInput); if (result.status === 'success') { const data = result.data; // 1. 填入基本資訊 document.getElementById('diagResMac').textContent = data.mac; document.getElementById('diagResIp').textContent = data.ip || 'N/A'; document.getElementById('diagResCpe').textContent = data.cpe_count; const stateEl = document.getElementById('diagResState'); stateEl.textContent = data.state || 'N/A'; stateEl.style.color = (data.state && data.state.toLowerCase().includes('online')) ? '#27ae60' : '#e74c3c'; document.getElementById('diagResTx').textContent = data.phy.tx_power !== null ? data.phy.tx_power : 'N/A'; document.getElementById('diagResRx').textContent = data.phy.rx_power !== null ? data.phy.rx_power : 'N/A'; // 2. 渲染上行 SNR (Upstreams) 標籤群 const usContainer = document.getElementById('upstreamSnrContainer'); usContainer.innerHTML = ''; if (data.upstreams && data.upstreams.length > 0) { data.upstreams.forEach(us => { let color = '#e74c3c'; // 預設紅 (差: < 30) if (us.snr >= 35) color = '#27ae60'; // 綠 (優: >= 35) else if (us.snr >= 30) color = '#f39c12'; // 橘 (尚可: 30~34.9) usContainer.innerHTML += ` ${us.channel} : ${us.snr} `; }); } else { usContainer.innerHTML = '無資料'; } // 3. 處理 OFDM MER 頻道按鈕與圖表 globalMerData = data.mer_channels; const tabsContainer = document.getElementById('ofdmChannelTabs'); tabsContainer.innerHTML = ''; const channels = Object.keys(globalMerData); if (channels.length > 0) { channels.forEach((ch, index) => { const chData = globalMerData[ch]; // 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅) let bgCol = '#bdc3c7'; // 預設灰色 (無資料) if (chData.health === 'good') bgCol = '#27ae60'; else if (chData.health === 'warning') bgCol = '#f39c12'; else if (chData.health === 'critical') bgCol = '#e74c3c'; const btn = document.createElement('button'); // 💡 拔除方正的 class,改用純手工膠囊樣式 btn.style.boxSizing = 'border-box'; btn.style.cursor = 'pointer'; btn.style.backgroundColor = bgCol; btn.style.color = '#ffffff'; btn.style.padding = '6px 14px'; btn.style.borderRadius = '20px'; // 🌟 完美的膠囊圓角 btn.style.fontSize = '12px'; btn.style.fontWeight = 'bold'; btn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; btn.style.transition = 'all 0.2s ease'; // 點擊與未點擊的狀態區分 (透明度與外框) btn.style.opacity = index === 0 ? '1' : '0.5'; btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent'; // 標籤文字加入最低 MER 數值提示 btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`; btn.onclick = () => { // 重置所有按鈕外觀 Array.from(tabsContainer.children).forEach(b => { b.style.opacity = '0.5'; b.style.border = '2px solid transparent'; }); // 突顯當前點擊的按鈕 btn.style.opacity = '1'; btn.style.border = '2px solid #2c3e50'; // 重新繪圖 renderMerChart(ch, chData.histogram); }; tabsContainer.appendChild(btn); }); // 預設渲染第一個頻道的圖表 renderMerChart(channels[0], globalMerData[channels[0]].histogram); } else { tabsContainer.innerHTML = '⚠️ 設備無回傳任何 OFDM 頻道 MER 數據'; renderMerChart('無資料', {}); } resultArea.style.display = 'flex'; } } catch (error) { alert(`❌ 診斷失敗: ${error.message}`); } finally { btn.disabled = false; btn.style.opacity = '1'; msg.style.display = 'none'; } }; function renderMerChart(channelName, histogramData) { if (merChartInstance) { merChartInstance.destroy(); } const ctx = document.getElementById('merChart').getContext('2d'); if (!histogramData || Object.keys(histogramData).length === 0) { merChartInstance = new Chart(ctx, { type: 'bar', data: { labels: ['無資料'], datasets: [{ label: 'Subcarriers', data: [0] }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: '無法取得 MER 數據' } } } }); return; } const labels = Object.keys(histogramData).sort((a, b) => parseInt(a) - parseInt(b)); const dataPoints = labels.map(label => histogramData[label]); merChartInstance = new Chart(ctx, { type: 'bar', data: { labels: labels.map(l => `${l} dB`), datasets: [{ label: 'Subcarriers 數量', data: dataPoints, backgroundColor: 'rgba(155, 89, 182, 0.6)', borderColor: 'rgba(142, 68, 173, 1)', borderWidth: 1, borderRadius: 4, hoverBackgroundColor: 'rgba(155, 89, 182, 0.9)' }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, title: { display: true, text: 'Subcarriers 數量', font: { weight: 'bold' } }, grid: { color: '#ecf0f1' } }, x: { title: { display: true, text: 'MER (dB)', font: { weight: 'bold' } }, grid: { display: false } } }, plugins: { title: { display: true, text: `顯示中頻道: ${channelName}`, font: { size: 15, weight: 'bold' }, color: '#2c3e50' }, legend: { display: false }, tooltip: { backgroundColor: 'rgba(44, 62, 80, 0.9)', titleFont: { size: 14 }, bodyFont: { size: 14 }, padding: 10, callbacks: { label: function(context) { return ` ${context.raw} 個 Subcarriers`; } } } } } }); } // ============================================================================ // 🌟 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 => ``).join(''); } } else { // 🌟 3. POST 請求:把 host 塞進 body 裡面 fetch('/api/v1/cmts-leaf-options/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode }) }); } } catch (e) { console.warn("選項外掛載入失敗,維持純文字輸入", e); } } // ============================================================================ // 🌟 核心功能:執行掃描 (廣播升級版),加上 mode 參數 // ============================================================================ // 🌟 新增:遞迴提取所有快取 Key 的輔助函數 (不依賴網頁 DOM) function extractAllCacheKeys(node, path = '') { let keys = []; for (const [key, value] of Object.entries(node)) { const currentPath = path ? `${path}::${key}` : key; if (typeof value === 'object' && value !== null) { keys = keys.concat(extractAllCacheKeys(value, currentPath)); } else { const origVal = (value === null ? '' : value).toString(); let cacheKey = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (currentPath.endsWith('::no')) { cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; } keys.push(cacheKey); } } return keys; } async function performScan(isGlobal, mode = 'running') { const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return alert("請先連線至設備!"); const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode); if (isSomeoneScanning) { alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!"); return; } const treeContainer = document.getElementById(`tree-container-${mode}`); if (!treeContainer) return; const statusEl = document.getElementById('scan-status'); try { const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); const optData = await optRes.json(); const pathsToSync = []; if (isGlobal) { // 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM if (!window.currentTreeData) return alert("請先載入設備配置!"); const allKeys = extractAllCacheKeys(window.currentTreeData); allKeys.forEach(cacheKey => { if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) { pathsToSync.push(cacheKey); } }); } else { // 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的) const allContainers = treeContainer.querySelectorAll('.leaf-container'); allContainers.forEach(container => { const isHiddenByFolder = container.closest('details:not([open])') !== null; if (!isHiddenByFolder) { const path = container.getAttribute('data-path'); if (path) { const origVal = container.getAttribute('data-original') || ''; let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) { pathsToSync.push(cacheKey); } } } }); } if (pathsToSync.length === 0) { statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!"; statusEl.style.color = "#27ae60"; setTimeout(() => statusEl.textContent = "", 3000); return; } const response = await fetch('/api/v1/cmts-leaf-options/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode }) }); const result = await response.json(); if (result.status === 'busy') { alert(result.message); } } catch (error) { console.error("掃描請求發送失敗:", error); statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。"; statusEl.style.color = "#e74c3c"; } } // ============================================================================ // 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數 // ============================================================================ async function performClear(isGlobal, mode = 'running') { // 🌟 1. 取得當前連線的 IP const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return alert("請先連線至設備!"); // 🌟 2. 檢查掃描狀態時,傳入 host const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode); if (isSomeoneScanning) { alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突,請稍候掃描完成再執行清除動作。"); return; } let pathsToClear = []; if (isGlobal) { try { // 🌟 3. GET 請求:網址加上 host 參數 const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); const optData = await optRes.json(); pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__'); } catch (e) { console.error(e); return alert("❌ 無法取得全域快取清單。"); } } else { const treeContainer = document.getElementById(`tree-container-${mode}`); const elements = treeContainer ? treeContainer.querySelectorAll('.leaf-container') : []; elements.forEach(el => { const isHiddenByFolder = el.closest('details:not([open])') !== null; if (!isHiddenByFolder) { const path = el.getAttribute('data-path'); if (path) { const cacheKey = path.replace(/::/g, ' '); if (!pathsToClear.includes(cacheKey)) pathsToClear.push(cacheKey); } } }); } if (pathsToClear.length === 0) return alert(isGlobal ? "目前沒有任何快取可以清除!" : "局部沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。"); const msg = isGlobal ? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)` : `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後將自動為您重新掃描)`; if (!confirm(msg)) return; try { // 🌟 4. 呼叫清除 API 時,傳入 host const result = await apiClearCache(connInfo.host, pathsToClear, mode); const statusEl = document.getElementById('scan-status'); if (statusEl) { statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`; statusEl.style.color = "#27ae60"; } // 清除完畢後,自動呼叫簡化版的 performScan await performScan(isGlobal, mode); } catch (error) { console.error("清除快取失敗:", error); alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。"); } } // 綁定給 HTML 呼叫的 4 個獨立函數 function scanVisibleMissingOptions() { performScan(false); } function scanGlobalMissingOptions() { // 🌟 動態抓取當前選擇的模式,傳給 performScan const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; performScan(true, mode); } function clearVisibleCache() { performClear(false); } function clearGlobalCache() { // 🌟 動態抓取當前選擇的模式,傳給 performClear const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; performClear(true, mode); } // ============================================================================ // 🌟 5. 系統設定與過濾器 (System Settings & Filters) // ============================================================================ let currentHiddenKeys = []; // 🌟 新增:切換過濾器模式時觸發 async function switchFilterMode() { const container = document.getElementById('filter-checkboxes'); if (container) container.style.display = 'none'; // 先隱藏樹狀圖,強迫使用者重新載入 await openSystemSettings(); } // 開啟系統設定並讀取目前的過濾器清單 async function openSystemSettings() { // 🌟 動態抓取過濾器模式 const modeSelect = document.getElementById('filter-mode-select'); const mode = modeSelect ? modeSelect.value : 'running'; try { const result = await apiGetTreeFilters(mode); if (result.status === 'success') currentHiddenKeys = result.data; } catch (error) { console.error("讀取設定失敗:", error); } } // 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API) async function loadRealConfigForFilters() { const connInfo = getGlobalConnectionInfo(); if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!"); const modeSelect = document.getElementById('filter-mode-select'); const mode = modeSelect ? modeSelect.value : 'running'; const loadingMsg = document.getElementById('filter-loading-msg'); const container = document.getElementById('filter-checkboxes'); loadingMsg.style.display = 'inline-block'; loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色 loadingMsg.innerHTML = '⏳ 準備連線至設備...'; container.style.display = 'none'; let progressInterval = null; // 🌟 新增:進度條計時器 try { const url = `/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`; const response = await fetch(url); if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let finalTreeData = null; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); for (let line of lines) { if (!line.trim()) continue; try { const data = JSON.parse(line); if (data.status === 'progress') { // 🌟 導入百分比動畫邏輯 if (data.message.includes('正在下載')) { let progress = 0; if (progressInterval) clearInterval(progressInterval); progressInterval = setInterval(() => { progress += (95 - progress) * 0.06; if (loadingMsg) { loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (${Math.round(progress)}%)...`; } }, 500); } else { if (progressInterval) { clearInterval(progressInterval); progressInterval = null; if (loadingMsg) { loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (100%) - 下載完成!`; loadingMsg.style.color = "#27ae60"; // 瞬間變綠色 } await new Promise(resolve => setTimeout(resolve, 600)); if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 恢復橘色 } if (loadingMsg) { loadingMsg.style.color = '#f39c12'; loadingMsg.innerHTML = data.message; } } } else if (data.status === 'success') { finalTreeData = data.data; } else if (data.status === 'error') { throw new Error(data.message); } } catch (e) { console.warn("解析串流 JSON 失敗:", line); } } } if (finalTreeData) { container.style.display = 'block'; container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode); } else { throw new Error("未收到完整的配置資料"); } } catch (error) { container.style.display = 'block'; container.innerHTML = `❌ 連線或解析失敗: ${error.message}`; } finally { if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器 loadingMsg.style.display = 'none'; } } // 當點擊「父節點」時:同步勾選/取消所有子節點 function toggleChildCheckboxes(parentCheckbox) { const details = parentCheckbox.closest('details'); if (details) { const childCheckboxes = details.querySelectorAll('.filter-children-container .filter-checkbox'); childCheckboxes.forEach(cb => cb.checked = parentCheckbox.checked); } updateParentCheckboxState(parentCheckbox); } // 當點擊「子節點」時:往上檢查父節點是否該被勾選 function updateParentCheckboxState(element) { const parentDetails = element.closest('.filter-children-container')?.closest('details'); if (!parentDetails) return; const parentCheckbox = parentDetails.querySelector('summary .filter-checkbox'); const childCheckboxes = parentDetails.querySelectorAll('.filter-children-container .filter-checkbox'); if (parentCheckbox && childCheckboxes.length > 0) { const allChecked = Array.from(childCheckboxes).every(cb => cb.checked); parentCheckbox.checked = allChecked; updateParentCheckboxState(parentCheckbox); } } // 儲存系統設定 (過濾器) async function saveSystemSettings() { // 🌟 動態抓取過濾器模式 const modeSelect = document.getElementById('filter-mode-select'); const mode = modeSelect ? modeSelect.value : 'running'; const checkboxes = document.querySelectorAll('.filter-checkbox:checked'); const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value); try { const result = await apiSaveTreeFilters(selectedHiddenKeys, mode); if (result.status === 'success') { const statusText = document.getElementById('settings-status'); statusText.style.display = 'inline-block'; currentHiddenKeys = selectedHiddenKeys; setTimeout(() => { statusText.style.display = 'none'; }, 3000); } } catch (error) { console.error("儲存設定失敗:", error); alert("儲存設定時發生錯誤!"); } } // ============================================================================ // 🌟 新增:設備備份與還原 (Backup & Restore) // ============================================================================ // 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾 let allBackupsData = []; // 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫) async function createSnapshot() { if (!isConnected) { return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)"); } const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; const snapshotName = document.getElementById('snapshotName').value.trim(); if (!snapshotName) return alert("請輸入快照名稱!"); const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || ""; const configType = document.getElementById('backupConfigType').value; const btn = document.getElementById('btnCreateSnapshot'); const msg = document.getElementById('backup-status-msg'); btn.disabled = true; msg.style.display = 'inline-block'; msg.style.color = '#f39c12'; // 🌟 確保橘色 msg.innerHTML = '⏳ 準備連線至設備...'; let progressInterval = null; // 🌟 新增:進度條計時器 try { const response = await fetch('/api/v1/backups/snapshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, snapshot_name: snapshotName, description: snapshotDescription, config_type: configType }) }); if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let isSuccess = false; let finalMessage = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); for (let line of lines) { if (!line.trim()) continue; try { const data = JSON.parse(line); if (data.status === 'progress') { // 🌟 導入百分比動畫邏輯 if (data.message.includes('正在下載')) { let progress = 0; if (progressInterval) clearInterval(progressInterval); progressInterval = setInterval(() => { progress += (95 - progress) * 0.06; if (msg) { msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`; } }, 500); } else { if (progressInterval) { clearInterval(progressInterval); progressInterval = null; if (msg) { msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`; msg.style.color = "#27ae60"; // 瞬間變綠色 } await new Promise(resolve => setTimeout(resolve, 600)); if (msg) msg.style.color = "#f39c12"; // 恢復橘色 } if (msg) { msg.style.color = '#f39c12'; msg.innerHTML = data.message; } } } else if (data.status === 'success') { isSuccess = true; finalMessage = data.message; } else if (data.status === 'error') { throw new Error(data.message); } } catch (e) { if (e.message) throw e; console.warn("解析串流 JSON 失敗:", line); } } } if (isSuccess) { alert(`✅ ${finalMessage}`); document.getElementById('snapshotName').value = ''; if (document.getElementById('snapshotDescription')) { document.getElementById('snapshotDescription').value = ''; } loadBackupHistory(); } } catch (error) { alert(`❌ 發生錯誤: ${error.message}`); } finally { if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器 btn.disabled = false; msg.style.display = 'none'; } } // 2. 載入歷史紀錄列表 (維持 IP 綁定,避免後端報錯) async function loadBackupHistory() { // 🌟 補回取得當前畫面上設定的 IP const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; const tbody = document.getElementById('backup-history-tbody'); if (!tbody) return; // 🌟 修正:將原本的灰色改為橘色並加粗 tbody.innerHTML = '⏳ 正在載入歷史紀錄...'; try { // 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來 const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=all`); const result = await response.json(); if (result.status === 'success') { allBackupsData = result.data; applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染 } else { // 加入防呆:如果後端回傳 422 (detail),也能正確印出錯誤而不是 undefined const errMsg = result.message || (result.detail ? JSON.stringify(result.detail) : '未知錯誤'); tbody.innerHTML = `❌ 載入失敗: ${errMsg}`; } } catch (error) { tbody.innerHTML = `❌ 連線失敗: ${error.message}`; } } // 💡 前端多維度過濾器邏輯 window.applyBackupFilters = function() { const tbody = document.getElementById('backup-history-tbody'); if (!tbody) return; // 取得所有過濾條件 (加上 trim() 避免不小心多敲了空白鍵) const keyword = (document.getElementById('filter-keyword')?.value || '').trim().toLowerCase(); const type = document.getElementById('filter-type')?.value || ''; const dateStart = document.getElementById('filter-date-start')?.value; const dateEnd = document.getElementById('filter-date-end')?.value; // 進行陣列過濾 const filteredData = allBackupsData.filter(item => { // 💡 終極防呆版:確保變數存在,並同時搜尋名稱與描述 const safeName = (item.snapshot_name || '').toLowerCase(); const safeDesc = (item.description || '').toLowerCase(); const matchKeyword = safeName.includes(keyword) || safeDesc.includes(keyword); const matchType = type === '' || item.config_type === type; let matchDate = true; const itemDate = new Date(item.timestamp); if (dateStart) { const start = new Date(dateStart); start.setHours(0, 0, 0, 0); if (itemDate < start) matchDate = false; } if (dateEnd) { const end = new Date(dateEnd); end.setHours(23, 59, 59, 999); if (itemDate > end) matchDate = false; } return matchKeyword && matchType && matchDate; }); // 渲染過濾後的結果 if (filteredData.length > 0) { tbody.innerHTML = filteredData.map(item => ` ${new Date(item.timestamp).toLocaleString()} ${item.snapshot_name} ${item.description || '無'} ${item.config_type} 👁️ 檢視 ⚡ 還原 🗑️ 刪除 `).join(''); } else { // 🟢 修正:沒有資料時,colspan 改為 5 tbody.innerHTML = '找不到符合條件的備份紀錄。'; } }; // 3. 檢視特定快照內容 async function viewBackup(backupId) { openModal("載入快照中..."); const outputEl = document.getElementById('modalOutput'); outputEl.innerHTML = `⏳ 正在向資料庫撈取快照資料,請稍候...`; try { const response = await fetch(`/api/v1/backups/${backupId}`); const result = await response.json(); if (result.status === 'success') { // 確保這行真的有加進去,且檔案有按下 Ctrl+S 儲存! const data = result.data; // 🟤 將描述加入彈出視窗的標頭中 const descText = data.description ? data.description : '無'; const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【備份描述】: ${descText}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`; // 如果後端有回傳 raw_cli 就優先顯示,否則將 parsed_tree 轉為字串顯示 const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2); outputEl.style.color = "#e0e0e0"; outputEl.textContent = infoHeader + content; } else { outputEl.style.color = "#e74c3c"; outputEl.textContent = `❌ 讀取失敗: ${result.message}`; } } catch (error) { outputEl.style.color = "#e74c3c"; outputEl.textContent = `❌ 連線失敗: ${error.message}`; } } // 4. 刪除快照 async function deleteBackup(backupId) { if (!confirm("⚠️ 確定要永久刪除這筆備份紀錄嗎?此動作無法復原!")) return; try { const response = await fetch(`/api/v1/backups/${backupId}`, { method: 'DELETE' }); const result = await response.json(); if (result.status === 'success') { loadBackupHistory(); // 刪除成功後重新整理列表 } else { alert(`❌ 刪除失敗: ${result.message}`); } } catch (error) { alert(`❌ 發生錯誤: ${error.message}`); } } // 5. 請求設備差異分析 (安全還原第一步) // 🌟 接收第三個參數 backupHost async function restoreBackup(snapshotId, snapshotName, backupHost) { // 🛡️ 防線一:必須先連線 if (!isConnected) { return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行還原任務!"); } const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; // 🛡️ 防線二:嚴格阻斷跨設備還原 if (backupHost && backupHost !== connInfo.host) { alert( `🚨 【跨設備還原阻斷】🚨\n\n` + `這份快照屬於設備:${backupHost}\n` + `您目前連線的設備:${connInfo.host}\n\n` + `⚠️ 系統目前禁止跨設備還原,以防止嚴重的網路衝突。請先連線至正確的設備再執行此操作!` ); return; // 直接中斷,不進行後續 API 呼叫 } const modal = document.getElementById('outputModal'); const modalOutput = document.getElementById('modalOutput'); const modalTargetInfo = document.getElementById('modalTargetInfo'); // 1. 移除進度條,改為純文字百分比動態顯示 modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`; // 🌟 修正:將原本的藍色改為橘色 modalOutput.innerHTML = ` 🔍 正在抓取設備當前配置,並與快照進行深度比對... (0%) `; modal.classList.add('active'); // 啟動漸進式模擬進度邏輯 (最高停在 95%) let progress = 0; const progressInterval = setInterval(() => { progress += (95 - progress) * 0.08; const text = document.getElementById('diff-progress-text'); if (text) { text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (${Math.round(progress)}%)`; } }, 400); try { const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass }) }); const result = await response.json(); // API 回傳後,清除計時器並將進度填滿 100% clearInterval(progressInterval); const text = document.getElementById('diff-progress-text'); if (text) { text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (100%) - 分析完成!`; } // 稍微延遲 0.5 秒讓使用者看清楚 100%,再渲染結果 setTimeout(() => { if (result.status === 'success') { const commands = result.data.commands; let diffHtml = `以下是將設備還原至 ${snapshotName} 所需執行的指令清單:`; if (commands.length === 0) { diffHtml += `✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!`; } else { const safeCommands = JSON.stringify(commands).replace(/"/g, '"'); // 2. 更改按鈕顏色:使用亮藍色 (#2980b9) 並加上陰影與邊框,凸顯立體感 modalTargetInfo.innerHTML = ` 正在分析快照差異: ${snapshotName} 📊 邏輯差異 💻 原始執行指令 (Raw CLI) `; // 動態插入確認按鈕到 modal-action-container const actionContainer = document.getElementById('modal-action-container'); if (actionContainer) { const oldConfirmBtn = document.getElementById('btn-confirm-restore'); if (oldConfirmBtn) oldConfirmBtn.remove(); const confirmBtn = document.createElement('button'); confirmBtn.id = 'btn-confirm-restore'; confirmBtn.className = 'btn-modern btn-save'; confirmBtn.style.backgroundColor = '#e74c3c'; confirmBtn.style.padding = '6px 12px'; confirmBtn.style.fontSize = '13px'; confirmBtn.innerHTML = '⚠️ 確認無誤,立即寫入設備並 Commit'; confirmBtn.onclick = () => executeSmartRestore(snapshotId, snapshotName, commands); actionContainer.insertBefore(confirmBtn, document.getElementById('btn-modal-close')); } diffHtml += ``; commands.forEach(cmd => { if (cmd.startsWith('no ')) diffHtml += `- ${cmd}\n`; else diffHtml += `+ ${cmd}\n`; }); diffHtml += ``; diffHtml += ``; commands.forEach(cmd => { diffHtml += `${cmd}\n`; }); diffHtml += `commit\n`; diffHtml += ``; } modalOutput.innerHTML = diffHtml; } else { modalOutput.innerHTML = `❌ 差異分析失敗:\n${result.message}`; } }, 500); } catch (error) { clearInterval(progressInterval); modalOutput.innerHTML = `❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}`; } } // 6. 真正執行差異還原 (串接後端 Streaming API) window.executeSmartRestore = async function(snapshotId, snapshotName, commands) { if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit!\n確定要繼續嗎?`)) return; // 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕 const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close']; btnIds.forEach(id => { const btn = document.getElementById(id); if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; btn.style.cursor = 'not-allowed'; btn.style.pointerEvents = 'none'; if (id === 'btn-confirm-restore') { btn.innerText = '⏳ 正在寫入設備中...'; btn.style.backgroundColor = '#7f8c8d'; } } }); const connInfo = getGlobalConnectionInfo(); const modalOutput = document.getElementById('modalOutput'); // 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸) modalOutput.innerHTML = ` [系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗... [系統] 準備連線至設備 ${connInfo.host}... `; const logContainer = document.getElementById('restore-log-container'); try { const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands }) }); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); for (let line of lines) { if (!line.trim()) continue; try { const data = JSON.parse(line); const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false }); if (data.status === 'progress') { logContainer.innerHTML += `[${timeStr}] ${data.message}`; } else if (data.status === 'success') { let cleanOutput = data.output || '無'; cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim(); logContainer.innerHTML += ` [${timeStr}] ${data.message} 設備 Commit 回傳:${cleanOutput} `; document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`; // 成功後,隱藏確認按鈕,並恢復關閉按鈕 const confirmBtn = document.getElementById('btn-confirm-restore'); if (confirmBtn) confirmBtn.style.display = 'none'; const closeBtn = document.getElementById('btn-modal-close'); if (closeBtn) { closeBtn.disabled = false; closeBtn.style.opacity = '1'; closeBtn.style.cursor = 'pointer'; closeBtn.style.pointerEvents = 'auto'; } } else if (data.status === 'error') { logContainer.innerHTML += `[${timeStr}] ${data.message}`; if (data.output) { logContainer.innerHTML += `設備回傳: ${data.output}`; } restoreButtonsState(btnIds); } logContainer.scrollTop = logContainer.scrollHeight; } catch (e) { console.warn("解析串流 JSON 失敗:", line); } } } } catch (error) { logContainer.innerHTML += `❌ 系統錯誤:無法連線至後端伺服器。${error.message}`; restoreButtonsState(btnIds); } }; // 輔助函數:恢復按鈕狀態 (請放在 executeSmartRestore 下方) function restoreButtonsState(btnIds) { btnIds.forEach(id => { const btn = document.getElementById(id); if (btn) { btn.disabled = false; btn.style.opacity = '1'; btn.style.cursor = 'pointer'; btn.style.pointerEvents = 'auto'; if (id === 'btn-confirm-restore') { btn.innerText = '⚠️ 確認無誤,重新嘗試寫入'; btn.style.backgroundColor = '#e74c3c'; } } }); } // ============================================================================ // 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States) // ============================================================================ // 開啟共用輸出彈出視窗 function openModal(targetInfo) { document.getElementById('outputModal').classList.add('active'); document.getElementById('modalTargetInfo').textContent = targetInfo ? `(${targetInfo})` : ''; document.body.style.overflow = 'hidden'; } // 關閉共用輸出彈出視窗 function closeModal() { document.getElementById('outputModal').classList.remove('active'); document.body.style.overflow = ''; // 清除動態加入的確認按鈕,避免污染下次開啟 const confirmBtn = document.getElementById('btn-confirm-restore'); if (confirmBtn) confirmBtn.remove(); } // 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態) document.addEventListener("DOMContentLoaded", checkScanButtonState); // ========================================== // 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯 // ========================================== const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global']; document.addEventListener('DOMContentLoaded', () => { const appTitle = document.getElementById('app-title'); function unlockAdminFeatures() { ADMIN_BTN_IDS.forEach(id => { const btn = document.getElementById(id); if (btn) btn.style.display = 'inline-block'; }); // 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕 document.querySelectorAll('.debug-preview-btn').forEach(btn => { btn.style.display = 'inline-block'; }); } const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('mode') === 'godmode') unlockAdminFeatures(); let clickCount = 0; let clickTimer = null; if (appTitle) { appTitle.addEventListener('click', () => { clickCount++; if (clickCount === 5) { unlockAdminFeatures(); alert('🔓 已解鎖系統維護者模式!'); clickCount = 0; } clearTimeout(clickTimer); clickTimer = setTimeout(() => { clickCount = 0; }, 1000); }); } }); // 統一控制 4 顆按鈕的狀態 function setAdminButtonsState(isBusy, text = "⏳ 執行中...") { ADMIN_BTN_IDS.forEach(id => { const btn = document.getElementById(id); if (btn) { btn.disabled = isBusy; if (isBusy) { btn.classList.add('is-busy'); if (id.includes('scan')) btn.innerText = text; } else { btn.classList.remove('is-busy'); // 恢復原本的文字 if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失"; if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失"; } } }); } function checkScanButtonState() { const btn = document.getElementById('btn-scan-visible'); if (!btn) return; const scanLockUntil = localStorage.getItem('scanLockUntil'); if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) { lockScanButton(parseInt(scanLockUntil) - Date.now()); } } function lockScanButton(durationMs) { setAdminButtonsState(true, "⏳ 背景掃描執行中..."); setTimeout(() => { const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const treeContainer = document.getElementById(`tree-container-${currentMode}`); if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) { enableGlobalScanButton(); } else { setAdminButtonsState(true, "⏳ 請先載入樹狀圖"); } localStorage.removeItem('scanLockUntil'); }, durationMs); } function enableGlobalScanButton() { const scanLockUntil = localStorage.getItem('scanLockUntil'); if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) { setAdminButtonsState(false); } } // ============================================================================ // 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫) // ============================================================================ // --- 終端機綁定 --- window.connectWebSocket = () => connectWebSocket(resetAllManagerData); window.disconnectWebSocket = disconnectWebSocket; window.injectCommand = injectCommand; window.downloadTerminal = downloadTerminal; // --- 頁籤與任務切換 --- window.switchTab = switchTab; window.switchQueryTask = switchQueryTask; window.switchConfigTask = switchConfigTask; window.startConfigTask = startConfigTask; window.toggleQueryInputs = toggleQueryInputs; // --- 查詢與 MAC Domain 精靈 --- window.executeQuery = executeQuery; window.fetchMacDomainConfig = fetchMacDomainConfig; window.revertFormChanges = revertFormChanges; window.toggleGroupVisibility = toggleGroupVisibility; window.loadDsGroupData = loadDsGroupData; window.loadUsGroupData = loadUsGroupData; window.generateMacDomainCLI = generateMacDomainCLI; window.executeBondingConfig = executeBondingConfig; window.loadMacDomainList = loadMacDomainList; // --- 系統設定與快取掃描 --- window.openSystemSettings = openSystemSettings; window.loadRealConfigForFilters = loadRealConfigForFilters; window.saveSystemSettings = saveSystemSettings; window.scanVisibleMissingOptions = scanVisibleMissingOptions; window.scanGlobalMissingOptions = scanGlobalMissingOptions; window.clearVisibleCache = clearVisibleCache; window.clearGlobalCache = clearGlobalCache; window.previewNodeCLI = previewNodeCLI; window.switchFilterMode = switchFilterMode; // --- 樹狀圖展開與編輯操作 --- window.expandAll = expandAll; window.collapseAll = collapseAll; window.startEditFolder = startEditFolder; window.startEditLeaf = startEditLeaf; window.cancelEditFolder = cancelEditFolder; window.cancelEditLeaf = cancelEditLeaf; window.previewFolderCLI = previewFolderCLI; window.previewLeafCLI = previewLeafCLI; window.hideSideCLI = hideSideCLI; window.executeSideCLI = executeSideCLI; // --- 其他 UI 輔助 --- window.toggleChildCheckboxes = toggleChildCheckboxes; window.updateParentCheckboxState = updateParentCheckboxState; window.closeModal = closeModal; // --- 備份與還原 --- window.createSnapshot = createSnapshot; window.loadBackupHistory = loadBackupHistory; window.viewBackup = viewBackup; window.deleteBackup = deleteBackup; window.restoreBackup = restoreBackup; window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
`; commands.forEach(cmd => { if (cmd.startsWith('no ')) diffHtml += `- ${cmd}\n`; else diffHtml += `+ ${cmd}\n`; }); diffHtml += `
`; commands.forEach(cmd => { diffHtml += `${cmd}\n`; }); diffHtml += `commit\n`; diffHtml += `