Compare commits

...

3 Commits

Author SHA1 Message Date
swpa 2168ccf770 perf(core): 實施 Priority 3 效能與體驗極致優化
- 前端渲染優化:將 tree-ui.js 的巨量字串拼接改為 Array.join,大幅降低 V8 引擎 GC 負擔與畫面卡頓。
- Terminal 資源回收:強化 WebSocket 斷線偵測,主動 cancel 背景任務並確保 SSH 連線乾淨釋放 (terminal.py)。
- 智慧 Prompt 偵測:全面導入 Regex 匹配設備 Prompt,取代盲目 Timeout 等待,巨幅提升爬蟲與設定寫入速度 (cmts_scraper.py, backup.py, config.py)。
2026-05-31 18:19:12 +08:00
swpa 60195a735e perf(memory): 實施 Priority 2 記憶體洩漏修復與資源回收優化
- 前端 OOM 防護:實作 clearTreeCache 並於切換設備時觸發,釋放巨量樹狀圖快取 (tree-ui.js, app.js)。
- 後端 SSE 資源回收:延長輪詢 timeout 降低 CPU 負載,並透過 finally 確保斷線時 Queue 絕對釋放 (leaf_options.py)。
- SSH 連線生命週期:全面導入 async with Context Manager,確保爬蟲任務中 AsyncSSH 連線與 Process 乾淨關閉 (cmts_scraper.py)。
2026-05-31 16:15:44 +08:00
swpa 4437e4e3fa refactor(backend): 實施 Priority 1 核心穩定性升級與連線防護
- 實作設備級別鎖 (Per-Host Lock):將全域鎖改為 defaultdict(asyncio.Lock),大幅提升多人併發操作能力 (shared.py, config.py, backup.py)。
- 修復 Netmiko 連線洩漏:全面導入 try...finally 機制,確保發生異常時 disconnect() 絕對會被執行 (query.py, config.py)。
- 解除 Event Loop 阻塞:將解析樹狀圖等 CPU-Bound 任務移至 asyncio.to_thread 背景執行,防止 API 請求卡死 (config.py)。
- 強化 AsyncSSH 生命週期:於備份還原路由導入 async with Context Manager,確保 SSH 連線與 Process 絕對乾淨釋放 (backup.py)。
2026-05-31 14:24:10 +08:00
10 changed files with 716 additions and 564 deletions

View File

@ -663,8 +663,8 @@ def save_settings(settings_data):
with open(SETTINGS_FILE, "w", encoding="utf-8") as f: with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings_data, f, indent=4, ensure_ascii=False) json.dump(settings_data, f, indent=4, ensure_ascii=False)
# 全域非同步鎖:確保同一時間只有一人能執行設定寫入 # 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖
cmts_config_lock = asyncio.Lock() cmts_config_locks = defaultdict(asyncio.Lock)
================================================================================ ================================================================================
@ -1423,13 +1423,13 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
for batch_idx, batch in enumerate(batches): for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
conn = None
try: try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) # 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=1.0): async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
@ -1439,6 +1439,9 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
@ -1447,14 +1450,14 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if cmts_version == "unknown": if cmts_version == "unknown":
process.stdin.write("show version\n") process.stdin.write("show version\n")
await process.stdin.drain() await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5) version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output) match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match: if match:
cmts_version = match.group(1) cmts_version = match.group(1)
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5) await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
for path in batch: for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求 # 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
@ -1523,8 +1526,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
except Exception as e: except Exception as e:
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n" yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue continue
finally:
if conn: conn.close()
# --- 步驟 3寫入快取 (DB or JSON) --- # --- 步驟 3寫入快取 (DB or JSON) ---
try: try:
@ -1606,12 +1607,12 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str: async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
"""連線至設備並抓取完整的純文字設定檔""" """連線至設備並抓取完整的純文字設定檔"""
conn = None
try: try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) # 🧹 [穩定性修復] 全面改用 async with 管理生命週期
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=2.0): async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
@ -1622,25 +1623,28 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊 # 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0) await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁 # 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
if config_type == "full": if config_type == "full":
# 1. 先進入 config 模式 # 1. 先進入 config 模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)# await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore) # 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
cmd = "show full-configuration | nomore" cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n") process.stdin.write(f"{cmd}\n")
await process.stdin.drain() await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0) raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
# 3. 抓完後退回上一層 (保持良好習慣) # 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n") process.stdin.write("exit\n")
@ -1683,6 +1687,7 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
except Exception as e: except Exception as e:
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}") raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
finally: finally:
if conn: if conn:
conn.close() conn.close()
@ -2038,7 +2043,7 @@ import {
import { getGlobalConnectionInfo } from './utils.js'; import { getGlobalConnectionInfo } from './utils.js';
// 4. 樹狀圖 UI 模組 (Tree UI) // 4. 樹狀圖 UI 模組 (Tree UI)
import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-ui.js'; import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js';
// 5. 編輯模式與鎖定模組 (Edit Mode) // 5. 編輯模式與鎖定模組 (Edit Mode)
import { import {
@ -2339,6 +2344,9 @@ function toggleQueryInputs(mode) {
function resetAllManagerData() { function resetAllManagerData() {
resetMacDomainData(); resetMacDomainData();
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache();
const configArea = document.getElementById('macDomainConfigArea'); const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none'; if (configArea) configArea.style.display = 'none';
@ -5057,6 +5065,12 @@ FILE: static/tree-ui.js
export const folderDataCache = {}; export const folderDataCache = {};
export const filterFolderCache = {}; export const filterFolderCache = {};
// 🧹 [修復 Leak] 徹底清空樹狀圖快取,釋放記憶體
export function clearTreeCache() {
for (let key in folderDataCache) delete folderDataCache[key];
for (let key in filterFolderCache) delete filterFolderCache[key];
}
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去 // 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
window.lazyLoadFolder = function(elementId, isFilter = false) { window.lazyLoadFolder = function(elementId, isFilter = false) {
const detailsEl = document.getElementById(`details-${elementId}`); const detailsEl = document.getElementById(`details-${elementId}`);
@ -5119,8 +5133,8 @@ export function collapseAll(elementId) {
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版) // 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ========================================== // ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') { export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
let html = ''; const htmlParts = [];
if (!node || typeof node !== 'object') return html; if (!node || typeof node !== 'object') return htmlParts.join('');
for (const [key, value] of Object.entries(node)) { for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key; const currentPath = path ? `${path}::${key}` : key;
@ -5161,8 +5175,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
`; `;
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder htmlParts.push(`
html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);"> ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
@ -5203,9 +5216,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
<!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 --> <!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 -->
</div> </div>
</details> </details>
`; `);
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no'); const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -5250,7 +5262,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
`; `;
} }
html += ` htmlParts.push(`
<div class="tree-leaf-node" <div class="tree-leaf-node"
title="${cliCommand}" title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -5278,10 +5290,10 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
</div> </div>
</div> </div>
`; `);
} }
} }
return html; return htmlParts.join('');
} }
// ========================================== // ==========================================
@ -5290,7 +5302,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') { export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
if (typeof data !== 'object' || data === null) return ''; if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`; const htmlParts = [];
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
for (const key in data) { for (const key in data) {
const value = data[key]; const value = data[key];
@ -5340,7 +5353,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
`; `;
// 🌟 加入 lazyLoadFolder 觸發 // 🌟 加入 lazyLoadFolder 觸發
html += ` htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" <details id="details-${elementId}" class="tree-folder"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);"> ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
@ -5358,9 +5371,8 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<!-- 🌟 延遲渲染 --> <!-- 🌟 延遲渲染 -->
</div> </div>
</details> </details>
`; `);
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`; let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -5381,7 +5393,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`; currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
} }
html += ` htmlParts.push(`
<div class="tree-leaf-node" <div class="tree-leaf-node"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'" onmouseout="this.style.backgroundColor='transparent'"
@ -5394,16 +5406,17 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<span style="margin-right: 5px;">${displayName}${colonHtml}</span> <span style="margin-right: 5px;">${displayName}${colonHtml}</span>
${valueHtml} ${valueHtml}
</div> </div>
`; `);
} }
} }
html += '</div>'; htmlParts.push('</div>');
return html; return htmlParts.join('');
} }
================================================================================ ================================================================================
FILE: routers/backup.py FILE: routers/backup.py
================================================================================ ================================================================================
# --- routers/backup.py ---
import logging import logging
import asyncssh import asyncssh
import asyncio import asyncio
@ -5423,7 +5436,8 @@ from database import (
) )
from cmts_scraper import fetch_raw_config from cmts_scraper import fetch_raw_config
from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖 # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -5629,18 +5643,23 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
return StreamingResponse(empty_success(), media_type="application/x-ndjson") return StreamingResponse(empty_success(), media_type="application/x-ndjson")
async def restore_generator(): async def restore_generator():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = cmts_config_locks[req.host]
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令) # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
if cmts_config_lock.locked(): if host_lock.locked():
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n" yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
return return
async with cmts_config_lock: async with host_lock:
try: try:
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n" yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
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): # 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
@ -5650,6 +5669,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
@ -5657,14 +5679,14 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
# 2. 進入設定模式 # 2. 進入設定模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5) await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n" yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
# 3. 逐行寫入並進行 Fail-safe 檢查 # 3. 逐行寫入並進行 Fail-safe 檢查
for cmd in commands: for cmd in commands:
process.stdin.write(f"{cmd}\n") process.stdin.write(f"{cmd}\n")
await process.stdin.drain() await process.stdin.drain()
out = await read_until_quiet(timeout=0.2) out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip() clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
@ -5679,7 +5701,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
# 2. 對設備下達 abort 指令 # 2. 對設備下達 abort 指令
process.stdin.write("abort\n") process.stdin.write("abort\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式 await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
# 3. 回報最終錯誤並關閉連線 # 3. 回報最終錯誤並關閉連線
yield json.dumps({ yield json.dumps({
@ -5687,7 +5709,6 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}", "message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
"output": clean_out "output": clean_out
}) + "\n" }) + "\n"
conn.close()
return return
yield json.dumps({ yield json.dumps({
@ -5703,13 +5724,12 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n" yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write("commit\n") process.stdin.write("commit\n")
await process.stdin.drain() await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0) commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線 # 5. 退出並關閉連線
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
conn.close() # 🌟 移除手動 conn.close(),交由 async with 處理
yield json.dumps({ yield json.dumps({
"status": "success", "status": "success",
@ -5724,7 +5744,6 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
return StreamingResponse(restore_generator(), media_type="application/x-ndjson") return StreamingResponse(restore_generator(), media_type="application/x-ndjson")
================================================================================ ================================================================================
FILE: routers/terminal.py FILE: routers/terminal.py
================================================================================ ================================================================================
@ -5739,6 +5758,9 @@ router = APIRouter()
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str): async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
await websocket.accept() await websocket.accept()
conn = None conn = None
process = None
ws_task = None
ssh_task = None
try: try:
# 建立連線 # 建立連線
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
@ -5760,6 +5782,8 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
safe_text = data_bytes.decode('utf-8', errors='replace') safe_text = data_bytes.decode('utf-8', errors='replace')
await websocket.send_text(safe_text) await websocket.send_text(safe_text)
except asyncio.CancelledError:
pass
except Exception as e: except Exception as e:
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:") print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
traceback.print_exc() traceback.print_exc()
@ -5777,6 +5801,9 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
process.stdin.write(data_bytes) process.stdin.write(data_bytes)
await process.stdin.drain() await process.stdin.drain()
except WebSocketDisconnect: except WebSocketDisconnect:
# 主動拋出,讓外層捕捉以進行資源回收
raise
except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:") print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
@ -5793,6 +5820,12 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
for task in pending: for task in pending:
task.cancel() task.cancel()
except WebSocketDisconnect:
print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...")
if ws_task and not ws_task.done():
ws_task.cancel()
if ssh_task and not ssh_task.done():
ssh_task.cancel()
except Exception as e: except Exception as e:
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n" error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
try: try:
@ -5802,6 +5835,9 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:") print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
traceback.print_exc() traceback.print_exc()
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process:
process.close()
if conn: if conn:
conn.close() conn.close()
try: try:
@ -5810,6 +5846,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
pass pass
================================================================================ ================================================================================
FILE: routers/lock.py FILE: routers/lock.py
================================================================================ ================================================================================
@ -5954,26 +5991,32 @@ def parse_cm_output(raw_text: str) -> list:
@router.get("/cable-modems") @router.get("/cable-modems")
async def get_cable_modems(): async def get_cable_modems():
net_connect = None
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) net_connect = ConnectHandler(**CMTS_DEVICE)
raw_output = str(net_connect.send_command("show cable modem")) raw_output = str(net_connect.send_command("show cable modem"))
net_connect.disconnect()
structured_data = parse_cm_output(raw_output) structured_data = parse_cm_output(raw_output)
return {"status": "success", "total_count": len(structured_data), "data": structured_data} return {"status": "success", "total_count": len(structured_data), "data": structured_data}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/configuration") @router.get("/configuration")
async def get_configuration(): async def get_configuration():
net_connect = None
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) net_connect = ConnectHandler(**CMTS_DEVICE)
net_connect.send_command_timing("config") net_connect.send_command_timing("config")
raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120) raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120)
net_connect.send_command_timing("exit") net_connect.send_command_timing("exit")
net_connect.disconnect()
return {"status": "success", "data": raw_output} return {"status": "success", "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-query") @router.get("/cmts-query")
async def get_cmts_query( async def get_cmts_query(
@ -5983,6 +6026,7 @@ async def get_cmts_query(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -6031,14 +6075,17 @@ async def get_cmts_query(
cli_command = commands[query_type] + " | nomore" cli_command = commands[query_type] + " | nomore"
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = net_connect.send_command(cli_command, read_timeout=15) raw_output = net_connect.send_command(cli_command, read_timeout=15)
net_connect.disconnect()
return {"status": "success", "command": cli_command, "data": raw_output} return {"status": "success", "command": cli_command, "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-config") @router.get("/cmts-mac-domain-config")
async def get_mac_domain_config(target: str, host: str, username: str, password: str): async def get_mac_domain_config(target: str, host: str, username: str, password: str):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -6046,7 +6093,6 @@ async def get_mac_domain_config(target: str, host: str, username: str, password:
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore" cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command(cli_command, read_timeout=15)) raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
net_connect.disconnect()
# 💡 正名工程:更新字典的 Key 為一致性的命名 # 💡 正名工程:更新字典的 Key 為一致性的命名
config = { config = {
@ -6102,6 +6148,9 @@ async def get_mac_domain_config(target: str, host: str, username: str, password:
return {"status": "success", "data": config} return {"status": "success", "data": config}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}") raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-list") @router.get("/cmts-mac-domain-list")
async def get_cmts_mac_domain_list( async def get_cmts_mac_domain_list(
@ -6109,13 +6158,13 @@ async def get_cmts_mac_domain_list(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?")) raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
net_connect.disconnect()
import re import re
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output) matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
@ -6124,6 +6173,9 @@ async def get_cmts_mac_domain_list(
return {"status": "success", "data": mac_domains} return {"status": "success", "data": mac_domains}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-version") @router.get("/cmts-version")
async def get_cmts_version( async def get_cmts_version(
@ -6131,13 +6183,13 @@ async def get_cmts_version(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command("show version", read_timeout=15)) raw_output = str(net_connect.send_command("show version", read_timeout=15))
net_connect.disconnect()
import re import re
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號 # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
@ -6150,6 +6202,9 @@ async def get_cmts_version(
except Exception as e: except Exception as e:
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"} return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
finally:
if net_connect:
net_connect.disconnect()
================================================================================ ================================================================================
@ -6166,8 +6221,8 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
from netmiko import ConnectHandler from netmiko import ConnectHandler
# 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制 # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict from collections import defaultdict
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -6246,8 +6301,9 @@ async def execute_cmts_config(req: ConfigRequest):
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')] commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
async def config_streamer(): async def config_streamer():
# 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行 # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
async with cmts_config_lock: host_lock = cmts_config_locks[req.host]
async with host_lock:
try: try:
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n" yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
@ -6261,7 +6317,7 @@ async def execute_cmts_config(req: ConfigRequest):
async with conn.create_process() as process: async with conn.create_process() as process:
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死) # 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
async def read_until_quiet(timeout=0.5): async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
out_data = "" out_data = ""
while True: while True:
try: try:
@ -6270,6 +6326,9 @@ async def execute_cmts_config(req: ConfigRequest):
if not chunk: if not chunk:
break break
out_data += str(chunk) out_data += str(chunk)
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, out_data):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return out_data return out_data
@ -6277,7 +6336,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 1. 進入設定模式 # 1. 進入設定模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(0.5) # 清空歡迎訊息 await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
# 2. 逐行送出指令並回報進度 # 2. 逐行送出指令並回報進度
for cmd in commands: for cmd in commands:
@ -6287,7 +6346,7 @@ async def execute_cmts_config(req: ConfigRequest):
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒 # ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
wait_time = 5.0 if cmd.strip() == "commit" else 0.5 wait_time = 5.0 if cmd.strip() == "commit" else 0.5
output = await read_until_quiet(wait_time) output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort # 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower() lower_output = output.lower()
@ -6305,7 +6364,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 3. 退出設定模式 # 3. 退出設定模式
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
final_output = await read_until_quiet(1.0) final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
yield json.dumps({ yield json.dumps({
"status": "success", "status": "success",
@ -6392,6 +6451,7 @@ async def generate_cli(req: GenerateCliRequest):
@router.get("/cmts-ds-rf-port-list") @router.get("/cmts-ds-rf-port-list")
async def get_ds_rf_port_list(host: str, username: str, password: str = ""): async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
"""獲取設備上所有的 DS RF Port 清單""" """獲取設備上所有的 DS RF Port 清單"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -6399,7 +6459,6 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = 'show running-config | include "cable ds-rf-port"' command = 'show running-config | include "cable ds-rf-port"'
raw_output = str(net_connect.send_command(command, read_timeout=60)) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
pattern = r"cable ds-rf-port\s+([0-9:/]+)" pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output) ports = re.findall(pattern, raw_output)
@ -6412,11 +6471,15 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
} }
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-ds-rf-port-config") @router.get("/cmts-ds-rf-port-config")
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""): async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON""" """獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -6424,10 +6487,10 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore" command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=60)) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
base_tree = parse_cli_to_tree(raw_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
return { return {
"status": "success", "status": "success",
@ -6436,11 +6499,15 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
} }
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config") @router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"): async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON""" """獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -6456,13 +6523,12 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
command = "show running-config | nomore" command = "show running-config | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=180)) raw_output = str(net_connect.send_command(command, read_timeout=180))
net_connect.disconnect()
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE) clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip() clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ========================================== # ==========================================
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯 # 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
@ -6488,12 +6554,16 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "success", "data": perfect_tree} return {"status": "success", "data": perfect_tree}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config/stream") @router.get("/cmts-full-config/stream")
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"): async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置 (串流進度回報版)""" """獲取整台 CMTS 的完整配置 (串流進度回報版)"""
async def config_streamer(): async def config_streamer():
net_connect = None
try: try:
# 1. 廣播:正在連線 # 1. 廣播:正在連線
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n" yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
@ -6517,8 +6587,6 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
command = "show running-config | nomore" command = "show running-config | nomore"
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180) raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
await asyncio.to_thread(net_connect.disconnect)
# 3. 廣播:正在解析 # 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n" yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -6526,8 +6594,9 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE) clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip() clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ========================================== # ==========================================
# 🌟 深度過濾攔截器 (維持原樣) # 🌟 深度過濾攔截器 (維持原樣)
@ -6556,6 +6625,10 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
except Exception as e: except Exception as e:
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n" yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
finally:
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
if net_connect:
await asyncio.to_thread(net_connect.disconnect)
# 回傳 NDJSON 串流格式 # 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson") return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
@ -6638,10 +6711,12 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
async def event_generator(): async def event_generator():
try: try:
while True: while True:
# 主動檢查斷線,避免死鎖
if await request.is_disconnected(): if await request.is_disconnected():
break break
try: try:
message = await asyncio.wait_for(client_queue.get(), timeout=1.0) # [修復 Leak] 延長 timeout 降低輪詢負擔
message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
yield message yield message
except asyncio.TimeoutError: except asyncio.TimeoutError:
yield ": keepalive\n\n" yield ": keepalive\n\n"
@ -6649,6 +6724,7 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
# [修復 Leak] 確保斷線時 Queue 絕對會被移出 active_clients 釋放記憶體
if channel_key in active_clients: if channel_key in active_clients:
active_clients[channel_key].discard(client_queue) active_clients[channel_key].discard(client_queue)

View File

@ -157,13 +157,13 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
for batch_idx, batch in enumerate(batches): for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
conn = None
try: try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) # 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=1.0): async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
@ -173,6 +173,9 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
@ -181,14 +184,14 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if cmts_version == "unknown": if cmts_version == "unknown":
process.stdin.write("show version\n") process.stdin.write("show version\n")
await process.stdin.drain() await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5) version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output) match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match: if match:
cmts_version = match.group(1) cmts_version = match.group(1)
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5) await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
for path in batch: for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求 # 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
@ -257,8 +260,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
except Exception as e: except Exception as e:
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n" yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue continue
finally:
if conn: conn.close()
# --- 步驟 3寫入快取 (DB or JSON) --- # --- 步驟 3寫入快取 (DB or JSON) ---
try: try:
@ -340,12 +341,12 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str: async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
"""連線至設備並抓取完整的純文字設定檔""" """連線至設備並抓取完整的純文字設定檔"""
conn = None
try: try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) # 🧹 [穩定性修復] 全面改用 async with 管理生命週期
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=2.0): async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
@ -356,25 +357,28 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊 # 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0) await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁 # 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
if config_type == "full": if config_type == "full":
# 1. 先進入 config 模式 # 1. 先進入 config 模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)# await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore) # 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
cmd = "show full-configuration | nomore" cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n") process.stdin.write(f"{cmd}\n")
await process.stdin.drain() await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0) raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
# 3. 抓完後退回上一層 (保持良好習慣) # 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n") process.stdin.write("exit\n")
@ -417,6 +421,7 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
except Exception as e: except Exception as e:
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}") raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
finally: finally:
if conn: if conn:
conn.close() conn.close()

View File

@ -1,3 +1,4 @@
# --- routers/backup.py ---
import logging import logging
import asyncssh import asyncssh
import asyncio import asyncio
@ -17,7 +18,8 @@ from database import (
) )
from cmts_scraper import fetch_raw_config from cmts_scraper import fetch_raw_config
from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖 # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -223,18 +225,23 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
return StreamingResponse(empty_success(), media_type="application/x-ndjson") return StreamingResponse(empty_success(), media_type="application/x-ndjson")
async def restore_generator(): async def restore_generator():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = cmts_config_locks[req.host]
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令) # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
if cmts_config_lock.locked(): if host_lock.locked():
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n" yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
return return
async with cmts_config_lock: async with host_lock:
try: try:
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n" yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
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): # 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
@ -244,6 +251,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
@ -251,14 +261,14 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
# 2. 進入設定模式 # 2. 進入設定模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5) await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n" yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
# 3. 逐行寫入並進行 Fail-safe 檢查 # 3. 逐行寫入並進行 Fail-safe 檢查
for cmd in commands: for cmd in commands:
process.stdin.write(f"{cmd}\n") process.stdin.write(f"{cmd}\n")
await process.stdin.drain() await process.stdin.drain()
out = await read_until_quiet(timeout=0.2) out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip() clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
@ -273,7 +283,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
# 2. 對設備下達 abort 指令 # 2. 對設備下達 abort 指令
process.stdin.write("abort\n") process.stdin.write("abort\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式 await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
# 3. 回報最終錯誤並關閉連線 # 3. 回報最終錯誤並關閉連線
yield json.dumps({ yield json.dumps({
@ -281,7 +291,6 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}", "message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
"output": clean_out "output": clean_out
}) + "\n" }) + "\n"
conn.close()
return return
yield json.dumps({ yield json.dumps({
@ -297,13 +306,12 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n" yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write("commit\n") process.stdin.write("commit\n")
await process.stdin.drain() await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0) commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線 # 5. 退出並關閉連線
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
conn.close() # 🌟 移除手動 conn.close(),交由 async with 處理
yield json.dumps({ yield json.dumps({
"status": "success", "status": "success",
@ -316,4 +324,3 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n" yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n"
return StreamingResponse(restore_generator(), media_type="application/x-ndjson") return StreamingResponse(restore_generator(), media_type="application/x-ndjson")

View File

@ -9,8 +9,8 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
from netmiko import ConnectHandler from netmiko import ConnectHandler
# 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制 # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict from collections import defaultdict
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -89,8 +89,9 @@ async def execute_cmts_config(req: ConfigRequest):
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')] commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
async def config_streamer(): async def config_streamer():
# 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行 # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
async with cmts_config_lock: host_lock = cmts_config_locks[req.host]
async with host_lock:
try: try:
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n" yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
@ -104,7 +105,7 @@ async def execute_cmts_config(req: ConfigRequest):
async with conn.create_process() as process: async with conn.create_process() as process:
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死) # 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
async def read_until_quiet(timeout=0.5): async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
out_data = "" out_data = ""
while True: while True:
try: try:
@ -113,6 +114,9 @@ async def execute_cmts_config(req: ConfigRequest):
if not chunk: if not chunk:
break break
out_data += str(chunk) out_data += str(chunk)
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, out_data):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return out_data return out_data
@ -120,7 +124,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 1. 進入設定模式 # 1. 進入設定模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(0.5) # 清空歡迎訊息 await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
# 2. 逐行送出指令並回報進度 # 2. 逐行送出指令並回報進度
for cmd in commands: for cmd in commands:
@ -130,7 +134,7 @@ async def execute_cmts_config(req: ConfigRequest):
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒 # ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
wait_time = 5.0 if cmd.strip() == "commit" else 0.5 wait_time = 5.0 if cmd.strip() == "commit" else 0.5
output = await read_until_quiet(wait_time) output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort # 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower() lower_output = output.lower()
@ -148,7 +152,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 3. 退出設定模式 # 3. 退出設定模式
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
final_output = await read_until_quiet(1.0) final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
yield json.dumps({ yield json.dumps({
"status": "success", "status": "success",
@ -235,6 +239,7 @@ async def generate_cli(req: GenerateCliRequest):
@router.get("/cmts-ds-rf-port-list") @router.get("/cmts-ds-rf-port-list")
async def get_ds_rf_port_list(host: str, username: str, password: str = ""): async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
"""獲取設備上所有的 DS RF Port 清單""" """獲取設備上所有的 DS RF Port 清單"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -242,7 +247,6 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = 'show running-config | include "cable ds-rf-port"' command = 'show running-config | include "cable ds-rf-port"'
raw_output = str(net_connect.send_command(command, read_timeout=60)) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
pattern = r"cable ds-rf-port\s+([0-9:/]+)" pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output) ports = re.findall(pattern, raw_output)
@ -255,11 +259,15 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
} }
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-ds-rf-port-config") @router.get("/cmts-ds-rf-port-config")
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""): async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON""" """獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -267,10 +275,10 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore" command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=60)) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
base_tree = parse_cli_to_tree(raw_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
return { return {
"status": "success", "status": "success",
@ -279,11 +287,15 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
} }
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config") @router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"): async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON""" """獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -299,13 +311,12 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
command = "show running-config | nomore" command = "show running-config | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=180)) raw_output = str(net_connect.send_command(command, read_timeout=180))
net_connect.disconnect()
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE) clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip() clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ========================================== # ==========================================
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯 # 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
@ -331,12 +342,16 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "success", "data": perfect_tree} return {"status": "success", "data": perfect_tree}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config/stream") @router.get("/cmts-full-config/stream")
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"): async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置 (串流進度回報版)""" """獲取整台 CMTS 的完整配置 (串流進度回報版)"""
async def config_streamer(): async def config_streamer():
net_connect = None
try: try:
# 1. 廣播:正在連線 # 1. 廣播:正在連線
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n" yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
@ -360,8 +375,6 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
command = "show running-config | nomore" command = "show running-config | nomore"
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180) raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
await asyncio.to_thread(net_connect.disconnect)
# 3. 廣播:正在解析 # 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n" yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -369,8 +382,9 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE) clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip() clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ========================================== # ==========================================
# 🌟 深度過濾攔截器 (維持原樣) # 🌟 深度過濾攔截器 (維持原樣)
@ -399,6 +413,10 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
except Exception as e: except Exception as e:
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n" yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
finally:
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
if net_connect:
await asyncio.to_thread(net_connect.disconnect)
# 回傳 NDJSON 串流格式 # 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson") return StreamingResponse(config_streamer(), media_type="application/x-ndjson")

View File

@ -51,10 +51,12 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
async def event_generator(): async def event_generator():
try: try:
while True: while True:
# 主動檢查斷線,避免死鎖
if await request.is_disconnected(): if await request.is_disconnected():
break break
try: try:
message = await asyncio.wait_for(client_queue.get(), timeout=1.0) # [修復 Leak] 延長 timeout 降低輪詢負擔
message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
yield message yield message
except asyncio.TimeoutError: except asyncio.TimeoutError:
yield ": keepalive\n\n" yield ": keepalive\n\n"
@ -62,6 +64,7 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
# [修復 Leak] 確保斷線時 Queue 絕對會被移出 active_clients 釋放記憶體
if channel_key in active_clients: if channel_key in active_clients:
active_clients[channel_key].discard(client_queue) active_clients[channel_key].discard(client_queue)

View File

@ -30,26 +30,32 @@ def parse_cm_output(raw_text: str) -> list:
@router.get("/cable-modems") @router.get("/cable-modems")
async def get_cable_modems(): async def get_cable_modems():
net_connect = None
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) net_connect = ConnectHandler(**CMTS_DEVICE)
raw_output = str(net_connect.send_command("show cable modem")) raw_output = str(net_connect.send_command("show cable modem"))
net_connect.disconnect()
structured_data = parse_cm_output(raw_output) structured_data = parse_cm_output(raw_output)
return {"status": "success", "total_count": len(structured_data), "data": structured_data} return {"status": "success", "total_count": len(structured_data), "data": structured_data}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/configuration") @router.get("/configuration")
async def get_configuration(): async def get_configuration():
net_connect = None
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) net_connect = ConnectHandler(**CMTS_DEVICE)
net_connect.send_command_timing("config") net_connect.send_command_timing("config")
raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120) raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120)
net_connect.send_command_timing("exit") net_connect.send_command_timing("exit")
net_connect.disconnect()
return {"status": "success", "data": raw_output} return {"status": "success", "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-query") @router.get("/cmts-query")
async def get_cmts_query( async def get_cmts_query(
@ -59,6 +65,7 @@ async def get_cmts_query(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -107,14 +114,17 @@ async def get_cmts_query(
cli_command = commands[query_type] + " | nomore" cli_command = commands[query_type] + " | nomore"
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = net_connect.send_command(cli_command, read_timeout=15) raw_output = net_connect.send_command(cli_command, read_timeout=15)
net_connect.disconnect()
return {"status": "success", "command": cli_command, "data": raw_output} return {"status": "success", "command": cli_command, "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-config") @router.get("/cmts-mac-domain-config")
async def get_mac_domain_config(target: str, host: str, username: str, password: str): async def get_mac_domain_config(target: str, host: str, username: str, password: str):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -122,7 +132,6 @@ async def get_mac_domain_config(target: str, host: str, username: str, password:
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore" cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command(cli_command, read_timeout=15)) raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
net_connect.disconnect()
# 💡 正名工程:更新字典的 Key 為一致性的命名 # 💡 正名工程:更新字典的 Key 為一致性的命名
config = { config = {
@ -178,6 +187,9 @@ async def get_mac_domain_config(target: str, host: str, username: str, password:
return {"status": "success", "data": config} return {"status": "success", "data": config}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}") raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-list") @router.get("/cmts-mac-domain-list")
async def get_cmts_mac_domain_list( async def get_cmts_mac_domain_list(
@ -185,13 +197,13 @@ async def get_cmts_mac_domain_list(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?")) raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
net_connect.disconnect()
import re import re
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output) matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
@ -200,6 +212,9 @@ async def get_cmts_mac_domain_list(
return {"status": "success", "data": mac_domains} return {"status": "success", "data": mac_domains}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-version") @router.get("/cmts-version")
async def get_cmts_version( async def get_cmts_version(
@ -207,13 +222,13 @@ async def get_cmts_version(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command("show version", read_timeout=15)) raw_output = str(net_connect.send_command("show version", read_timeout=15))
net_connect.disconnect()
import re import re
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號 # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
@ -226,3 +241,6 @@ async def get_cmts_version(
except Exception as e: except Exception as e:
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"} return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
finally:
if net_connect:
net_connect.disconnect()

View File

@ -9,6 +9,9 @@ router = APIRouter()
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str): async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
await websocket.accept() await websocket.accept()
conn = None conn = None
process = None
ws_task = None
ssh_task = None
try: try:
# 建立連線 # 建立連線
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
@ -30,6 +33,8 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
safe_text = data_bytes.decode('utf-8', errors='replace') safe_text = data_bytes.decode('utf-8', errors='replace')
await websocket.send_text(safe_text) await websocket.send_text(safe_text)
except asyncio.CancelledError:
pass
except Exception as e: except Exception as e:
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:") print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
traceback.print_exc() traceback.print_exc()
@ -47,6 +52,9 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
process.stdin.write(data_bytes) process.stdin.write(data_bytes)
await process.stdin.drain() await process.stdin.drain()
except WebSocketDisconnect: except WebSocketDisconnect:
# 主動拋出,讓外層捕捉以進行資源回收
raise
except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:") print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
@ -63,6 +71,12 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
for task in pending: for task in pending:
task.cancel() task.cancel()
except WebSocketDisconnect:
print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...")
if ws_task and not ws_task.done():
ws_task.cancel()
if ssh_task and not ssh_task.done():
ssh_task.cancel()
except Exception as e: except Exception as e:
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n" error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
try: try:
@ -72,9 +86,13 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:") print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
traceback.print_exc() traceback.print_exc()
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process:
process.close()
if conn: if conn:
conn.close() conn.close()
try: try:
await websocket.close() await websocket.close()
except: except:
pass pass

View File

@ -258,5 +258,5 @@ def save_settings(settings_data):
with open(SETTINGS_FILE, "w", encoding="utf-8") as f: with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings_data, f, indent=4, ensure_ascii=False) json.dump(settings_data, f, indent=4, ensure_ascii=False)
# 全域非同步鎖:確保同一時間只有一人能執行設定寫入 # 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖
cmts_config_lock = asyncio.Lock() cmts_config_locks = defaultdict(asyncio.Lock)

View File

@ -19,7 +19,7 @@ import {
import { getGlobalConnectionInfo } from './utils.js'; import { getGlobalConnectionInfo } from './utils.js';
// 4. 樹狀圖 UI 模組 (Tree UI) // 4. 樹狀圖 UI 模組 (Tree UI)
import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-ui.js'; import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js';
// 5. 編輯模式與鎖定模組 (Edit Mode) // 5. 編輯模式與鎖定模組 (Edit Mode)
import { import {
@ -320,6 +320,9 @@ function toggleQueryInputs(mode) {
function resetAllManagerData() { function resetAllManagerData() {
resetMacDomainData(); resetMacDomainData();
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache();
const configArea = document.getElementById('macDomainConfigArea'); const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none'; if (configArea) configArea.style.display = 'none';

View File

@ -6,6 +6,12 @@
export const folderDataCache = {}; export const folderDataCache = {};
export const filterFolderCache = {}; export const filterFolderCache = {};
// 🧹 [修復 Leak] 徹底清空樹狀圖快取,釋放記憶體
export function clearTreeCache() {
for (let key in folderDataCache) delete folderDataCache[key];
for (let key in filterFolderCache) delete filterFolderCache[key];
}
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去 // 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
window.lazyLoadFolder = function(elementId, isFilter = false) { window.lazyLoadFolder = function(elementId, isFilter = false) {
const detailsEl = document.getElementById(`details-${elementId}`); const detailsEl = document.getElementById(`details-${elementId}`);
@ -68,8 +74,8 @@ export function collapseAll(elementId) {
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版) // 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ========================================== // ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') { export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
let html = ''; const htmlParts = [];
if (!node || typeof node !== 'object') return html; if (!node || typeof node !== 'object') return htmlParts.join('');
for (const [key, value] of Object.entries(node)) { for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key; const currentPath = path ? `${path}::${key}` : key;
@ -110,8 +116,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
`; `;
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder htmlParts.push(`
html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);"> ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
@ -152,9 +157,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
<!-- 🌟 延遲渲染這裡一開始是空的展開時才會填入 --> <!-- 🌟 延遲渲染這裡一開始是空的展開時才會填入 -->
</div> </div>
</details> </details>
`; `);
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no'); const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -199,7 +203,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
`; `;
} }
html += ` htmlParts.push(`
<div class="tree-leaf-node" <div class="tree-leaf-node"
title="${cliCommand}" title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -227,10 +231,10 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
</div> </div>
</div> </div>
`; `);
} }
} }
return html; return htmlParts.join('');
} }
// ========================================== // ==========================================
@ -239,7 +243,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') { export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
if (typeof data !== 'object' || data === null) return ''; if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`; const htmlParts = [];
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
for (const key in data) { for (const key in data) {
const value = data[key]; const value = data[key];
@ -289,7 +294,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
`; `;
// 🌟 加入 lazyLoadFolder 觸發 // 🌟 加入 lazyLoadFolder 觸發
html += ` htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" <details id="details-${elementId}" class="tree-folder"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);"> ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
@ -307,9 +312,8 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<!-- 🌟 延遲渲染 --> <!-- 🌟 延遲渲染 -->
</div> </div>
</details> </details>
`; `);
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`; let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -330,7 +334,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`; currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
} }
html += ` htmlParts.push(`
<div class="tree-leaf-node" <div class="tree-leaf-node"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'" onmouseout="this.style.backgroundColor='transparent'"
@ -343,9 +347,9 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<span style="margin-right: 5px;">${displayName}${colonHtml}</span> <span style="margin-right: 5px;">${displayName}${colonHtml}</span>
${valueHtml} ${valueHtml}
</div> </div>
`; `);
} }
} }
html += '</div>'; htmlParts.push('</div>');
return html; return htmlParts.join('');
} }