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)。
This commit is contained in:
swpa 2026-05-31 16:15:44 +08:00
parent 4437e4e3fa
commit 60195a735e
5 changed files with 481 additions and 420 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,110 +1423,108 @@ 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):
output = "" output = ""
while True: while True:
try: try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break if not chunk: break
output += chunk output += chunk
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()
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
# 🌟 新增:在第一批次連線時,先抓取設備版本 # 🌟 新增:在第一批次連線時,先抓取設備版本
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)
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 read_until_quiet(timeout=1.5)
for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
parsed_data = {
"hint": q_data["hint"],
"options": q_data["options"],
"type": "list" if q_data["options"] else "unknown",
"current_value": q_data.get("current_value")
}
# --- 步驟 2判斷是否需要發送 Enter ---
if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number"
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5)
output_enter = await read_until_quiet(timeout=1.0) for path in batch:
enter_data = parse_device_response(output_enter) # 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
parsed_data["type"] = enter_data["type"] # --- 步驟 1發送 '?' 查詢 ---
parsed_data["options"] = enter_data["options"] command_to_send = f"{path} ?"
parsed_data["current_value"] = enter_data["current_value"] process.stdin.write(command_to_send)
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=0.5) output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
result_data[path] = parsed_data backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
processed_count += 1 parsed_data = {
event_data = { "hint": q_data["hint"],
"event": "progress", "options": q_data["options"],
"current": processed_count, "type": "list" if q_data["options"] else "unknown",
"total": total_paths, "current_value": q_data.get("current_value")
"current_path": path }
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n") # --- 步驟 2判斷是否需要發送 Enter ---
await process.stdin.drain() if q_data.get("is_format_only"):
await read_until_quiet(timeout=1.0) parsed_data["type"] = "string_or_number"
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain()
output_enter = await read_until_quiet(timeout=1.0)
enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"]
parsed_data["options"] = enter_data["options"]
parsed_data["current_value"] = enter_data["current_value"]
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
processed_count += 1
event_data = {
"event": "progress",
"current": processed_count,
"total": total_paths,
"current_path": path
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0)
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:
db_success = False db_success = False
current_ts = int(time.time()) current_ts = int(time.time())
@ -1606,83 +1604,84 @@ 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):
output = "" output = ""
while True: while True:
try: try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break if not chunk: break
output += chunk output += chunk
# 自動翻頁 # 自動翻頁
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()
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)
# 關鍵修改:根據 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) # 等待提示字元變成 (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)
# 3. 抓完後退回上一層 (保持良好習慣) # 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
else: else:
# running-config 在一般模式即可下達 (🌟 加上 | nomore) # running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore" cmd = "show running-config | 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)
# 最終退出設備 # 最終退出設備
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo) # 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h) # 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output) cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K) # 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output) cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 3. 清除純文字的 --More-- (防呆) # 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output) cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記 # 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output) cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行! # 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines() lines = cleaned_output.splitlines()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元 # 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [ clean_lines = [
line for line in lines line for line in lines
if not line.strip().startswith(cmd) if not line.strip().startswith(cmd)
and not line.strip().startswith("admin@") and not line.strip().startswith("admin@")
] ]
return "\n".join(clean_lines).strip() return "\n".join(clean_lines).strip()
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 +2037,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 +2338,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 +5059,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}`);
@ -5404,6 +5412,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
================================================================================ ================================================================================
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 +5432,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,93 +5639,98 @@ 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 絕對會被釋放
output = "" async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
while True: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
# 2. 進入設定模式 async def read_until_quiet(timeout=1.0):
process.stdin.write("config\n") output = ""
await process.stdin.drain() while True:
await read_until_quiet(timeout=1.5) try:
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n" chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
# 3. 逐行寫入並進行 Fail-safe 檢查 # 2. 進入設定模式
for cmd in commands: process.stdin.write("config\n")
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
out = await read_until_quiet(timeout=0.2)
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
# 1. 先通知前端正在撤銷
yield json.dumps({
"status": "progress",
"message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
}) + "\n"
# 2. 對設備下達 abort 指令
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.5)
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
# 3. 逐行寫入並進行 Fail-safe 檢查
for cmd in commands:
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
out = await read_until_quiet(timeout=0.2)
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
# 1. 先通知前端正在撤銷
yield json.dumps({
"status": "progress",
"message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
}) + "\n"
# 2. 對設備下達 abort 指令
process.stdin.write("abort\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式
# 3. 回報最終錯誤並關閉連線
yield json.dumps({
"status": "error",
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
"output": clean_out
}) + "\n"
# 🌟 移除手動 conn.close(),交由 async with 處理
return
yield json.dumps({
"status": "progress",
"message": f"執行: {cmd}",
"output": clean_out
}) + "\n"
# 稍微讓出控制權,確保串流順暢
await asyncio.sleep(0.05)
# 4. 提交變更 (Commit)
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write("commit\n")
await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0)
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
# 🌟 移除手動 conn.close(),交由 async with 處理
# 3. 回報最終錯誤並關閉連線
yield json.dumps({ yield json.dumps({
"status": "error", "status": "success",
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}", "message": "✅ 所有差異還原指令已成功送出並 Commit",
"output": clean_out "output": clean_commit
}) + "\n" }) + "\n"
conn.close()
return
yield json.dumps({
"status": "progress",
"message": f"執行: {cmd}",
"output": clean_out
}) + "\n"
# 稍微讓出控制權,確保串流順暢
await asyncio.sleep(0.05)
# 4. 提交變更 (Commit)
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write("commit\n")
await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0)
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
conn.close()
yield json.dumps({
"status": "success",
"message": "✅ 所有差異還原指令已成功送出並 Commit",
"output": clean_commit
}) + "\n"
except Exception as e: except Exception as e:
logger.error(f"還原執行失敗: {str(e)}") logger.error(f"還原執行失敗: {str(e)}")
@ -5724,7 +5739,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
================================================================================ ================================================================================
@ -5954,26 +5968,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 +6003,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 +6052,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 +6070,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 +6125,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 +6135,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 +6150,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 +6160,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 +6179,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 +6198,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 +6278,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"
@ -6392,6 +6425,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 +6433,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 +6445,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 +6461,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 +6473,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 +6497,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 +6528,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 +6561,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 +6568,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 +6599,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 +6685,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 +6698,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,110 +157,108 @@ 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):
output = "" output = ""
while True: while True:
try: try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break if not chunk: break
output += chunk output += chunk
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()
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
# 🌟 新增:在第一批次連線時,先抓取設備版本 # 🌟 新增:在第一批次連線時,先抓取設備版本
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)
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 read_until_quiet(timeout=1.5)
for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
parsed_data = {
"hint": q_data["hint"],
"options": q_data["options"],
"type": "list" if q_data["options"] else "unknown",
"current_value": q_data.get("current_value")
}
# --- 步驟 2判斷是否需要發送 Enter ---
if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number"
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5)
output_enter = await read_until_quiet(timeout=1.0) for path in batch:
enter_data = parse_device_response(output_enter) # 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
parsed_data["type"] = enter_data["type"] # --- 步驟 1發送 '?' 查詢 ---
parsed_data["options"] = enter_data["options"] command_to_send = f"{path} ?"
parsed_data["current_value"] = enter_data["current_value"] process.stdin.write(command_to_send)
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=0.5) output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
result_data[path] = parsed_data backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
processed_count += 1 parsed_data = {
event_data = { "hint": q_data["hint"],
"event": "progress", "options": q_data["options"],
"current": processed_count, "type": "list" if q_data["options"] else "unknown",
"total": total_paths, "current_value": q_data.get("current_value")
"current_path": path }
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n") # --- 步驟 2判斷是否需要發送 Enter ---
await process.stdin.drain() if q_data.get("is_format_only"):
await read_until_quiet(timeout=1.0) parsed_data["type"] = "string_or_number"
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain()
output_enter = await read_until_quiet(timeout=1.0)
enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"]
parsed_data["options"] = enter_data["options"]
parsed_data["current_value"] = enter_data["current_value"]
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
processed_count += 1
event_data = {
"event": "progress",
"current": processed_count,
"total": total_paths,
"current_path": path
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0)
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:
db_success = False db_success = False
current_ts = int(time.time()) current_ts = int(time.time())
@ -340,83 +338,84 @@ 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):
output = "" output = ""
while True: while True:
try: try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break if not chunk: break
output += chunk output += chunk
# 自動翻頁 # 自動翻頁
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()
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)
# 關鍵修改:根據 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) # 等待提示字元變成 (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)
# 3. 抓完後退回上一層 (保持良好習慣) # 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
else: else:
# running-config 在一般模式即可下達 (🌟 加上 | nomore) # running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore" cmd = "show running-config | 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)
# 最終退出設備 # 最終退出設備
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo) # 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h) # 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output) cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K) # 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output) cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 3. 清除純文字的 --More-- (防呆) # 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output) cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記 # 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output) cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行! # 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines() lines = cleaned_output.splitlines()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元 # 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [ clean_lines = [
line for line in lines line for line in lines
if not line.strip().startswith(cmd) if not line.strip().startswith(cmd)
and not line.strip().startswith("admin@") and not line.strip().startswith("admin@")
] ]
return "\n".join(clean_lines).strip() return "\n".join(clean_lines).strip()
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

@ -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

@ -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}`);