From 098b7a831f9380044f7b9890056239455223b763 Mon Sep 17 00:00:00 2001 From: swpa Date: Wed, 13 May 2026 10:32:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20isNoCommand=E8=AE=80=E5=8F=96=E5=AF=AB?= =?UTF-8?q?=E5=85=A5=E9=82=8F=E8=BC=AF=E9=87=8D=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmts_leaf_options_cache.json | 66 +++++++++ shared.py | 58 ++++++-- static/app.js | 275 +++++++++++++++++++++++++++-------- system_settings.json | 5 +- 4 files changed, 333 insertions(+), 71 deletions(-) diff --git a/cmts_leaf_options_cache.json b/cmts_leaf_options_cache.json index 489057f..fd4f67b 100644 --- a/cmts_leaf_options_cache.json +++ b/cmts_leaf_options_cache.json @@ -99028,5 +99028,71 @@ "type": "string_or_number", "current_value": null, "updated_at": 1778489033 + }, + "cable mac-domain 13:0/0.0 shared-secret": { + "hint": "Description: shared-secret {0 | 7}\nPossible completions:\n[7] ", + "options": [], + "type": "string_or_number", + "current_value": "7", + "updated_at": 1778495587 + }, + "network link-holdoff-time-ms": { + "hint": "Description: Link bring-up delay in milliseconds\nPossible completions:\n[0]", + "options": [], + "type": "string_or_number", + "current_value": "0", + "updated_at": 1778566706 + }, + "network l3-toward-access": { + "hint": "Description: Enables/disables L3 mode\nPossible completions:\n[disabled] disabled enabled", + "options": [ + "disabled", + "enabled" + ], + "type": "list", + "current_value": "disabled", + "updated_at": 1778566706 + }, + "logging buffered": { + "hint": "Possible completions:\nseverity size-mb", + "options": [], + "type": "string_or_number", + "current_value": null, + "updated_at": 1778570349 + }, + "cable rip": { + "hint": "Possible completions:\nallow-subnet Allowed subnets for RIP routes\nauthentication", + "options": [], + "type": "string_or_number", + "current_value": null, + "updated_at": 1778570650 + }, + "cable bundle 1 nsi start-ipv6-address": { + "hint": "Description: Starting address within ipv6 subnet on the CRE for the bundle nsi subnet\nPossible completions:\n[2001:50:5:11::7]", + "options": [], + "type": "string_or_number", + "current_value": "2001:50:5:11::7", + "updated_at": 1778582997 + }, + "cable bundle 1 nsi cre-ipv4-address": { + "hint": "Description: CRE gateway IP address for the bundle nsi subnet\nPossible completions:\n[10.14.11.1/24]", + "options": [], + "type": "string_or_number", + "current_value": "10.14.11.1/24", + "updated_at": 1778582997 + }, + "cable bundle 1 nsi cre-ipv6-address": { + "hint": "Description: CRE gateway IPv6 address for the bundle nsi subnet\nPossible completions:\n[2001:50:5:11::1/64]", + "options": [], + "type": "string_or_number", + "current_value": "2001:50:5:11::1/64", + "updated_at": 1778582997 + }, + "cable bundle 1 nsi pool-count": { + "hint": "Description: Number of IP addresses starting from \"start-ipv4-addr/start-ipv6-addr\" used as nsi\nPossible completions:\n[10]", + "options": [], + "type": "string_or_number", + "current_value": "10", + "updated_at": 1778582997 } } \ No newline at end of file diff --git a/shared.py b/shared.py index 62e28f2..7e6f82f 100644 --- a/shared.py +++ b/shared.py @@ -26,10 +26,45 @@ def parse_cli_to_tree(cli_text: str) -> dict: """ 將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法) """ - lines = [line.rstrip() for line in cli_text.splitlines() if line.strip()] + # ========================================== + # 🌟 階段 0:預處理 (修復終端機 200 字元自動折行問題) + # ========================================== + raw_lines = cli_text.split('\n') + fixed_lines = [] + + # 定義常見的 Root 指令關鍵字,避免把正常的指令誤判為斷行 + root_keywords = ( + 'alias', 'ssh', 'hostname', 'logging', 'cable', 'ipdr', + 'snmp-server', 'aaa', 'radius-server', 'privilege', 'cli', 'packetcable', + 'system', 'network', 'clock', 'no' + ) + + for line in raw_lines: + line = line.replace('\r', '') + if not line.strip(): + continue + + # 💡 判斷是否為被截斷的行: + # 1. 前一行長度非常長 (大於 190 字元,根據您的截圖,極限是 199) + # 2. 當前行「沒有縮排」(終端機自動折行會把字元推到最左邊第 0 格) + # 3. 當前行不是註解 '!' + if fixed_lines and len(fixed_lines[-1]) > 190 and not line.startswith(' ') and not line.startswith('!'): + # 取得當前行的第一個單字 + first_word = line.strip().split()[0] if line.strip() else "" + + # 確保它不是一個正常的 Root 指令 + if first_word not in root_keywords: + # ✅ 觸發黏合邏輯:將這行直接接在上一行後面 + fixed_lines[-1] = fixed_lines[-1] + line + continue + + fixed_lines.append(line) + + # 統一去除右側多餘空白,準備進入階段 1 + lines = [line.rstrip() for line in fixed_lines if line.strip()] # ========================================== - # 階段 1:建立實體樹 (依據縮排與 '!') + # 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係) # ========================================== def build_raw_tree(start_idx, current_indent): nodes = [] @@ -37,26 +72,25 @@ def parse_cli_to_tree(cli_text: str) -> dict: while i < len(lines): line = lines[i] stripped = line.strip() - - # 遇到 ! 的處理邏輯 - if stripped == '!': - if current_indent > 0: - return nodes, i + 1 # 子區塊遇到 !,結束並消耗該行 - else: - i += 1 # Root 層級的 ! 只是分隔符號,直接跳過 - continue - indent = len(line) - len(line.lstrip()) - # 縮排退回,隱含結束當前區塊 (不消耗該行) + # 🌟 嚴格判斷 1:如果遇到縮排退回 (包含縮排退回的 !),代表當前深度的區塊已結束 if indent < current_indent: return nodes, i + + # 🌟 嚴格判斷 2:處理同層級的 ! + if stripped == '!': + # 能走到這裡,代表 indent >= current_indent + # 這是對應當前層級的結束符號或分隔符,我們將其消耗掉並繼續,絕對不提早 return + i += 1 + continue # 探測是否有子節點 has_children = False if i + 1 < len(lines): next_line = lines[i+1] next_stripped = next_line.strip() + # 只有當下一行不是 ! 且縮排大於當前行時,才判定為有子節點 if next_stripped != '!': next_indent = len(next_line) - len(next_line.lstrip()) if next_indent > indent: diff --git a/static/app.js b/static/app.js index 6928650..59d5c0a 100644 --- a/static/app.js +++ b/static/app.js @@ -178,7 +178,12 @@ async function startEditFolder(path, elementId) { const origVal = container.getAttribute('data-original'); const rawPath = container.getAttribute('data-path'); // 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1] - const cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); + let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); + + // 💡 攔截 no 指令:將查詢路徑改為實際指令 + if (rawPath.endsWith('::no')) { + cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; + } let leafCache = optData[cacheKey]; @@ -265,7 +270,13 @@ async function startEditLeaf(path, elementId) { let optData = await optRes.json(); // 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1] - const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); + let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); + + // 💡 攔截 no 指令:將查詢路徑改為實際指令 + if (path.endsWith('::no')) { + cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; + } + let leafCache = optData[cacheKey]; const currentTime = Math.floor(Date.now() / 1000); @@ -545,17 +556,41 @@ function showCliPreviewModal(cliScript) { } // ❌ 隱藏側邊預覽區塊 (恢復左側 100% 寬度) -function hideSideCLI() { +async 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) { - if (currentEditType === 'folder') { - applyEditFolder(currentEditPath, currentEditElementId); - } else if (currentEditType === 'leaf') { - applyEditLeaf(currentEditPath, currentEditElementId); + + // 🔍 檢查這個節點是不是 no 指令 (透過尋找我們剛加的 display-wrapper) + const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`); + + if (wrapper) { + // 🌟 這是 no 指令的變身邏輯! + const container = document.getElementById(`container-${currentEditElementId}`); + const input = container ? container.querySelector('.edit-input') : null; + // 取得使用者編輯後的新指令 + const newCommand = input ? input.value.trim() : (container ? container.getAttribute('data-original') : ''); + + // 1. 先正常解除後端鎖定 (這會還原原本的 HTML 結構) + if (currentEditType === 'folder') { + await cancelEditFolder(currentEditPath, currentEditElementId); + } else { + await cancelEditLeaf(currentEditPath, currentEditElementId); + } + + // 2. 觸發原地變身!(這會把整個 wrapper 換成綠色打勾的樣式) + transformNoToActive(currentEditElementId, newCommand); + + } else { + // 🌟 一般正常指令的套用邏輯 (維持您原本的寫法) + if (currentEditType === 'folder') { + await applyEditFolder(currentEditPath, currentEditElementId); + } else if (currentEditType === 'leaf') { + await applyEditLeaf(currentEditPath, currentEditElementId); + } } } @@ -1538,30 +1573,34 @@ async function executeBondingConfig() { // ========================================== // 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定與虛擬群組視覺優化) // ========================================== -function buildTree(node, path = '') { +// 🌟 1. 在參數中加入 isParentCommandGroup,用來實現「向下繼承」 +// 🌟 加入 isParentCommandGroup 參數,實現向下繼承 +function buildTree(node, path = '', isParentCommandGroup = false) { let html = ''; if (!node || typeof node !== 'object') return html; for (const [key, value] of Object.entries(node)) { const currentPath = path ? `${path}::${key}` : key; - - // 🌟 產生純淨的 CLI 指令路徑 (替換 :: 為空白,並移除虛擬索引) - const cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); + let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (typeof value === 'object' && value !== null) { - // 🌟 處理資料夾或指令群組 - const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; - const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null); - const folderIcon = hasNestedObject ? '📁' : '📑'; - const folderSuffix = hasNestedObject ? '' : '(指令群組)'; + const isCommandGroup = isParentCommandGroup || !hasNestedObject; + const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; + const folderSuffix = isCommandGroup ? '(指令群組)' : ''; + + const svgFolderClosed = ``; + const svgFolderOpen = ``; + const svgGroupClosed = ``; + const svgGroupOpen = ``; + + const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed; + const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen; - // 🌟 定義專業的 SVG 向量圖示 const expandAllIcon = ``; const collapseAllIcon = ``; - // 🌟 全部展開/收合按鈕 (使用 SVG 並加入懸停變色效果) - const expandCollapseBtns = hasNestedObject ? ` + const expandCollapseBtns = ` - ` : ''; + `; - // ✅ 加入 title="${cliCommand}" 提供懸停提示 html += ` -
+
- ${folderIcon} + + + ${iconClosed} + + ${key}${folderSuffix} ${expandCollapseBtns} @@ -1601,35 +1644,63 @@ function buildTree(node, path = '') {
- ${buildTree(value, currentPath)} + ${buildTree(value, currentPath, isCommandGroup)}
`; } else { // 🌟 處理單一設定項目 (Leaf) const isVirtualIndex = /^\[\d+\]$/.test(key); + const isNoCommand = (key === 'no'); const safeValue = (value === null ? '' : value).toString().replace(/"/g, """); const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; + // 💡 終極修正:完美兼容根目錄與子目錄的 no 指令 + if (isNoCommand) { + // 1. 移除結尾的 " no" (子目錄) 或是整句剛好是 "no" (根目錄) + let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim(); + + // 2. 如果 baseCmd 有東西,代表是子目錄;如果為空,代表是根目錄 + cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`; + } + + const svgLeaf = ``; + const svgDisabled = ``; + + const iconHtml = (icon) => `${icon}`; + + // 💡 關鍵修正:加上 display: inline-block; 與 transform: translateY(1.5px); 強制下移 + const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`; + let displayContent = ''; if (isVirtualIndex) { displayContent = ` - 📄 + ${iconHtml(svgLeaf)} - ${safeValue} + ${safeValue} + + `; + } else if (isNoCommand) { + // 為了方便後續的「原地變身」,我們加上了 id="display-wrapper-..." + displayContent = ` + + ${iconHtml(svgDisabled)} + no: + + ${safeValue} + `; } else { displayContent = ` - 📄 ${key}: + ${iconHtml(svgLeaf)} ${key}: - ${safeValue} + ${safeValue} `; } - // ✅ 加入 title="${cliCommand}" 提供懸停提示 html += `
`; + let html = `
`; + for (const key in data) { const value = data[key]; const currentPath = parentPath ? `${parentPath}::${key}` : key; const isChecked = hiddenKeys.includes(currentPath) ? "checked" : ""; + + // 💡 統一在這裡解析 cliCommand,讓資料夾與 Leaf 都能擁有 Hint,並拔除虛擬索引 [0] + let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); - if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) { - // 🌟 系統設定區塊也套用相同的視覺優化 - const isVirtualGroup = Object.keys(value).every(k => /^\[\d+\]$/.test(k)); - const isVirtualIndex = /^\[\d+\]$/.test(key); + const svgFolderClosed = ``; + const svgFolderOpen = ``; + const svgGroupClosed = ``; + const svgGroupOpen = ``; + const svgLeaf = ``; - let displayName = key; - let iconHtml = ``; + const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0; + const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)'; + const checkboxHtml = ``; - if (isVirtualGroup) { - iconHtml = `📑`; - displayName = `${key} (指令群組)`; - } else if (isVirtualIndex) { - iconHtml = `📦`; - displayName = `項目 ${key}`; - } + if (isFolder) { + const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null); + const isCommandGroup = isParentCommandGroup || !hasNestedObject; + const elementId = `filter-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; + + const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed; + const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen; + const folderSuffix = isCommandGroup ? `(指令群組)` : ''; + + const expandAllIcon = ``; + const collapseAllIcon = ``; + + const expandCollapseBtns = ` + + + ${expandAllIcon} + + + ${collapseAllIcon} + + + `; html += ` -
- - - - ${iconHtml} - ${displayName} +
+ + + ${checkboxHtml} + + ${iconClosed} + + + ${key}${folderSuffix} + ${expandCollapseBtns} -
- ${buildRealFilterTree(value, currentPath, hiddenKeys)} +
+ ${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
`; } else { + // 🌟 處理單一設定項目 (Leaf) const isVirtualIndex = /^\[\d+\]$/.test(key); let displayName = isVirtualIndex ? `項目 ${key}` : `${key}`; const safeValue = (value === null ? '' : value).toString().replace(/"/g, """); + + const isNoCommand = (key === 'no'); + + // 預設的數值、冒號與圖示顯示 + let valueHtml = `${safeValue}`; + let colonHtml = `:`; + let currentIcon = ``; + + // 💡 統一 UI 顯示:紅色禁止符號 + 紅色 no: + 灰色數值 + if (isNoCommand) { + let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim(); + cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`; + + displayName = `no`; + colonHtml = `:`; + valueHtml = `${safeValue}`; + currentIcon = ``; + } html += ` -
- - 📄 ${displayName}: - ${safeValue} +
+ ${checkboxHtml} + + ${currentIcon} + + ${displayName}${colonHtml} + ${valueHtml}
`; } @@ -1984,3 +2109,37 @@ async function clearVisibleCache() { } } +/** + * 將 no 指令原地變身為啟用狀態 + * @param {string} elementId - 該節點的 elementId (例如 leaf-no-cable-...) + * @param {string} newCommand - 解除 no 之後的完整指令 (例如 "cable dynamic-secret tftp...") + */ +function transformNoToActive(elementId, newCommand) { + const wrapper = document.getElementById(`display-wrapper-${elementId}`); + if (!wrapper) return; + + // 1. 解析指令:切出第一個字 (如 cable) 與後續指令 + const parts = newCommand.trim().split(' '); + const firstWord = parts[0]; // "cable" + const restCommand = parts.slice(1).join(' '); // "dynamic-secret..." + + // 💡 2. 換成視覺平衡的「空心綠圈圈 + 打勾」圖示 + const svgEnabled = ``; + const iconHtml = `${svgEnabled}`; + + // 3. 執行原地替換 (In-place Update) + wrapper.innerHTML = ` + ${iconHtml} + ${firstWord} + + ${restCommand} + (已啟用,重整後歸檔) + + `; + + // 4. 隱藏該行的編輯按鈕,避免使用者在歸檔前重複編輯產生邏輯錯誤 + const actionBtns = document.getElementById(`actions-${elementId}`); + const editBtn = document.getElementById(`edit-btn-${elementId}`); + if (actionBtns) actionBtns.style.display = 'none'; + if (editBtn) editBtn.style.display = 'none'; +} \ No newline at end of file diff --git a/system_settings.json b/system_settings.json index ba3788a..2988058 100644 --- a/system_settings.json +++ b/system_settings.json @@ -1,3 +1,6 @@ { - "hidden_keys": [] + "hidden_keys": [ + "ssh", + "clock" + ] } \ No newline at end of file