Compare commits

..

No commits in common. "2168ccf7702f02876027974b36e473981f7ed8d9" and "d0d80b26ace98f0aeb2ef74ed314571582c00747" have entirely different histories.

10 changed files with 566 additions and 718 deletions

File diff suppressed because it is too large Load Diff

View File

@ -157,111 +157,110 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
conn = None
try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
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:
conn = await asyncssh.connect(host, username=username, password=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, prompt_pattern: str = None):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError:
break
return output
# 🌟 新增:在第一批次連線時,先抓取設備版本
if cmts_version == "unknown":
process.stdin.write("show version\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match:
cmts_version = match.group(1)
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
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")
async def read_until_quiet(timeout=1.0):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
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)
except asyncio.TimeoutError:
break
return output
result_data[path] = parsed_data
# 🌟 新增:在第一批次連線時,先抓取設備版本
if cmts_version == "unknown":
process.stdin.write("show version\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5)
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match:
cmts_version = match.group(1)
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"
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")
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0)
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:
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue
finally:
if conn: conn.close()
# --- 步驟 3寫入快取 (DB or JSON) ---
# --- 步驟 3寫入快取 (DB or JSON) ---
try:
db_success = False
current_ts = int(time.time())
@ -341,87 +340,83 @@ 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:
"""連線至設備並抓取完整的純文字設定檔"""
conn = None
try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期
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:
conn = await asyncssh.connect(host, username=username, password=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=2.0, prompt_pattern: str = None):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
# 自動翻頁
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError:
break
return output
async def read_until_quiet(timeout=2.0):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
# 自動翻頁
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0)
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
if config_type == "full":
# 1. 先進入 config 模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
# 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n")
await process.stdin.drain()
else:
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
# 最終退出設備
process.stdin.write("exit\n")
await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
if config_type == "full":
# 1. 先進入 config 模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
# 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n")
await process.stdin.drain()
else:
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
# 最終退出設備
process.stdin.write("exit\n")
await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines()
# 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [
line for line in lines
if not line.strip().startswith(cmd)
and not line.strip().startswith("admin@")
]
return "\n".join(clean_lines).strip()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [
line for line in lines
if not line.strip().startswith(cmd)
and not line.strip().startswith("admin@")
]
return "\n".join(clean_lines).strip()
except Exception as e:
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
finally:
if conn:
conn.close()

View File

@ -1,4 +1,3 @@
# --- routers/backup.py ---
import logging
import asyncssh
import asyncio
@ -18,8 +17,7 @@ from database import (
)
from cmts_scraper import fetch_raw_config
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖
logger = logging.getLogger(__name__)
@ -225,102 +223,97 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
return StreamingResponse(empty_success(), media_type="application/x-ndjson")
async def restore_generator():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = cmts_config_locks[req.host]
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
if host_lock.locked():
if cmts_config_lock.locked():
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
return
async with host_lock:
async with cmts_config_lock:
try:
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
# 🌟 [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:
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, prompt_pattern: str = None):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError:
break
return output
# 2. 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
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, prompt_pattern=r"\(config.*\)#")
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")
async def read_until_quiet(timeout=1.0):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
# 3. 回報最終錯誤並關閉連線
yield json.dumps({
"status": "error",
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
"output": clean_out
}) + "\n"
return
except asyncio.TimeoutError:
break
return output
yield json.dumps({
"status": "progress",
"message": f"執行: {cmd}",
"output": clean_out
}) + "\n"
# 稍微讓出控制權,確保串流順暢
await asyncio.sleep(0.05)
# 2. 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5)
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
# 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, prompt_pattern=r"\(config\)#")
# 5. 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
# 🌟 移除手動 conn.close(),交由 async with 處理
# 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": "success",
"message": "✅ 所有差異還原指令已成功送出並 Commit",
"output": clean_commit
"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()
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:
logger.error(f"還原執行失敗: {str(e)}")
yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n"
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 typing import List
from netmiko import ConnectHandler
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
# 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict
from fastapi.responses import StreamingResponse
@ -89,9 +89,8 @@ async def execute_cmts_config(req: ConfigRequest):
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
async def config_streamer():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = cmts_config_locks[req.host]
async with host_lock:
# 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行
async with cmts_config_lock:
try:
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
@ -105,7 +104,7 @@ async def execute_cmts_config(req: ConfigRequest):
async with conn.create_process() as process:
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
async def read_until_quiet(timeout=0.5):
out_data = ""
while True:
try:
@ -114,9 +113,6 @@ async def execute_cmts_config(req: ConfigRequest):
if not chunk:
break
out_data += str(chunk)
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, out_data):
break
except asyncio.TimeoutError:
break
return out_data
@ -124,7 +120,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 1. 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
await read_until_quiet(0.5) # 清空歡迎訊息
# 2. 逐行送出指令並回報進度
for cmd in commands:
@ -134,7 +130,7 @@ async def execute_cmts_config(req: ConfigRequest):
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
output = await read_until_quiet(wait_time)
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower()
@ -152,7 +148,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 3. 退出設定模式
process.stdin.write("exit\n")
await process.stdin.drain()
final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
final_output = await read_until_quiet(1.0)
yield json.dumps({
"status": "success",
@ -239,7 +235,6 @@ async def generate_cli(req: GenerateCliRequest):
@router.get("/cmts-ds-rf-port-list")
async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
"""獲取設備上所有的 DS RF Port 清單"""
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
@ -247,6 +242,7 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
net_connect = ConnectHandler(**device)
command = 'show running-config | include "cable ds-rf-port"'
raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output)
@ -259,15 +255,11 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-ds-rf-port-config")
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
@ -275,10 +267,10 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
base_tree = parse_cli_to_tree(raw_output)
perfect_tree = deep_split_tree(base_tree)
return {
"status": "success",
@ -287,15 +279,11 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
@ -311,12 +299,13 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
command = "show running-config | nomore"
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 = clean_output.lstrip()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
base_tree = parse_cli_to_tree(clean_output)
perfect_tree = deep_split_tree(base_tree)
# ==========================================
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
@ -342,16 +331,12 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "success", "data": perfect_tree}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@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"):
"""獲取整台 CMTS 的完整配置 (串流進度回報版)"""
async def config_streamer():
net_connect = None
try:
# 1. 廣播:正在連線
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
@ -375,6 +360,8 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
command = "show running-config | nomore"
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
await asyncio.to_thread(net_connect.disconnect)
# 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1)
@ -382,9 +369,8 @@ 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 = clean_output.lstrip()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
base_tree = parse_cli_to_tree(clean_output)
perfect_tree = deep_split_tree(base_tree)
# ==========================================
# 🌟 深度過濾攔截器 (維持原樣)
@ -413,10 +399,6 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
except Exception as e:
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
finally:
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
if net_connect:
await asyncio.to_thread(net_connect.disconnect)
# 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,12 +6,6 @@
export const folderDataCache = {};
export const filterFolderCache = {};
// 🧹 [修復 Leak] 徹底清空樹狀圖快取,釋放記憶體
export function clearTreeCache() {
for (let key in folderDataCache) delete folderDataCache[key];
for (let key in filterFolderCache) delete filterFolderCache[key];
}
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
window.lazyLoadFolder = function(elementId, isFilter = false) {
const detailsEl = document.getElementById(`details-${elementId}`);
@ -74,8 +68,8 @@ export function collapseAll(elementId) {
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
const htmlParts = [];
if (!node || typeof node !== 'object') return htmlParts.join('');
let html = '';
if (!node || typeof node !== 'object') return html;
for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key;
@ -116,7 +110,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span>
`;
htmlParts.push(`
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
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);">
@ -157,8 +152,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
<!-- 🌟 延遲渲染這裡一開始是空的展開時才會填入 -->
</div>
</details>
`);
`;
} else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -203,7 +199,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
`;
}
htmlParts.push(`
html += `
<div class="tree-leaf-node"
title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -231,10 +227,10 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span>
</div>
</div>
`);
`;
}
}
return htmlParts.join('');
return html;
}
// ==========================================
@ -243,8 +239,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
if (typeof data !== 'object' || data === null) return '';
const htmlParts = [];
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
for (const key in data) {
const value = data[key];
@ -294,7 +289,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
`;
// 🌟 加入 lazyLoadFolder 觸發
htmlParts.push(`
html += `
<details id="details-${elementId}" class="tree-folder"
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);">
@ -312,8 +307,9 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<!-- 🌟 延遲渲染 -->
</div>
</details>
`);
`;
} else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -334,7 +330,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>`;
}
htmlParts.push(`
html += `
<div class="tree-leaf-node"
onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'"
@ -347,9 +343,9 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
${valueHtml}
</div>
`);
`;
}
}
htmlParts.push('</div>');
return htmlParts.join('');
html += '</div>';
return html;
}