diff --git a/cmts_scraper.py b/cmts_scraper.py index ab444b3..266ac58 100644 --- a/cmts_scraper.py +++ b/cmts_scraper.py @@ -360,25 +360,53 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type: break return output - # 根據 config_type 決定指令 (這裡以 running 為例,您可依設備指令集調整) - cmd = "show running-config" if config_type == "running" else f"show config {config_type}" - - process.stdin.write(f"{cmd}\n") - await process.stdin.drain() - - raw_output = await read_until_quiet(timeout=3.0) + # 🌟 關鍵修改:根據 config_type 切換模式與指令 + if config_type == "full": + # 1. 先進入 config 模式 + process.stdin.write("config\n") + await process.stdin.drain() + await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)# + + # 2. 下達 full-configuration 指令 + cmd = "show full-configuration" + process.stdin.write(f"{cmd}\n") + await process.stdin.drain() + raw_output = await read_until_quiet(timeout=3.0) + + # 3. 抓完後退回上一層 (保持良好習慣) + process.stdin.write("exit\n") + await process.stdin.drain() + + else: + # running-config 在一般模式即可下達 + cmd = "show running-config" + process.stdin.write(f"{cmd}\n") + await process.stdin.drain() + raw_output = await read_until_quiet(timeout=3.0) + # 最終退出設備 process.stdin.write("exit\n") await process.stdin.drain() # 簡單清理頭尾的雜訊 (例如指令本身的 echo) - # 1. 清除 ANSI 控制碼 (如顏色、游標移動) 與退格鍵 \x08 - cleaned_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]|\x08', '', raw_output) - # 2. 清除殘留的 --More-- 文字 - cleaned_output = re.sub(r'--More--', '', cleaned_output) + # 🌟 關鍵修正 1:強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h) + cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output) - lines = raw_output.splitlines() - clean_lines = [line for line in lines if not line.startswith(cmd) and not line.startswith("admin@")] + # 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K) + cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output) + + # 3. 清除純文字的 --More-- (防呆) + cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output) + + # 關鍵修正:這裡改用 cleaned_output 來切行! + lines = cleaned_output.splitlines() + + # 🌟 關鍵修正 2:加入 strip() 避免空白干擾,確保精準踢掉提示字元 + clean_lines = [ + line for line in lines + if not line.strip().startswith(cmd) + and not line.strip().startswith("admin@") + ] return "\n".join(clean_lines).strip() except Exception as e: diff --git a/database.py b/database.py index 5ef7141..59ae7b8 100644 --- a/database.py +++ b/database.py @@ -253,6 +253,7 @@ async def insert_config_backup( raw_cli: str, parsed_tree: dict, snapshot_name: Optional[str] = None, + description: str = "", # 🟢 新增描述參數 (預設為空字串) is_auto: bool = False ) -> Optional[str]: """新增一筆設備配置備份,回傳產生的 Backup ID""" @@ -262,10 +263,11 @@ async def insert_config_backup( backup_id = str(uuid.uuid4()) + # 🟢 SQL 語句加入 description 與對應的 $5 query = """ INSERT INTO config_backups - (id, host, config_type, snapshot_name, is_auto, raw_cli, parsed_tree) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + (id, host, config_type, snapshot_name, description, is_auto, raw_cli, parsed_tree) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) """ try: async with pool.acquire() as conn: @@ -275,6 +277,7 @@ async def insert_config_backup( host, config_type, snapshot_name, + description, # 🟢 傳入描述參數 is_auto, raw_cli, json.dumps(parsed_tree) @@ -288,21 +291,32 @@ async def insert_config_backup( return None async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]: - """取得歷史快照列表 (輕量級,不含完整設定檔內容)""" + """取得歷史快照列表 (支援 config_type='all' 撈取全部)""" pool = await get_pool() if not pool: return None - query = """ - SELECT id, host, config_type, timestamp, snapshot_name, is_auto - FROM config_backups - WHERE host = $1 AND config_type = $2 - ORDER BY timestamp DESC; - """ try: async with pool.acquire() as conn: - records = await conn.fetch(query, host, config_type) - # 將 asyncpg 的 Record 轉為 dict,並將 datetime 轉為字串方便 JSON 序列化 + if config_type == "all": + # 🟢 SELECT 加入 description + query = """ + SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto + FROM config_backups + WHERE host = $1 + ORDER BY timestamp DESC; + """ + records = await conn.fetch(query, host) + else: + # 🟢 SELECT 加入 description + query = """ + SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto + FROM config_backups + WHERE host = $1 AND config_type = $2 + ORDER BY timestamp DESC; + """ + records = await conn.fetch(query, host, config_type) + return [ { "id": str(r["id"]), @@ -310,6 +324,7 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D "config_type": r["config_type"], "timestamp": r["timestamp"].isoformat(), "snapshot_name": r["snapshot_name"], + "description": r["description"], # 🟢 將資料庫的描述放入回傳字典 "is_auto": r["is_auto"] } for r in records @@ -327,8 +342,9 @@ async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]: if not pool: return None + # 🟢 SELECT 加入 description query = """ - SELECT id, host, timestamp, snapshot_name, is_auto, parsed_tree + SELECT id, host, timestamp, snapshot_name, description, is_auto, parsed_tree FROM config_backups WHERE id = $1; """ @@ -344,6 +360,7 @@ async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]: "host": record["host"], "timestamp": record["timestamp"].isoformat(), "snapshot_name": record["snapshot_name"], + "description": record["description"], # 🟢 將資料庫的描述放入回傳字典 "is_auto": record["is_auto"], "parsed_tree": parsed_tree } diff --git a/index.html b/index.html index df2c998..12bc2a1 100644 --- a/index.html +++ b/index.html @@ -383,47 +383,97 @@
-
-

📸 建立新快照

-
- - - - +
+ + +
+

+ 📸 建立新快照 +

+ + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
-
+
- -
-

- 🗂️ 歷史備份紀錄 + +
+ +

+ 📁 歷史備份紀錄

- - +
+ + + + + +
+ + + +
+ + +
+ - - - - - - + + + + + - +
時間快照名稱類型操作時間快照名稱描述類型操作
請點擊「重新整理」載入歷史紀錄...請點擊「重新整理」載入歷史紀錄...
diff --git a/routers/backup.py b/routers/backup.py index 0bf22b7..7a9a1ab 100644 --- a/routers/backup.py +++ b/routers/backup.py @@ -32,6 +32,7 @@ class SnapshotRequest(BaseModel): username: str password: str snapshot_name: str + description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述 config_type: str = "running" class RestoreRequest(BaseModel): @@ -48,6 +49,10 @@ class ExecuteRestoreRequest(BaseModel): # ========================================== # 🛠️ 核心演算法:深度差異比對 (Deep Diff) # ========================================== +# 🌟 新增小工具:拔除 [0], [1] 虛擬後綴,還原真實 CLI +def clean_virtual_key(key: str) -> str: + return re.sub(r'\s*\[\d+\]$', '', key) + def build_add_commands(node: dict, current_path: str) -> list: """遞迴展開所有需要新增的絕對路徑指令""" commands = [] @@ -56,11 +61,13 @@ def build_add_commands(node: dict, current_path: str) -> list: return commands for key, val in node.items(): - # 🌟 新增這行:忽略系統中介資料 if key.startswith('_'): continue - path = f"{current_path} {key}".strip() + # 🌟 使用乾淨的 key 來組合路徑 + real_key = clean_virtual_key(key) + path = f"{current_path} {real_key}".strip() + if isinstance(val, dict): commands.extend(build_add_commands(val, path)) else: @@ -72,23 +79,21 @@ def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path """比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單""" commands = [] - # 1. 找出需要刪除或覆蓋的 (存在於 current) for key, curr_val in current_node.items(): - # 🌟 新增這行:忽略系統中介資料 if key.startswith('_'): continue - path = f"{current_path} {key}".strip() + # 🌟 使用乾淨的 key + real_key = clean_virtual_key(key) + path = f"{current_path} {real_key}".strip() if key not in snapshot_node: - # 快照裡沒有,代表這是後來新增的殘留設定 -> 刪除 commands.append(f"no {path}") else: snap_val = snapshot_node[key] if isinstance(curr_val, dict) and isinstance(snap_val, dict): commands.extend(generate_diff_commands(curr_val, snap_val, path)) elif isinstance(curr_val, dict) or isinstance(snap_val, dict): - # 型態不一致,先刪除舊的再新增新的 commands.append(f"no {path}") if isinstance(snap_val, dict): commands.extend(build_add_commands(snap_val, path)) @@ -96,17 +101,17 @@ def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path val_str = f" {snap_val}" if snap_val else "" commands.append(f"{path}{val_str}") elif curr_val != snap_val: - # 兩邊都是最終值,但內容不同 -> 直接覆寫 val_str = f" {snap_val}" if snap_val else "" commands.append(f"{path}{val_str}") - # 2. 找出需要新增的 (存在於 snapshot,但 current 沒有) for key, snap_val in snapshot_node.items(): - # 🌟 新增這行:忽略系統中介資料 if key.startswith('_'): continue - path = f"{current_path} {key}".strip() + # 🌟 使用乾淨的 key + real_key = clean_virtual_key(key) + path = f"{current_path} {real_key}".strip() + if key not in current_node: if isinstance(snap_val, dict): commands.extend(build_add_commands(snap_val, path)) @@ -152,6 +157,7 @@ async def create_snapshot(req: SnapshotRequest): raw_cli=raw_cli, parsed_tree=parsed_tree, snapshot_name=req.snapshot_name, + description=req.description, # 🟢 將描述傳遞給資料庫函數 is_auto=False ) @@ -180,7 +186,12 @@ async def analyze_backup_diff(backup_id: str, req: RestoreRequest): if not snapshot_tree: return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"} - raw_current = await fetch_raw_config(req.host, req.username, req.password, "running") + # 🌟 關鍵修正:動態取得該快照的 config_type,避免蘋果比橘子 + snapshot_config_type = backup_record.get("config_type", "running") + + # 🌟 關鍵修正:將硬編碼的 "running" 改為 snapshot_config_type + raw_current = await fetch_raw_config(req.host, req.username, req.password, snapshot_config_type) + if not raw_current: return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"} diff --git a/static/app.js b/static/app.js index b067abe..48828e7 100644 --- a/static/app.js +++ b/static/app.js @@ -346,8 +346,12 @@ function resetAllManagerData() { document.getElementById('queryTargetCm').value = ''; document.getElementById('queryTargetRpd').value = ''; -} + // 🌟 新增:切換設備連線後,自動重整備份歷史紀錄 + if (typeof loadBackupHistory === 'function') { + loadBackupHistory(); + } +} // ============================================================================ // 🌟 3. 設備查詢與全域配置載入 (Query & Full Config) @@ -823,14 +827,24 @@ async function saveSystemSettings() { // 🌟 新增:設備備份與還原 (Backup & Restore) // ============================================================================ +// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾 +let allBackupsData = []; + // 1. 建立新快照 async function createSnapshot() { + // 🛡️ 防線一:必須先連線才能備份 + if (!isConnected) { + return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)"); + } + const connInfo = getGlobalConnectionInfo(); - if (!connInfo || !connInfo.host) return alert("請先在畫面上方輸入設備連線資訊!"); + 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'); @@ -847,6 +861,7 @@ async function createSnapshot() { username: connInfo.user, password: connInfo.pass, snapshot_name: snapshotName, + description: snapshotDescription, // 🟢 將描述傳給後端 config_type: configType }) }); @@ -854,7 +869,10 @@ async function createSnapshot() { if (result.status === 'success') { alert(`✅ ${result.message}`); - document.getElementById('snapshotName').value = ''; // 清空輸入框 + document.getElementById('snapshotName').value = ''; // 清空名稱輸入框 + if (document.getElementById('snapshotDescription')) { + document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框 + } loadBackupHistory(); // 重新載入歷史紀錄 } else { alert(`❌ 備份失敗: ${result.message}`); @@ -867,44 +885,95 @@ async function createSnapshot() { } } -// 2. 載入歷史紀錄列表 +// 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 = '⏳ 正在載入歷史紀錄...'; + tbody.innerHTML = '⏳ 正在載入歷史紀錄...'; try { - const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=running`); + // 🌟 關鍵修改:將 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' && result.data.length > 0) { - tbody.innerHTML = result.data.map(item => ` - - ${new Date(item.timestamp).toLocaleString()} - ${item.snapshot_name} - ${item.config_type} - - - - - - - - - - `).join(''); + if (result.status === 'success') { + allBackupsData = result.data; + applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染 } else { - tbody.innerHTML = '尚無任何備份紀錄。'; + // 加入防呆:如果後端回傳 422 (detail),也能正確印出錯誤而不是 undefined + const errMsg = result.message || (result.detail ? JSON.stringify(result.detail) : '未知錯誤'); + tbody.innerHTML = `❌ 載入失敗: ${errMsg}`; } } catch (error) { - tbody.innerHTML = `❌ 載入失敗: ${error.message}`; + 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("載入快照中..."); @@ -916,8 +985,12 @@ async function viewBackup(backupId) { const result = await response.json(); if (result.status === 'success') { - const data = result.data; - const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`; + // 確保這行真的有加進去,且檔案有按下 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); @@ -953,72 +1026,172 @@ async function deleteBackup(backupId) { } // 5. 請求設備差異分析 (安全還原第一步) -async function restoreBackup(snapshotId, snapshotName) { - const connInfo = getGlobalConnectionInfo(); - if (!connInfo || !connInfo.host) { - return alert("請先連線至 CMTS 設備!"); +// 🌟 接收第三個參數 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 呼叫 } - // 開啟 Modal 顯示分析進度 const modal = document.getElementById('outputModal'); const modalOutput = document.getElementById('modalOutput'); const modalTargetInfo = document.getElementById('modalTargetInfo'); + // 1. 移除進度條,改為純文字百分比動態顯示 modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`; - modalOutput.innerHTML = `🔍 正在抓取設備當前配置,並與快照進行深度比對,請稍候...`; + 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 { - // 呼叫後端全新的 Diff API (我們接下來要實作的) 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 - }) + body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass }) }); const result = await response.json(); - - if (result.status === 'success') { - // 渲染差異預覽畫面 - let diffHtml = `
以下是將設備還原至 ${snapshotName} 所需執行的指令清單:
`; - - if (result.data.commands.length === 0) { - diffHtml += `
✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!
`; - } else { - diffHtml += `
`;
-                result.data.commands.forEach(cmd => {
-                    if (cmd.startsWith('no ')) {
-                        diffHtml += `- ${cmd}\n`; // 刪除用紅色
-                    } else {
-                        diffHtml += `+ ${cmd}\n`; // 新增/修改用綠色
-                    }
-                });
-                diffHtml += `
`; - - // 加入最終確認按鈕 - diffHtml += ` -
- -
- `; - } - modalOutput.innerHTML = diffHtml; - } else { - modalOutput.innerHTML = `❌ 差異分析失敗:\n${result.message}`; + + // 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} + + + + + + `; + + diffHtml += `
`;
+                    commands.forEach(cmd => {
+                        if (cmd.startsWith('no ')) diffHtml += `- ${cmd}\n`;
+                        else diffHtml += `+ ${cmd}\n`;
+                    });
+                    diffHtml += `
`; + + diffHtml += ``; + } + modalOutput.innerHTML = diffHtml; + } else { + modalOutput.innerHTML = `❌ 差異分析失敗:\n${result.message}`; + } + }, 500); + } catch (error) { + clearInterval(progressInterval); modalOutput.innerHTML = `❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}`; } } -// 6. 真正執行差異還原 (掛載到 window 供按鈕呼叫) -window.executeSmartRestore = async function(snapshotId, snapshotName) { - // 這裡將會呼叫我們原本設計好的寫入 API - alert("即將實作:將上方預覽的指令寫入設備!"); +// 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 = `
⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...
`; + 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 = ` +
✅ 還原成功!設備已 Commit。
+
設備回傳結果:
+
${result.output}
+ `; + document.getElementById('modalTargetInfo').innerHTML = `還原完成: ${snapshotName}`; + } else { + loadingMsg.innerHTML = `❌ 還原失敗:
${result.message}
`; + // 失敗的話,把按鈕恢復原狀,允許重試 + 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 = `❌ 系統錯誤:
${error.message}
`; + } }; // ============================================================================ @@ -1168,7 +1341,7 @@ window.scanGlobalMissingOptions = scanGlobalMissingOptions; window.clearVisibleCache = clearVisibleCache; window.clearGlobalCache = clearGlobalCache; window.previewNodeCLI = previewNodeCLI; -window.switchFilterMode = switchFilterMode; // 🌟 新增綁定 +window.switchFilterMode = switchFilterMode; // --- 樹狀圖展開與編輯操作 --- window.expandAll = expandAll; @@ -1192,10 +1365,5 @@ window.createSnapshot = createSnapshot; window.loadBackupHistory = loadBackupHistory; window.viewBackup = viewBackup; window.deleteBackup = deleteBackup; -window.restoreBackup = restoreBackup; // 🌟 新增綁定還原函數 - -// 將備份還原相關函數綁定到全域,供 HTML onclick 使用 -window.loadBackupHistory = loadBackupHistory; -window.createSnapshot = createSnapshot; -//window.restoreSnapshot = restoreSnapshot; -//window.deleteSnapshot = deleteSnapshot; \ No newline at end of file +window.restoreBackup = restoreBackup; +window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 \ No newline at end of file