feat: Smart Diff Recovery & Preview

This commit is contained in:
swpa 2026-05-28 10:24:17 +08:00
parent eb8c3af98e
commit 77821fc620
3 changed files with 282 additions and 55 deletions

View File

@ -1,7 +1,10 @@
import logging import logging
import asyncssh
import asyncio
import re
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
# 引入 DB 函數 # 引入 DB 函數
from database import ( from database import (
@ -11,8 +14,8 @@ from database import (
delete_config_backup delete_config_backup
) )
# ✅ 修正:引入我們剛剛在 cmts_scraper.py 新增的函數 from cmts_scraper import fetch_raw_config
from cmts_scraper import fetch_raw_config, parse_config_to_tree from shared import parse_cli_to_tree # <== 引入真正的解析器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,6 +24,9 @@ router = APIRouter(
tags=["Backups"] tags=["Backups"]
) )
# ==========================================
# 📦 Pydantic Models
# ==========================================
class SnapshotRequest(BaseModel): class SnapshotRequest(BaseModel):
host: str host: str
username: str username: str
@ -28,6 +34,91 @@ class SnapshotRequest(BaseModel):
snapshot_name: str snapshot_name: str
config_type: str = "running" config_type: str = "running"
class RestoreRequest(BaseModel):
host: str
username: str
password: str
class ExecuteRestoreRequest(BaseModel):
host: str
username: str
password: str
commands: List[str] # 接收前端確認後的差異指令清單
# ==========================================
# 🛠️ 核心演算法:深度差異比對 (Deep Diff)
# ==========================================
def build_add_commands(node: dict, current_path: str) -> list:
"""遞迴展開所有需要新增的絕對路徑指令"""
commands = []
if not node:
commands.append(current_path)
return commands
for key, val in node.items():
# 🌟 新增這行:忽略系統中介資料
if key.startswith('_'):
continue
path = f"{current_path} {key}".strip()
if isinstance(val, dict):
commands.extend(build_add_commands(val, path))
else:
val_str = f" {val}" if val else ""
commands.append(f"{path}{val_str}")
return commands
def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list:
"""比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
commands = []
# 1. 找出需要刪除或覆蓋的 (存在於 current)
for key, curr_val in current_node.items():
# 🌟 新增這行:忽略系統中介資料
if key.startswith('_'):
continue
path = f"{current_path} {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))
else:
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()
if key not in current_node:
if isinstance(snap_val, dict):
commands.extend(build_add_commands(snap_val, path))
else:
val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}")
return commands
# ==========================================
# 🚀 API 路由
# ==========================================
@router.get("/", summary="取得歷史快照列表") @router.get("/", summary="取得歷史快照列表")
async def list_backups(host: str, config_type: str = "running"): async def list_backups(host: str, config_type: str = "running"):
records = await get_config_backup_list(host, config_type) records = await get_config_backup_list(host, config_type)
@ -52,11 +143,9 @@ async def delete_backup(backup_id: str):
@router.post("/snapshot", summary="手動建立設備快照") @router.post("/snapshot", summary="手動建立設備快照")
async def create_snapshot(req: SnapshotRequest): async def create_snapshot(req: SnapshotRequest):
try: try:
# ✅ 修正:使用函數式呼叫來抓取與解析設定檔
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
parsed_tree = parse_config_to_tree(raw_cli) parsed_tree = parse_cli_to_tree(raw_cli)
# 寫入資料庫
backup_id = await insert_config_backup( backup_id = await insert_config_backup(
host=req.host, host=req.host,
config_type=req.config_type, config_type=req.config_type,
@ -80,3 +169,93 @@ async def create_snapshot(req: SnapshotRequest):
logger.error(f"❌ 建立快照失敗: {e}") logger.error(f"❌ 建立快照失敗: {e}")
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"} return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
try:
backup_record = await get_config_backup_detail(backup_id)
if not backup_record:
return {"status": "error", "message": "找不到指定的備份紀錄"}
snapshot_tree = backup_record.get("parsed_tree")
if not snapshot_tree:
return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
raw_current = await fetch_raw_config(req.host, req.username, req.password, "running")
if not raw_current:
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
current_tree = parse_cli_to_tree(raw_current)
diff_commands = generate_diff_commands(current_tree, snapshot_tree)
return {
"status": "success",
"data": {
"commands": diff_commands
}
}
except Exception as e:
logger.error(f"差異分析失敗: {str(e)}")
return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"}
@router.post("/{backup_id}/restore", summary="執行差異還原 (寫入指定的指令清單)")
async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
commands = req.commands
if not commands:
return {"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}
output_log = []
try:
conn = await asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
async def read_until_quiet(timeout=1.0):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
# 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5)
# 逐行寫入精準的差異指令
for cmd in commands:
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
out = await read_until_quiet(timeout=0.2)
output_log.append(out)
# 提交變更
process.stdin.write("commit\n")
await process.stdin.drain()
commit_out = await read_until_quiet(timeout=2.0)
output_log.append(commit_out)
# 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
conn.close()
clean_log = re.sub(r'\x1b\[[0-9;]*[mGK]', '', "".join(output_log))
return {
"status": "success",
"message": "差異還原指令已成功送出並 Commit",
"output": clean_log
}
except Exception as e:
logger.error(f"還原執行失敗: {str(e)}")
return {"status": "error", "message": f"SSH 執行失敗: {str(e)}"}

View File

@ -864,8 +864,12 @@ async function loadBackupHistory() {
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td> <td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</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; 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;"> <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="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁 檢視</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">刪除</button>
<!-- 🌟 新增還原按鈕 -->
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}')" 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> </td>
</tr> </tr>
`).join(''); `).join('');
@ -924,6 +928,75 @@ async function deleteBackup(backupId) {
} }
} }
// 5. 請求設備差異分析 (安全還原第一步)
async function restoreBackup(snapshotId, snapshotName) {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) {
return alert("請先連線至 CMTS 設備!");
}
// 開啟 Modal 顯示分析進度
const modal = document.getElementById('outputModal');
const modalOutput = document.getElementById('modalOutput');
const modalTargetInfo = document.getElementById('modalTargetInfo');
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
modalOutput.innerHTML = `<span style="color: #3498db;">🔍 正在抓取設備當前配置,並與快照進行深度比對,請稍候...</span>`;
modal.classList.add('active');
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
})
});
const result = await response.json();
if (result.status === 'success') {
// 渲染差異預覽畫面
let diffHtml = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單:</div>`;
if (result.data.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 {
diffHtml += `<pre style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto;">`;
result.data.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 += `
<div style="margin-top: 20px; text-align: right;">
<button onclick="executeSmartRestore('${snapshotId}', '${snapshotName}')" class="btn-modern btn-save" style="background-color: #e74c3c; padding: 8px 16px;"> 確認無誤立即寫入設備並 Commit</button>
</div>
`;
}
modalOutput.innerHTML = diffHtml;
} else {
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗:\n${result.message}</span>`;
}
} catch (error) {
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`;
}
}
// 6. 真正執行差異還原 (掛載到 window 供按鈕呼叫)
window.executeSmartRestore = async function(snapshotId, snapshotName) {
// 這裡將會呼叫我們原本設計好的寫入 API
alert("即將實作:將上方預覽的指令寫入設備!");
};
// ============================================================================ // ============================================================================
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States) // 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
// ============================================================================ // ============================================================================
@ -1095,6 +1168,7 @@ window.createSnapshot = createSnapshot;
window.loadBackupHistory = loadBackupHistory; window.loadBackupHistory = loadBackupHistory;
window.viewBackup = viewBackup; window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup; window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup; // 🌟 新增綁定還原函數
// 將備份還原相關函數綁定到全域,供 HTML onclick 使用 // 將備份還原相關函數綁定到全域,供 HTML onclick 使用
window.loadBackupHistory = loadBackupHistory; window.loadBackupHistory = loadBackupHistory;

View File

@ -3,7 +3,7 @@
import { import {
apiAcquireLock, apiReleaseLock, apiSendHeartbeat, apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig, apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
apiSyncLeafOptionsStream // 🌟 新增這一個 apiSyncLeafOptionsStream
} from './api.js'; } from './api.js';
import { getGlobalConnectionInfo } from './utils.js'; import { getGlobalConnectionInfo } from './utils.js';
@ -24,19 +24,17 @@ function escapeHTML(str) {
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {}; const ACTIVE_HEARTBEATS = {};
// 💡 追蹤當前編輯狀態
export let currentEditPath = null; export let currentEditPath = null;
export let currentEditElementId = null; export let currentEditElementId = null;
export let currentEditIsSuccess = false; export let currentEditIsSuccess = false;
export let currentEditType = null; export let currentEditType = null;
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑 export let currentEditDiffs = [];
export async function releaseAllLocks() { export async function releaseAllLocks() {
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS); const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (keysToRelease.length === 0) return; if (keysToRelease.length === 0) return;
const releasePromises = keysToRelease.map(key => { const releasePromises = keysToRelease.map(key => {
// 🌟 解析出 host 與 path
const [host, path] = key.split('@@'); const [host, path] = key.split('@@');
clearInterval(ACTIVE_HEARTBEATS[key]); clearInterval(ACTIVE_HEARTBEATS[key]);
delete ACTIVE_HEARTBEATS[key]; delete ACTIVE_HEARTBEATS[key];
@ -49,7 +47,6 @@ export async function releaseAllLocks() {
location.reload(); location.reload();
} }
// 🌟 加入 host 與 lockKey 參數
async function sendHeartbeat(path, host, intervalId, lockKey) { async function sendHeartbeat(path, host, intervalId, lockKey) {
try { try {
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host); const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
@ -74,7 +71,7 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
export async function startEditFolder(path, elementId) { export async function startEditFolder(path, elementId) {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : ''; // 🌟 取得設備 IP const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
@ -89,7 +86,6 @@ export async function startEditFolder(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId; ACTIVE_HEARTBEATS[lockKey] = intervalId;
// 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
const editBtn = document.getElementById(`edit-btn-${elementId}`); const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) editBtn.style.display = 'none'; if (editBtn) editBtn.style.display = 'none';
@ -102,21 +98,17 @@ export async function startEditFolder(path, elementId) {
const contentDiv = document.getElementById(`content-${elementId}`); const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) { if (!contentDiv) {
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`); console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯 return;
} }
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {}; let optData = {};
// 🌟 修改 1傳入 host 給 apiGetLeafOptions
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {} try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
const pathsToSync = []; const pathsToSync = [];
// ==========================================
// 🌟 效能急救:分批渲染 (Chunking) 避免卡死主執行緒
// ==========================================
const containersArray = Array.from(leafContainers); const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50; // 每次處理 50 個,確保畫面不結凍 const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
const chunk = containersArray.slice(i, i + CHUNK_SIZE); const chunk = containersArray.slice(i, i + CHUNK_SIZE);
@ -159,12 +151,10 @@ export async function startEditFolder(path, elementId) {
container.innerHTML = inputHtml; container.innerHTML = inputHtml;
}); });
// 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
} }
// 🌟 傳入 host 給 apiSyncLeafOptions if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
} }
} catch (error) { } catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
@ -199,19 +189,16 @@ export async function startEditLeaf(path, elementId) {
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`; container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try { try {
// 🌟 修改 1傳入 host
let optData = await apiGetLeafOptions(host, currentMode); let optData = await apiGetLeafOptions(host, currentMode);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey]; let leafCache = optData[cacheKey];
if (!leafCache) { if (!leafCache) {
// 🌟 修改 2傳入 host apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
apiSyncLeafOptions(host, [cacheKey], currentMode);
let found = false; let found = false;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
// 🌟 修改 3傳入 host
optData = await apiGetLeafOptions(host, currentMode); optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey]; leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) { if (leafCache && leafCache.options && leafCache.options.length > 0) {
@ -301,7 +288,6 @@ export async function cancelEditLeaf(path, elementId) {
// 3. CLI 生成與預覽 (CLI Generation) // 3. CLI 生成與預覽 (CLI Generation)
// ========================================== // ==========================================
function getInterfacesWithAdminState() { function getInterfacesWithAdminState() {
// 🌟 限制只從當前顯示的樹狀圖中抓取狀態
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`); const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (!activeContainer) return []; if (!activeContainer) return [];
@ -343,7 +329,7 @@ export async function previewFolderCLI(path, elementId) {
}); });
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!"); if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異 currentEditDiffs = diffs;
try { try {
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
@ -375,7 +361,7 @@ export async function previewLeafCLI(path, elementId) {
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!"); if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }]; const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異 currentEditDiffs = diffs;
try { try {
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
@ -390,7 +376,6 @@ export async function previewLeafCLI(path, elementId) {
} }
function showCliPreviewModal(cliScript) { function showCliPreviewModal(cliScript) {
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance'); const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer'); const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview'); const rightPane = document.getElementById('side-cli-preview');
@ -407,12 +392,10 @@ function showCliPreviewModal(cliScript) {
const textarea = document.getElementById('side-cli-textarea'); const textarea = document.getElementById('side-cli-textarea');
textarea.value = cliScript; textarea.value = cliScript;
// 👇 確保正常模式可以編輯,並明確恢復深色主題
textarea.readOnly = false; textarea.readOnly = false;
textarea.style.backgroundColor = '#1e1e1e'; // 恢復成標準的深色背景 (您可以依喜好微調顏色碼) textarea.style.backgroundColor = '#1e1e1e';
textarea.style.color = '#ecf0f1'; // 確保字體是清晰的淺灰色 textarea.style.color = '#ecf0f1';
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)'); leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
rightPane.style.width = 'calc(50% - 11px)'; rightPane.style.width = 'calc(50% - 11px)';
resizer.style.display = 'flex'; resizer.style.display = 'flex';
@ -428,7 +411,6 @@ function showCliPreviewModal(cliScript) {
// 4. 側邊欄與執行邏輯 // 4. 側邊欄與執行邏輯
// ========================================== // ==========================================
export async function hideSideCLI() { export async function hideSideCLI() {
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%'); document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%');
document.getElementById('drag-resizer').style.display = 'none'; document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none'; document.getElementById('side-cli-preview').style.display = 'none';
@ -517,23 +499,29 @@ async function executeGeneratedCLI(script) {
const uniquePaths = [...new Set(pathsToSync)]; const uniquePaths = [...new Set(pathsToSync)];
try { try {
// 🌟 修改:取得當前模式,並將 connInfo.host 與 currentMode 傳給 apiSyncLeafOptionsStream
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);
await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => { await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
if (data.event === 'done') { if (data.event === 'done') {
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成'; document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成';
document.getElementById('side-pane-title').style.color = '#2ecc71'; document.getElementById('side-pane-title').style.color = '#2ecc71';
document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`; outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
} else if (data.event === 'error') { } else if (data.event === 'error') {
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)'; document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
document.getElementById('side-pane-title').style.color = '#e74c3c'; document.getElementById('side-pane-title').style.color = '#e74c3c';
document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message}</span>`; outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
} }
}, currentMode); }, currentMode);
} catch (syncErr) { } catch (syncErr) {
console.error("同步設定失敗:", syncErr);
document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('btn-side-close').style.display = 'inline-block';
} }
@ -558,7 +546,6 @@ async function executeGeneratedCLI(script) {
// ========================================== // ==========================================
function initResizer() { function initResizer() {
const resizer = document.getElementById('drag-resizer'); const resizer = document.getElementById('drag-resizer');
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance'); const leftPanes = document.querySelectorAll('.tree-view-instance');
const rightPane = document.getElementById('side-cli-preview'); const rightPane = document.getElementById('side-cli-preview');
const container = document.getElementById('split-container'); const container = document.getElementById('split-container');
@ -581,7 +568,6 @@ function initResizer() {
const leftPercentage = (newLeftWidth / containerRect.width) * 100; const leftPercentage = (newLeftWidth / containerRect.width) * 100;
const rightPercentage = 100 - leftPercentage; const rightPercentage = 100 - leftPercentage;
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`); leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
rightPane.style.width = `calc(${rightPercentage}% - 11px)`; rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
}); });
@ -639,11 +625,9 @@ function transformNoToActive(elementId, newCommand) {
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式) // 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
// ============================================================================ // ============================================================================
export async function previewNodeCLI(btnElement, rootPath) { export async function previewNodeCLI(btnElement, rootPath) {
// 1. 定位當前點擊的容器
let container = btnElement.closest('details'); let container = btnElement.closest('details');
if (container) { if (container) {
const elementId = container.id.replace('details-', ''); const elementId = container.id.replace('details-', '');
// 確保延遲渲染的內容已經載入 (Lazy Load)
if (container.dataset.loaded !== 'true') { if (container.dataset.loaded !== 'true') {
await window.lazyLoadFolder(elementId, false); await window.lazyLoadFolder(elementId, false);
} }
@ -651,7 +635,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
const targetNode = container || btnElement.closest('.tree-leaf-node'); const targetNode = container || btnElement.closest('.tree-leaf-node');
if (!targetNode) return; if (!targetNode) return;
// 2. 收集所有子節點資料
const leafNodes = targetNode.querySelectorAll('.leaf-container'); const leafNodes = targetNode.querySelectorAll('.leaf-container');
const interfaceGroups = {}; const interfaceGroups = {};
@ -662,7 +645,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
const parts = path.split('::'); const parts = path.split('::');
const paramName = parts.pop(); const paramName = parts.pop();
// 移除虛擬陣列索引 [0], [1] 等,還原真實介面路徑
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, ''); let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
if (!interfaceGroups[interfacePath]) { if (!interfaceGroups[interfacePath]) {
@ -671,7 +653,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
interfaceGroups[interfacePath].push({ param: paramName, val: val }); interfaceGroups[interfacePath].push({ param: paramName, val: val });
}); });
// 補強邏輯:處理使用者直接點擊葉節點的情況
if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) { if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
const singleNode = targetNode.querySelector('.leaf-container'); const singleNode = targetNode.querySelector('.leaf-container');
if (singleNode) { if (singleNode) {
@ -688,22 +669,18 @@ export async function previewNodeCLI(btnElement, rootPath) {
return alert("此節點下沒有任何數值可供預覽!"); return alert("此節點下沒有任何數值可供預覽!");
} }
// 3. 在前端組裝平坦化 CLI (模擬真實設備指令)
let cliLines = []; let cliLines = [];
cliLines.push("! --- Read-Only Configuration Preview ---"); cliLines.push("! --- Read-Only Configuration Preview ---");
for (const [interfacePath, params] of Object.entries(interfaceGroups)) { for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
cliLines.push(`! Node: ${interfacePath || 'Global'}`); cliLines.push(`! Node: ${interfacePath || 'Global'}`);
params.forEach(({param, val}) => { params.forEach(({param, val}) => {
if (/^\[\d+\]$/.test(param)) { if (/^\[\d+\]$/.test(param)) {
// 陣列元素本身就是值
if (val) { if (val) {
cliLines.push(interfacePath ? `${interfacePath} ${val}` : val); cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
} }
} else if (param === 'no') { } else if (param === 'no') {
// 處理 no 指令
cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`); cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
} else { } else {
// 一般鍵值對 (若 val 為空,代表是單純的 flag 指令)
let line = interfacePath ? `${interfacePath} ${param}` : param; let line = interfacePath ? `${interfacePath} ${param}` : param;
if (val) line += ` ${val}`; if (val) line += ` ${val}`;
cliLines.push(line); cliLines.push(line);
@ -713,11 +690,9 @@ export async function previewNodeCLI(btnElement, rootPath) {
} }
const finalScript = cliLines.join('\n'); const finalScript = cliLines.join('\n');
// 4. 顯示唯讀預覽面板
showReadOnlyCliPreviewModal(finalScript, rootPath); showReadOnlyCliPreviewModal(finalScript, rootPath);
} }
// 專為 God-Mode 設計的唯讀面板 UI
function showReadOnlyCliPreviewModal(cliScript, rootPath) { function showReadOnlyCliPreviewModal(cliScript, rootPath) {
const leftPanes = document.querySelectorAll('.tree-view-instance'); const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer'); const resizer = document.getElementById('drag-resizer');
@ -726,7 +701,6 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) {
document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`; document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
document.getElementById('side-pane-title').style.color = '#3498db'; document.getElementById('side-pane-title').style.color = '#3498db';
// 隱藏執行按鈕,只保留關閉
document.getElementById('btn-side-confirm').style.display = 'none'; document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-cancel').style.display = 'none'; document.getElementById('btn-side-cancel').style.display = 'none';
const closeBtn = document.getElementById('btn-side-close'); const closeBtn = document.getElementById('btn-side-close');
@ -736,8 +710,8 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) {
const textarea = document.getElementById('side-cli-textarea'); const textarea = document.getElementById('side-cli-textarea');
textarea.style.display = 'block'; textarea.style.display = 'block';
textarea.value = cliScript; textarea.value = cliScript;
textarea.readOnly = true; // 強制唯讀 textarea.readOnly = true;
textarea.style.backgroundColor = '#2c3e50'; // 改變背景色提示唯讀狀態 textarea.style.backgroundColor = '#2c3e50';
document.getElementById('side-execution-result').style.display = 'none'; document.getElementById('side-execution-result').style.display = 'none';