1369 lines
62 KiB
JavaScript
1369 lines
62 KiB
JavaScript
/* --- 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 = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${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 = '<option value="">請先點擊上方載入任務...</option>';
|
||
|
||
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 = `<span style="color: #f39c12;">正在向 ${connInfo.host} 查詢中,請稍候...</span>`;
|
||
|
||
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 = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
|
||
}
|
||
} catch (error) {
|
||
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
|
||
} 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 => `<option value="${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);
|
||
}
|
||
}
|
||
|
||
// 載入真實設備配置以供過濾器勾選
|
||
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';
|
||
container.style.display = 'none';
|
||
|
||
try {
|
||
// 🌟 根據模式載入對應的配置檔 (Running 或 Full)
|
||
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
|
||
const response = await fetch(url);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
container.style.display = 'block';
|
||
// 🌟 傳遞 mode 給 buildRealFilterTree
|
||
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
|
||
} else {
|
||
container.style.display = 'block';
|
||
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
|
||
}
|
||
} catch (error) {
|
||
container.style.display = 'block';
|
||
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`;
|
||
} finally {
|
||
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';
|
||
|
||
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
|
||
})
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
alert(`✅ ${result.message}`);
|
||
document.getElementById('snapshotName').value = ''; // 清空名稱輸入框
|
||
if (document.getElementById('snapshotDescription')) {
|
||
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框
|
||
}
|
||
loadBackupHistory(); // 重新載入歷史紀錄
|
||
} else {
|
||
alert(`❌ 備份失敗: ${result.message}`);
|
||
}
|
||
} catch (error) {
|
||
alert(`❌ 發生錯誤: ${error.message}`);
|
||
} finally {
|
||
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 = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">⏳ 正在載入歷史紀錄...</td></tr>';
|
||
|
||
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 = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 載入失敗: ${errMsg}</td></tr>`;
|
||
}
|
||
} catch (error) {
|
||
tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 連線失敗: ${error.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
// 💡 前端多維度過濾器邏輯
|
||
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 => `
|
||
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
||
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
||
<!-- 🟢 新增描述欄位,加上防撐破設計與 title 提示 -->
|
||
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
||
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
||
</td>
|
||
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
||
<td style="padding: 12px 10px; text-align: right;">
|
||
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
||
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
} else {
|
||
// 🟢 修正:沒有資料時,colspan 改為 5
|
||
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
||
}
|
||
};
|
||
|
||
// 3. 檢視特定快照內容
|
||
async function viewBackup(backupId) {
|
||
openModal("載入快照中...");
|
||
const outputEl = document.getElementById('modalOutput');
|
||
outputEl.innerHTML = `<span style="color: #f39c12;">⏳ 正在向資料庫撈取快照資料,請稍候...</span>`;
|
||
|
||
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 = `
|
||
<div id="diff-progress-text" style="color: #3498db; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置,並與快照進行深度比對... (0%)</div>
|
||
`;
|
||
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 = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單:</div>`;
|
||
|
||
if (commands.length === 0) {
|
||
diffHtml += `<div style="color: #27ae60; font-weight: bold; padding: 10px; background: rgba(39, 174, 96, 0.1); border-radius: 5px;">✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!</div>`;
|
||
} else {
|
||
const safeCommands = JSON.stringify(commands).replace(/"/g, '"');
|
||
|
||
// 2. 更改按鈕顏色:使用亮藍色 (#2980b9) 並加上陰影與邊框,凸顯立體感
|
||
modalTargetInfo.innerHTML = `
|
||
正在分析快照差異: ${snapshotName}
|
||
<span style="display: inline-flex; gap: 8px; margin-left: 35px; vertical-align: middle;">
|
||
<button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button>
|
||
<button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button>
|
||
</span>
|
||
<button id="btn-confirm-restore" onclick="executeSmartRestore('${snapshotId}', '${snapshotName}', ${safeCommands})" class="btn-modern btn-save" style="position: absolute; right: 45px; top: 12px; background-color: #e74c3c; padding: 6px 12px; font-size: 13px; border-radius: 4px; color: white; border: none; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">⚠️ 確認無誤,立即寫入設備並 Commit</button>
|
||
`;
|
||
|
||
diffHtml += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`;
|
||
commands.forEach(cmd => {
|
||
if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`;
|
||
else diffHtml += `<span style="color: #2ecc71;">+ ${cmd}</span>\n`;
|
||
});
|
||
diffHtml += `</pre>`;
|
||
|
||
diffHtml += `<pre id="view-cli" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: none; color: #ecf0f1;">`;
|
||
commands.forEach(cmd => { diffHtml += `${cmd}\n`; });
|
||
diffHtml += `<span style="color: #f39c12;">commit</span>\n`;
|
||
diffHtml += `</pre>`;
|
||
}
|
||
modalOutput.innerHTML = diffHtml;
|
||
} else {
|
||
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗:\n${result.message}</span>`;
|
||
}
|
||
}, 500);
|
||
|
||
} catch (error) {
|
||
clearInterval(progressInterval);
|
||
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// 6. 真正執行差異還原 (串接後端 API)
|
||
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
|
||
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit!\n確定要繼續嗎?`)) return;
|
||
|
||
// 優雅地將按鈕反灰 (Disabled),而不是消失
|
||
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore'];
|
||
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');
|
||
|
||
// 在原本的內容上方插入提示,保留指令預覽畫面讓使用者安心
|
||
const loadingMsg = document.createElement('div');
|
||
loadingMsg.innerHTML = `<div style="color: #f39c12; font-weight: bold; margin-bottom: 15px; padding: 10px; background: rgba(243, 156, 18, 0.1); border-radius: 5px;">⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...</div>`;
|
||
modalOutput.insertBefore(loadingMsg, modalOutput.firstChild);
|
||
|
||
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 result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
modalOutput.innerHTML = `
|
||
<div style="color: #27ae60; font-weight: bold; margin-bottom: 10px; font-size: 16px;">✅ 還原成功!設備已 Commit。</div>
|
||
<div style="color: #ecf0f1; margin-bottom: 5px;">設備回傳結果:</div>
|
||
<pre style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; color: #ecf0f1;">${result.output}</pre>
|
||
`;
|
||
document.getElementById('modalTargetInfo').innerHTML = `還原完成: ${snapshotName}`;
|
||
} else {
|
||
loadingMsg.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 還原失敗:<br>${result.message}</span>`;
|
||
// 失敗的話,把按鈕恢復原狀,允許重試
|
||
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 = '⚠️ 確認無誤,立即寫入設備並 Commit';
|
||
btn.style.backgroundColor = '#e74c3c';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
loadingMsg.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:<br>${error.message}</span>`;
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// 🌟 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(event) {
|
||
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
|
||
document.getElementById('outputModal').classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
|
||
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; // 🌟 確保過濾器綁定到全域
|