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): 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:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放 conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
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): 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()
# 🌟 精準 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")
await process.stdin.drain() await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
output_enter = await read_until_quiet(timeout=1.0) # 🌟 新增:在第一批次連線時,先抓取設備版本
enter_data = parse_device_response(output_enter) 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)
parsed_data["type"] = enter_data["type"] process.stdin.write("config\n")
parsed_data["options"] = enter_data["options"] await process.stdin.drain()
parsed_data["current_value"] = enter_data["current_value"] await read_until_quiet(timeout=1.5)
if enter_data["type"] != "unknown": for path in batch:
process.stdin.write("\x03") # 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await process.stdin.drain() await asyncio.sleep(0.01)
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) # --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
result_data[path] = parsed_data output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
processed_count += 1 backspaces = "\x08" * (len(command_to_send) + 5)
event_data = { process.stdin.write(backspaces)
"event": "progress", await process.stdin.drain()
"current": processed_count, await read_until_quiet(timeout=0.5)
"total": total_paths,
"current_path": path
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n") 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.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: 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())
@ -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: async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
"""連線至設備並抓取完整的純文字設定檔""" """連線至設備並抓取完整的純文字設定檔"""
conn = None
try: try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期 conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
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, prompt_pattern: str = None): 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()
# 🌟 精準 Prompt 偵測 except asyncio.TimeoutError:
if prompt_pattern and re.search(prompt_pattern, output): break
break return output
except asyncio.TimeoutError:
break
return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊 # 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") 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, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (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, prompt_pattern=r"\(config\)#") 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

@ -1,4 +1,3 @@
# --- routers/backup.py ---
import logging import logging
import asyncssh import asyncssh
import asyncio import asyncio
@ -18,8 +17,7 @@ from database import (
) )
from cmts_scraper import fetch_raw_config from cmts_scraper import fetch_raw_config
# 🌟 [Priority 1 修復] 引入 cmts_config_locks from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖
from shared import parse_cli_to_tree, cmts_config_locks
logger = logging.getLogger(__name__) 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") 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 host_lock.locked(): if cmts_config_lock.locked():
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n" yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
return return
async with host_lock: async with cmts_config_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')
# 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放 async def read_until_quiet(timeout=1.0):
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn: output = ""
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process: while True:
try:
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None): chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
output = "" if not chunk: break
while True: output += chunk
try: if "--More--" in chunk or "More" in chunk:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) process.stdin.write(" ")
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")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式 except asyncio.TimeoutError:
break
return output
# 3. 回報最終錯誤並關閉連線 # 2. 進入設定模式
yield json.dumps({ process.stdin.write("config\n")
"status": "error", await process.stdin.drain()
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}", await read_until_quiet(timeout=1.5)
"output": clean_out yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
}) + "\n"
return
yield json.dumps({ # 3. 逐行寫入並進行 Fail-safe 檢查
"status": "progress", for cmd in commands:
"message": f"執行: {cmd}", process.stdin.write(f"{cmd}\n")
"output": clean_out await process.stdin.drain()
}) + "\n" out = await read_until_quiet(timeout=0.2)
# 稍微讓出控制權,確保串流順暢 clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
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, prompt_pattern=r"\(config\)#")
# 5. 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
# 🌟 移除手動 conn.close(),交由 async with 處理
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
# 1. 先通知前端正在撤銷
yield json.dumps({ yield json.dumps({
"status": "success", "status": "progress",
"message": "✅ 所有差異還原指令已成功送出並 Commit", "message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
"output": clean_commit
}) + "\n" }) + "\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: except Exception as e:
logger.error(f"還原執行失敗: {str(e)}") logger.error(f"還原執行失敗: {str(e)}")
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
# 🌟 [Priority 1 修復] 引入 cmts_config_locks # 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB from shared import CMTS_DEVICE, cmts_config_lock, 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,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('!')] 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 隔離的鎖 # 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行
host_lock = cmts_config_locks[req.host] async with cmts_config_lock:
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"
@ -105,7 +104,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, prompt_pattern: str = None): async def read_until_quiet(timeout=0.5):
out_data = "" out_data = ""
while True: while True:
try: try:
@ -114,9 +113,6 @@ 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
@ -124,7 +120,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, prompt_pattern=r"\(config\)#") # 清空歡迎訊息 await read_until_quiet(0.5) # 清空歡迎訊息
# 2. 逐行送出指令並回報進度 # 2. 逐行送出指令並回報進度
for cmd in commands: for cmd in commands:
@ -134,7 +130,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, prompt_pattern=r"\(config.*\)#") output = await read_until_quiet(wait_time)
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort # 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower() lower_output = output.lower()
@ -152,7 +148,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, prompt_pattern=r"(?:#|>)") final_output = await read_until_quiet(1.0)
yield json.dumps({ yield json.dumps({
"status": "success", "status": "success",
@ -239,7 +235,6 @@ 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})
@ -247,6 +242,7 @@ 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)
@ -259,15 +255,11 @@ 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})
@ -275,10 +267,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()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool base_tree = parse_cli_to_tree(raw_output)
base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output) perfect_tree = deep_split_tree(base_tree)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
return { return {
"status": "success", "status": "success",
@ -287,15 +279,11 @@ 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})
@ -311,12 +299,13 @@ 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()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool base_tree = parse_cli_to_tree(clean_output)
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output) perfect_tree = deep_split_tree(base_tree)
perfect_tree = await asyncio.to_thread(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} 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"
@ -375,6 +360,8 @@ 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)
@ -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 = 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()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool base_tree = parse_cli_to_tree(clean_output)
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output) perfect_tree = deep_split_tree(base_tree)
perfect_tree = await asyncio.to_thread(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: 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,12 +51,10 @@ 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:
# [修復 Leak] 延長 timeout 降低輪詢負擔 message = await asyncio.wait_for(client_queue.get(), timeout=1.0)
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"
@ -64,7 +62,6 @@ 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,32 +30,26 @@ 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(
@ -65,7 +59,6 @@ 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})
@ -114,17 +107,14 @@ 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})
@ -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" 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 = {
@ -187,9 +178,6 @@ 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(
@ -197,13 +185,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)
@ -212,9 +200,6 @@ 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(
@ -222,13 +207,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 後面的版本號
@ -241,6 +226,3 @@ 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,9 +9,6 @@ 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)
@ -33,8 +30,6 @@ 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()
@ -52,9 +47,6 @@ 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] 寫入指令到設備時發生錯誤:")
@ -71,12 +63,6 @@ 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:
@ -86,13 +72,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:
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_locks = defaultdict(asyncio.Lock) cmts_config_lock = 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, clearTreeCache } from './tree-ui.js'; import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-ui.js';
// 5. 編輯模式與鎖定模組 (Edit Mode) // 5. 編輯模式與鎖定模組 (Edit Mode)
import { import {
@ -320,9 +320,6 @@ 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,12 +6,6 @@
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}`);
@ -74,8 +68,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') {
const htmlParts = []; let html = '';
if (!node || typeof node !== 'object') return htmlParts.join(''); if (!node || typeof node !== 'object') return html;
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;
@ -116,7 +110,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
`; `;
htmlParts.push(` // 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
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);">
@ -157,8 +152,9 @@ 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;");
@ -203,7 +199,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
`; `;
} }
htmlParts.push(` html += `
<div class="tree-leaf-node" <div class="tree-leaf-node"
title="${cliCommand}" title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -231,10 +227,10 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
</div> </div>
</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') { export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
if (typeof data !== 'object' || data === null) return ''; if (typeof data !== 'object' || data === null) return '';
const htmlParts = []; let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
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];
@ -294,7 +289,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
`; `;
// 🌟 加入 lazyLoadFolder 觸發 // 🌟 加入 lazyLoadFolder 觸發
htmlParts.push(` html += `
<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);">
@ -312,8 +307,9 @@ 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;");
@ -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>`; 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" <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'"
@ -347,9 +343,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>
`); `;
} }
} }
htmlParts.push('</div>'); html += '</div>';
return htmlParts.join(''); return html;
} }