Compare commits
No commits in common. "2168ccf7702f02876027974b36e473981f7ed8d9" and "d0d80b26ace98f0aeb2ef74ed314571582c00747" have entirely different histories.
2168ccf770
...
d0d80b26ac
614
all_code.txt
614
all_code.txt
File diff suppressed because it is too large
Load Diff
299
cmts_scraper.py
299
cmts_scraper.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, """);
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
|
@ -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, """);
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue