diff --git a/index.html b/index.html index 6564bba..1b7628a 100644 --- a/index.html +++ b/index.html @@ -250,22 +250,54 @@
diff --git a/routers/config.py b/routers/config.py index 1545c8c..ab06af3 100644 --- a/routers/config.py +++ b/routers/config.py @@ -6,6 +6,7 @@ from typing import List from netmiko import ConnectHandler # 💡 確保從 shared 引入 deep_split_tree from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings +from collections import defaultdict router = APIRouter() @@ -15,6 +16,15 @@ class ConfigRequest(BaseModel): username: str password: str +# 定義前端傳來的 Diff 資料結構 +class DiffItem(BaseModel): + path: str # 例如: "cable.ds-rf-port.1:9/0.down-channel.0.frequency" + old_val: str + new_val: str + +class GenerateCliRequest(BaseModel): + diffs: List[DiffItem] + @router.post("/cmts-config") async def execute_cmts_config(req: ConfigRequest): # 使用非同步鎖,確保同一時間只有一個寫入任務執行 @@ -27,18 +37,107 @@ async def execute_cmts_config(req: ConfigRequest): commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')] net_connect = ConnectHandler(**device) + # 🌟 1. 手動進入設定模式 (恢復您原本正確的寫法) net_connect.send_command_timing("config") - # 批次送出設定指令 - output = net_connect.send_config_set(commands, cmd_verify=False) + # 🌟 2. 批次送出設定指令 + # 加上 enter_config_mode=False 與 exit_config_mode=False + # 這樣 Netmiko 就不會自作主張去打 config terminal 了 + output = net_connect.send_config_set( + commands, + read_timeout=60, + cmd_verify=False, + enter_config_mode=False, + exit_config_mode=False + ) + # 🌟 3. 手動退出設定模式 net_connect.send_command_timing("exit") net_connect.disconnect() + + # 🌟 新增:檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字 + lower_output = output.lower() + if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output: + # 回傳錯誤狀態,前端就會顯示紅色字體且「不會」自動關閉視窗 + return { + "status": "error", + "message": "CMTS 拒絕了設定 (Commit Aborted)", + "detail": output + } return {"status": "success", "data": output} except Exception as e: raise HTTPException(status_code=500, detail=f"CMTS Config Error: {str(e)}") + +@router.post("/cmts-generate-cli") +async def generate_cli(req: GenerateCliRequest): + """ + 將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本 + 並自動處理 admin-state 的安全生命週期 (先 down 後 up) + """ + # 用來將同一個介面的變更群組化 + # 結構: { "cable ds-rf-port 1:9/0 down-channel 0": [ ("frequency", "350000000"), ... ] } + interface_groups = defaultdict(list) + + # 用來記錄該介面最終的 admin-state 應該是什麼 (預設為不變,若有改動則更新) + interface_admin_states = {} + for diff in req.diffs: + # 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency'] + parts = diff.path.split('::') + + # 由於 Harmonic 的樹狀圖結構與 CLI 結構高度一致 + # 我們可以假設「最後一個元素」是參數名稱,「前面的所有元素」組合起來就是介面路徑 + param_name = parts[-1] + interface_path = " ".join(parts[:-1]) # 例如: "cable ds-rf-port 1:9/0 down-channel 0" + + if param_name == "admin-state": + # 如果使用者直接修改了 admin-state,我們記錄下來,作為最後的狀態 + interface_admin_states[interface_path] = diff.new_val + else: + # 一般參數變更,加入群組 + interface_groups[interface_path].append((param_name, diff.new_val)) + + # 開始組裝最終的 CLI 腳本 + cli_lines = [] + + # 為了視覺清晰,我們可以加上註解 (您的執行 API 已經會自動過濾 ! 開頭的行) + cli_lines.append("! --- Auto-Generated Configuration Script ---") + + for interface, changes in interface_groups.items(): + cli_lines.append(f"! Configuring: {interface}") + + # 1. 安全機制:先強制將介面 admin-state down + cli_lines.append(f"{interface} admin-state down") + + # 2. 寫入所有被修改的參數 + for param, new_val in changes: + if new_val == "": + # 處理清空數值的情況 (通常是加上 no) + cli_lines.append(f"no {interface} {param}") + else: + cli_lines.append(f"{interface} {param} {new_val}") + + # 3. 恢復狀態:如果使用者有特別設定 admin-state 就用使用者的,否則預設恢復為 up + final_state = interface_admin_states.get(interface, "up") + cli_lines.append(f"{interface} admin-state {final_state}") + # 🌟 新增:Harmonic 必須要有 commit 才會生效 + cli_lines.append("commit") + cli_lines.append("!") # 空行分隔 + + # 將獨立修改 admin-state (沒有修改其他參數) 的情況也補上 + for interface, state in interface_admin_states.items(): + if interface not in interface_groups: + cli_lines.append(f"! Configuring state only: {interface}") + cli_lines.append(f"{interface} admin-state {state}") + # 🌟 新增:Harmonic 必須要有 commit 才會生效 + cli_lines.append("commit") + cli_lines.append("!") + + # 將陣列組合成字串回傳 + final_script = "\n".join(cli_lines) + + return {"status": "success", "data": final_script} # ========================================== # 💡 以下為 DS RF Port 動態樹狀查詢 API diff --git a/routers/lock.py b/routers/lock.py index 174fe0b..986cd81 100644 --- a/routers/lock.py +++ b/routers/lock.py @@ -45,10 +45,10 @@ def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[d if target_path == locked_path: return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中" - if target_path.startswith(locked_path + "."): + if target_path.startswith(locked_path + "::"): return lock_info, f"父層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法編輯子區塊" - if locked_path.startswith(target_path + "."): + if locked_path.startswith(target_path + "::"): return lock_info, f"子層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法鎖定整個父區塊" return None, "" diff --git a/static/app.js b/static/app.js index 22cf44c..2cb24d0 100644 --- a/static/app.js +++ b/static/app.js @@ -7,6 +7,11 @@ let term, fitAddon, ws; let currentMacDomainData = null; let isConnected = false; +// 💡 新增:追蹤當前編輯狀態 +let currentEditPath = null; +let currentEditElementId = null; +let currentEditIsSuccess = false; + window.onload = initTerminal; window.onresize = () => { if (fitAddon) fitAddon.fit(); }; @@ -14,7 +19,7 @@ window.onresize = () => { if (fitAddon) fitAddon.fit(); }; // 💡 全域狀態與鎖定管理 (Lock Management) // ========================================== const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); -const ACTIVE_HEARTBEATS = {}; // 記錄正在打心跳的計時器: { "path": intervalId } +const ACTIVE_HEARTBEATS = {}; async function sendHeartbeat(path) { try { @@ -26,6 +31,24 @@ async function sendHeartbeat(path) { } catch (error) {} } +// 🎨 輔助函數:解析終端機輸出,僅將錯誤/警告行標紅 +function highlightTerminalOutput(text) { + if (!text) return ''; + + // 將多行字串拆分成陣列,逐行檢查 + const lines = text.split('\n'); + const formattedLines = lines.map(line => { + // 偵測常見的錯誤與警告關鍵字 (不分大小寫) + // 您可以隨時在這裡擴充設備專屬的錯誤代碼,例如 '% Invalid' + if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) { + return `${line}`; + } + return line; // 沒有關鍵字的行,維持預設顏色 (白色) + }); + + return formattedLines.join('\n'); +} + // ========================================== // 💡 資料夾層級的編輯模式邏輯 (Node-Level Lock) // ========================================== @@ -43,20 +66,17 @@ async function startEditFolder(path, elementId) { const result = await response.json(); if (response.status === 409) { - alert(`⚠️ ${result.detail}`); // 後端擋下衝突時顯示警告 + alert(`⚠️ ${result.detail}`); return; } if (result.status === 'success') { - // 啟動心跳 ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000); - // UI 切換 document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; document.getElementById(`details-${elementId}`).open = true; - // 轉為輸入框 const contentDiv = document.getElementById(`content-${elementId}`); const leafContainers = contentDiv.querySelectorAll('.leaf-container'); @@ -102,40 +122,259 @@ async function cancelEditFolder(path, elementId) { }); } -function previewFolderCLI(path, elementId) { +// 🚀 Phase 3: 收集修改並呼叫後端轉譯 API +async function previewFolderCLI(path, elementId) { + // 💡 記錄當前正在編輯的節點資訊 + currentEditPath = path; + currentEditElementId = elementId; + currentEditIsSuccess = false; // 預設為尚未成功 const contentDiv = document.getElementById(`content-${elementId}`); + if (!contentDiv) return; + const inputs = contentDiv.querySelectorAll('.edit-input'); - - let changes = []; - - // 掃描所有輸入框,找出被修改過的值 + const diffs = []; + inputs.forEach(input => { - const nodePath = input.getAttribute('data-path'); - const newVal = input.value; - const origVal = input.parentElement.getAttribute('data-original'); + const container = input.closest('.leaf-container'); + if (!container) return; + + const originalVal = container.getAttribute('data-original') || ""; + const newVal = input.value.trim(); + const nodePath = container.getAttribute('data-path'); - if (newVal !== origVal) { - changes.push(`- ${nodePath}:\n [舊] ${origVal} ➡️ [新] ${newVal}`); + if (originalVal !== newVal && nodePath) { + diffs.push({ + path: nodePath, + old_val: originalVal, + new_val: newVal + }); } }); - - if (changes.length === 0) { - alert("您沒有修改任何數值!"); + + if (diffs.length === 0) { + alert("沒有偵測到任何修改,無需生成指令!"); return; } + + try { + const response = await fetch('/api/v1/cmts-generate-cli', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ diffs: diffs }) + }); + + const result = await response.json(); + + if (result.status === 'success') { + showCliPreviewModal(result.data); + } else { + alert("CLI 生成失敗: " + result.message); + } + } catch (error) { + console.error("Error generating CLI:", error); + alert("網路連線錯誤,無法生成 CLI"); + } +} + +// 🚀 Phase 3: 顯示側邊預覽區塊 (初始化為「預覽模式」) +function showCliPreviewModal(cliScript) { + const leftPane = document.getElementById('tree-container'); + const resizer = document.getElementById('drag-resizer'); + const rightPane = document.getElementById('side-cli-preview'); - alert(`【Phase 3 預覽】\n鎖定區塊: ${path}\n\n偵測到的修改:\n${changes.join('\n\n')}\n\n即將實作轉譯為 CMTS CLI 的功能!`); + // --- UI 狀態重置為「預覽模式」 --- + document.getElementById('side-pane-title').innerHTML = '⚠️ 即將寫入的指令'; + document.getElementById('side-pane-title').style.color = '#f1c40f'; + + document.getElementById('btn-side-cancel').style.display = 'inline-block'; + document.getElementById('btn-side-confirm').style.display = 'inline-block'; + document.getElementById('btn-side-close').style.display = 'none'; + + document.getElementById('side-cli-textarea').style.display = 'block'; + document.getElementById('side-execution-result').style.display = 'none'; + + // 填入指令 + document.getElementById('side-cli-textarea').value = cliScript; + + // 設定初始 50/50 比例 + leftPane.style.width = 'calc(50% - 11px)'; + rightPane.style.width = 'calc(50% - 11px)'; + + resizer.style.display = 'flex'; + rightPane.style.display = 'block'; + + if (!window.isResizerInitialized) { + initResizer(); + window.isResizerInitialized = true; + } +} + +// ❌ 隱藏側邊預覽區塊 (恢復左側 100% 寬度) +function hideSideCLI() { + document.getElementById('tree-container').style.width = '100%'; + document.getElementById('drag-resizer').style.display = 'none'; + document.getElementById('side-cli-preview').style.display = 'none'; + + // 💡 判斷是否需要更新左側樹狀圖 + if (currentEditIsSuccess && currentEditElementId && currentEditPath) { + applyEditFolder(currentEditPath, currentEditElementId); + } + + // 重置狀態,等待下一次編輯 + currentEditPath = null; + currentEditElementId = null; + currentEditIsSuccess = false; +} + +// 💡 新增:套用修改並解除鎖定 +async function applyEditFolder(path, elementId) { + const contentDiv = document.getElementById(`content-${elementId}`); + if (contentDiv) { + const inputs = contentDiv.querySelectorAll('.edit-input'); + inputs.forEach(input => { + const container = input.closest('.leaf-container'); + if (container) { + const newVal = input.value.trim(); + // 關鍵:將新值寫入 data-original + // 這樣稍後呼叫 cancelEditFolder 時,就會用這個新值來還原畫面 + container.setAttribute('data-original', newVal); + } + }); + } + + // 呼叫現有的 cancelEditFolder 來解除後端鎖定,並將輸入框變回純文字 + await cancelEditFolder(path, elementId); +} + +// 🚀 執行側邊區塊的指令 (切換為「執行模式」) +function executeSideCLI() { + const finalScript = document.getElementById('side-cli-textarea').value; + + // --- UI 狀態切換為「執行結果模式」 --- + document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...'; + document.getElementById('side-pane-title').style.color = '#3498db'; + + document.getElementById('btn-side-cancel').style.display = 'none'; + document.getElementById('btn-side-confirm').style.display = 'none'; + document.getElementById('btn-side-close').style.display = 'none'; + + document.getElementById('side-cli-textarea').style.display = 'none'; + const resultBox = document.getElementById('side-execution-result'); + resultBox.style.display = 'block'; + + // 💡 移除原本多餘的提示,直接以純白色顯示正在執行的腳本 + resultBox.innerHTML = `${finalScript}`; + + // 呼叫 API + executeGeneratedCLI(finalScript); +} + +// 🖱️ 實作左右拖曳調整寬度引擎 +function initResizer() { + const resizer = document.getElementById('drag-resizer'); + const leftPane = document.getElementById('tree-container'); + const rightPane = document.getElementById('side-cli-preview'); + const container = document.getElementById('split-container'); + + let isResizing = false; + + // 滑鼠按下分隔線:開始拖曳 + resizer.addEventListener('mousedown', (e) => { + isResizing = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; // 防止拖曳時意外反白文字 + }); + + // 滑鼠移動:動態計算寬度 + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + + const containerRect = container.getBoundingClientRect(); + let newLeftWidth = e.clientX - containerRect.left; + + // 限制左右面板的最小/最大寬度 (20% ~ 80%),防止拉過頭 + const minWidth = containerRect.width * 0.2; + const maxWidth = containerRect.width * 0.8; + if (newLeftWidth < minWidth) newLeftWidth = minWidth; + if (newLeftWidth > maxWidth) newLeftWidth = maxWidth; + + // 轉換為百分比 + const leftPercentage = (newLeftWidth / containerRect.width) * 100; + const rightPercentage = 100 - leftPercentage; + + // 即時更新左右面板寬度 + leftPane.style.width = `calc(${leftPercentage}% - 11px)`; + rightPane.style.width = `calc(${rightPercentage}% - 11px)`; + }); + + // 滑鼠放開:結束拖曳 + document.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; // 恢復文字反白功能 + } + }); +} + +// 🚀 Phase 3: 將腳本發送給設備執行 (輸出至側邊欄) +async function executeGeneratedCLI(script) { + const connInfo = getGlobalConnectionInfo(); + if (!connInfo) return; + + const outputEl = document.getElementById('side-execution-result'); + + try { + const response = await fetch('/api/v1/cmts-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + script: script, + host: connInfo.host, + username: connInfo.user, + password: connInfo.pass + }) + }); + + const result = await response.json(); + + // 執行完畢,顯示「完成並關閉」按鈕 + document.getElementById('btn-side-close').style.display = 'inline-block'; + + if (result.status === 'success') { + currentEditIsSuccess = true; // 💡 標記執行成功 + + document.getElementById('side-pane-title').innerHTML = '✅ 執行成功'; + document.getElementById('side-pane-title').style.color = '#2ecc71'; + + // 💡 成功時,移除內部提示,直接顯示純淨的白色 Log + outputEl.innerHTML = `${result.data}`; + } else { + currentEditIsSuccess = false; // 💡 標記執行失敗 + + document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗'; + document.getElementById('side-pane-title').style.color = '#ff6b6b'; + + // 💡 失敗時,移除內部提示,並透過輔助函數「僅針對錯誤行」進行紅色高亮 + const rawLog = result.detail || result.message || ''; + outputEl.innerHTML = `${highlightTerminalOutput(rawLog)}`; + } + } catch (error) { + document.getElementById('btn-side-close').style.display = 'inline-block'; + document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤'; + document.getElementById('side-pane-title').style.color = '#ff6b6b'; + + outputEl.innerHTML = `連線或伺服器錯誤: ${error}`; + } } // ========================================== -// 💡 編輯模式 UI 切換邏輯 +// 💡 編輯模式 UI 切換邏輯 (保留舊版單一節點編輯用) // ========================================== function renderEditModeUI(path, currentValue, elementId) { const container = document.getElementById(elementId); if (!container) return; - // 將原本的文字替換為 Input 與操作按鈕 - // 注意:這裡將單引號跳脫,避免 HTML 屬性解析錯誤 const safeValue = currentValue.replace(/'/g, "'"); container.innerHTML = ` @@ -158,7 +397,6 @@ function restoreViewModeUI(path, originalValue, elementId) { const container = document.getElementById(elementId); if (!container) return; - // 恢復成原本的文字與編輯按鈕 const safeValue = originalValue.replace(/'/g, "'"); container.innerHTML = ` ${originalValue} @@ -168,7 +406,6 @@ function restoreViewModeUI(path, originalValue, elementId) { `; } -// Phase 3 的預留函數:預覽 CLI 指令 function previewCLI(path, elementId) { const newValue = document.getElementById(`input-${elementId}`).value; alert(`【Phase 3 預覽】\n路徑: ${path}\n新值: ${newValue}\n\n即將實作轉譯為 CMTS CLI 的功能!`); @@ -208,21 +445,17 @@ function switchConfigTask() { } } -// 💡 合併修復:統一處理「載入任務」的邏輯與防呆 function startConfigTask() { - // 防呆 1:檢查是否已連線 if (!isConnected) { alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!"); return; } - // 先確保對應的表單有顯示出來 switchConfigTask(); const selectedTaskId = document.getElementById('configTask').value; if (selectedTaskId === 'form-bonding-config') { - // 任務 A:MAC Domain 配置精靈 if (currentMacDomainData && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) { return; } @@ -230,8 +463,6 @@ function startConfigTask() { loadMacDomainList(); } else if (selectedTaskId === 'form-full-config') { - // 💡 修復:任務 B:完整設備配置樹狀圖 - // 當使用者點擊「載入任務」時,直接幫他觸發抓取樹狀圖的 API fetchFullConfig(); } } @@ -758,16 +989,25 @@ function buildTree(data, parentPath = "") { let html = '