2026-04-29 10:00:40 +00:00
|
|
|
|
# --- routers/config.py ---
|
2026-04-30 08:27:18 +00:00
|
|
|
|
import re
|
2026-05-19 10:25:10 +00:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
2026-05-20 09:19:40 +00:00
|
|
|
|
import database
|
2026-05-29 08:21:43 +00:00
|
|
|
|
import asyncio
|
|
|
|
|
|
import asyncssh
|
2026-04-29 10:00:40 +00:00
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
|
|
from pydantic import BaseModel
|
2026-04-30 08:27:18 +00:00
|
|
|
|
from typing import List
|
2026-04-29 10:00:40 +00:00
|
|
|
|
from netmiko import ConnectHandler
|
2026-05-31 06:24:10 +00:00
|
|
|
|
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
|
|
|
|
|
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
|
2026-05-04 10:26:12 +00:00
|
|
|
|
from collections import defaultdict
|
2026-05-29 08:21:43 +00:00
|
|
|
|
from fastapi.responses import StreamingResponse
|
2026-06-05 10:29:04 +00:00
|
|
|
|
from logger import get_all_log_levels, set_log_level
|
2026-04-29 10:00:40 +00:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# ==========================================
|
2026-05-20 09:19:40 +00:00
|
|
|
|
# 🌟 雙軌過濾器檔案讀寫輔助函數 (加入高可用性 Fallback)
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
def get_filter_file_path(config_type: str) -> str:
|
|
|
|
|
|
# 確保檔名安全,只允許 'running' 或 'full'
|
|
|
|
|
|
safe_type = "full" if config_type == "full" else "running"
|
|
|
|
|
|
return f"filters_{safe_type}.json"
|
|
|
|
|
|
|
2026-05-20 09:19:40 +00:00
|
|
|
|
async def load_tree_filters(config_type: str) -> list:
|
|
|
|
|
|
if USE_DB:
|
|
|
|
|
|
try:
|
|
|
|
|
|
db_filters = await database.get_tree_filters(config_type)
|
|
|
|
|
|
if db_filters is not None:
|
|
|
|
|
|
return db_filters
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("⚠️ [Fallback] 資料庫無法取得過濾器,自動切換至 JSON 快取讀取...")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"⚠️ [Fallback] 資料庫讀取過濾器發生例外: {e},自動切換至 JSON 快取讀取...")
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
file_path = get_filter_file_path(config_type)
|
|
|
|
|
|
if os.path.exists(file_path):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
|
data = json.load(f)
|
|
|
|
|
|
return data.get("hidden_keys", [])
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"讀取過濾器檔案失敗: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2026-05-20 09:19:40 +00:00
|
|
|
|
async def save_tree_filters(config_type: str, hidden_keys: list):
|
|
|
|
|
|
db_success = False
|
|
|
|
|
|
if USE_DB:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if await database.upsert_tree_filters(config_type, hidden_keys):
|
|
|
|
|
|
db_success = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("⚠️ [Fallback] 資料庫儲存過濾器失敗,自動切換至 JSON 快取寫入...")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"⚠️ [Fallback] 資料庫寫入過濾器發生例外: {e},自動切換至 JSON 快取寫入...")
|
|
|
|
|
|
|
|
|
|
|
|
if not db_success:
|
|
|
|
|
|
file_path = get_filter_file_path(config_type)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
|
|
|
|
json.dump({"hidden_keys": hidden_keys}, f, ensure_ascii=False, indent=4)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"儲存過濾器檔案失敗: {e}")
|
2026-05-19 10:25:10 +00:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
2026-04-29 10:00:40 +00:00
|
|
|
|
class ConfigRequest(BaseModel):
|
|
|
|
|
|
script: str
|
|
|
|
|
|
host: str
|
|
|
|
|
|
username: str
|
|
|
|
|
|
password: str
|
|
|
|
|
|
|
2026-05-04 10:26:12 +00:00
|
|
|
|
# 定義前端傳來的 Diff 資料結構
|
|
|
|
|
|
class DiffItem(BaseModel):
|
|
|
|
|
|
path: str # 例如: "cable.ds-rf-port.1:9/0.down-channel.0.frequency"
|
|
|
|
|
|
old_val: str
|
|
|
|
|
|
new_val: str
|
|
|
|
|
|
|
|
|
|
|
|
class GenerateCliRequest(BaseModel):
|
|
|
|
|
|
diffs: List[DiffItem]
|
2026-05-19 10:25:10 +00:00
|
|
|
|
interfaces_with_admin_state: List[str] = []
|
2026-05-04 10:26:12 +00:00
|
|
|
|
|
2026-04-29 10:00:40 +00:00
|
|
|
|
@router.post("/cmts-config")
|
|
|
|
|
|
async def execute_cmts_config(req: ConfigRequest):
|
2026-05-29 08:21:43 +00:00
|
|
|
|
# 過濾掉空行與註解 (! 開頭的行)
|
|
|
|
|
|
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
|
|
|
|
|
|
|
|
|
|
|
|
async def config_streamer():
|
2026-05-31 06:24:10 +00:00
|
|
|
|
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
|
|
|
|
|
host_lock = cmts_config_locks[req.host]
|
|
|
|
|
|
async with host_lock:
|
2026-05-29 08:21:43 +00:00
|
|
|
|
try:
|
|
|
|
|
|
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
# 使用 asyncssh 建立非同步連線
|
|
|
|
|
|
async with asyncssh.connect(
|
|
|
|
|
|
req.host,
|
|
|
|
|
|
username=req.username,
|
|
|
|
|
|
password=req.password,
|
|
|
|
|
|
known_hosts=None
|
|
|
|
|
|
) as conn:
|
|
|
|
|
|
async with conn.create_process() as process:
|
|
|
|
|
|
|
|
|
|
|
|
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
2026-05-31 10:19:12 +00:00
|
|
|
|
async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
|
2026-05-29 08:21:43 +00:00
|
|
|
|
out_data = ""
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 利用 wait_for 設定超時,時間內沒資料就當作設備吐完了
|
|
|
|
|
|
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
|
|
|
|
|
if not chunk:
|
|
|
|
|
|
break
|
|
|
|
|
|
out_data += str(chunk)
|
2026-05-31 10:19:12 +00:00
|
|
|
|
# 🌟 精準 Prompt 偵測
|
|
|
|
|
|
if prompt_pattern and re.search(prompt_pattern, out_data):
|
|
|
|
|
|
break
|
2026-05-29 08:21:43 +00:00
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
break
|
|
|
|
|
|
return out_data
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 進入設定模式
|
|
|
|
|
|
process.stdin.write("config\n")
|
|
|
|
|
|
await process.stdin.drain()
|
2026-05-31 10:19:12 +00:00
|
|
|
|
await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
|
2026-05-29 08:21:43 +00:00
|
|
|
|
|
|
|
|
|
|
# 2. 逐行送出指令並回報進度
|
|
|
|
|
|
for cmd in commands:
|
|
|
|
|
|
yield json.dumps({"status": "progress", "message": f"▶️ 執行: {cmd}"}) + "\n"
|
|
|
|
|
|
process.stdin.write(cmd + "\n")
|
|
|
|
|
|
await process.stdin.drain()
|
|
|
|
|
|
|
|
|
|
|
|
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
|
|
|
|
|
|
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
|
2026-05-31 10:19:12 +00:00
|
|
|
|
output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
|
2026-05-29 08:21:43 +00:00
|
|
|
|
|
|
|
|
|
|
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
|
|
|
|
|
|
lower_output = output.lower()
|
|
|
|
|
|
if "% invalid" in lower_output or "% incomplete" in lower_output or "error" in lower_output or "aborted" in lower_output:
|
|
|
|
|
|
yield json.dumps({
|
|
|
|
|
|
"status": "error",
|
|
|
|
|
|
"message": f"❌ 設備拒絕指令: {cmd}",
|
|
|
|
|
|
"output": output.strip()
|
|
|
|
|
|
}) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
process.stdin.write("abort\n")
|
|
|
|
|
|
await process.stdin.drain()
|
|
|
|
|
|
return # 發生錯誤,提早結束串流
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 退出設定模式
|
|
|
|
|
|
process.stdin.write("exit\n")
|
|
|
|
|
|
await process.stdin.drain()
|
2026-05-31 10:19:12 +00:00
|
|
|
|
final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
|
2026-05-29 08:21:43 +00:00
|
|
|
|
|
|
|
|
|
|
yield json.dumps({
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"message": "✅ 所有配置已成功寫入並 Commit!",
|
|
|
|
|
|
"output": final_output.strip()
|
|
|
|
|
|
}) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
yield json.dumps({"status": "error", "message": f"SSH 執行發生例外錯誤: {str(e)}"}) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
# 回傳 NDJSON 串流格式
|
|
|
|
|
|
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
|
2026-05-04 10:26:12 +00:00
|
|
|
|
|
|
|
|
|
|
@router.post("/cmts-generate-cli")
|
|
|
|
|
|
async def generate_cli(req: GenerateCliRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
|
|
|
|
|
|
並自動處理 admin-state 的安全生命週期 (先 down 後 up)
|
|
|
|
|
|
"""
|
|
|
|
|
|
interface_groups = defaultdict(list)
|
|
|
|
|
|
interface_admin_states = {}
|
|
|
|
|
|
|
|
|
|
|
|
for diff in req.diffs:
|
|
|
|
|
|
parts = diff.path.split('::')
|
|
|
|
|
|
|
|
|
|
|
|
param_name = parts[-1]
|
2026-05-08 08:50:12 +00:00
|
|
|
|
interface_path = " ".join(parts[:-1])
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# 全域清除路徑中間可能夾帶的虛擬資料夾索引
|
2026-05-08 08:50:12 +00:00
|
|
|
|
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
|
2026-05-04 10:26:12 +00:00
|
|
|
|
|
|
|
|
|
|
if param_name == "admin-state":
|
|
|
|
|
|
interface_admin_states[interface_path] = diff.new_val
|
|
|
|
|
|
else:
|
2026-05-08 08:50:12 +00:00
|
|
|
|
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
|
2026-05-04 10:26:12 +00:00
|
|
|
|
|
|
|
|
|
|
cli_lines = []
|
|
|
|
|
|
cli_lines.append("! --- Auto-Generated Configuration Script ---")
|
|
|
|
|
|
|
|
|
|
|
|
for interface, changes in interface_groups.items():
|
|
|
|
|
|
cli_lines.append(f"! Configuring: {interface}")
|
|
|
|
|
|
|
2026-05-08 08:50:12 +00:00
|
|
|
|
supports_admin_state = interface in req.interfaces_with_admin_state
|
|
|
|
|
|
|
|
|
|
|
|
if supports_admin_state:
|
|
|
|
|
|
cli_lines.append(f"{interface} admin-state down")
|
2026-05-04 10:26:12 +00:00
|
|
|
|
|
2026-05-08 08:50:12 +00:00
|
|
|
|
for param, old_val, new_val in changes:
|
|
|
|
|
|
if re.match(r"^\[\d+\]$", param):
|
|
|
|
|
|
if new_val == "":
|
|
|
|
|
|
if old_val:
|
|
|
|
|
|
cli_lines.append(f"no {interface} {old_val}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
if old_val and old_val != new_val:
|
|
|
|
|
|
cli_lines.append(f"no {interface} {old_val}")
|
|
|
|
|
|
cli_lines.append(f"{interface} {new_val}")
|
2026-05-04 10:26:12 +00:00
|
|
|
|
else:
|
2026-05-08 08:50:12 +00:00
|
|
|
|
if new_val == "":
|
|
|
|
|
|
cli_lines.append(f"no {interface} {param}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
cli_lines.append(f"{interface} {param} {new_val}")
|
2026-05-04 10:26:12 +00:00
|
|
|
|
|
2026-05-08 08:50:12 +00:00
|
|
|
|
if supports_admin_state:
|
|
|
|
|
|
final_state = interface_admin_states.get(interface, "up")
|
|
|
|
|
|
cli_lines.append(f"{interface} admin-state {final_state}")
|
|
|
|
|
|
|
2026-05-04 10:26:12 +00:00
|
|
|
|
cli_lines.append("commit")
|
2026-05-19 10:25:10 +00:00
|
|
|
|
cli_lines.append("!")
|
2026-05-04 10:26:12 +00:00
|
|
|
|
|
|
|
|
|
|
for interface, state in interface_admin_states.items():
|
|
|
|
|
|
if interface not in interface_groups:
|
|
|
|
|
|
cli_lines.append(f"! Configuring state only: {interface}")
|
|
|
|
|
|
cli_lines.append(f"{interface} admin-state {state}")
|
|
|
|
|
|
cli_lines.append("commit")
|
|
|
|
|
|
cli_lines.append("!")
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
2026-05-04 10:26:12 +00:00
|
|
|
|
final_script = "\n".join(cli_lines)
|
|
|
|
|
|
return {"status": "success", "data": final_script}
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# DS RF Port 動態樹狀查詢 API
|
2026-04-30 08:27:18 +00:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/cmts-ds-rf-port-list")
|
|
|
|
|
|
async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
|
|
|
|
|
|
"""獲取設備上所有的 DS RF Port 清單"""
|
2026-05-31 06:24:10 +00:00
|
|
|
|
net_connect = None
|
2026-04-30 08:27:18 +00:00
|
|
|
|
try:
|
|
|
|
|
|
device = CMTS_DEVICE.copy()
|
|
|
|
|
|
device.update({'host': host, 'username': username, 'password': password})
|
|
|
|
|
|
|
|
|
|
|
|
net_connect = ConnectHandler(**device)
|
|
|
|
|
|
command = 'show running-config | include "cable ds-rf-port"'
|
2026-05-19 10:25:10 +00:00
|
|
|
|
raw_output = str(net_connect.send_command(command, read_timeout=60))
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
|
|
|
|
|
|
ports = re.findall(pattern, raw_output)
|
|
|
|
|
|
unique_ports = sorted(list(set(ports)))
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"data": unique_ports,
|
|
|
|
|
|
"raw_output": raw_output
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return {"status": "error", "message": str(e)}
|
2026-05-31 06:24:10 +00:00
|
|
|
|
finally:
|
|
|
|
|
|
if net_connect:
|
|
|
|
|
|
net_connect.disconnect()
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/cmts-ds-rf-port-config")
|
|
|
|
|
|
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
|
|
|
|
|
|
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
|
2026-05-31 06:24:10 +00:00
|
|
|
|
net_connect = None
|
2026-04-30 08:27:18 +00:00
|
|
|
|
try:
|
|
|
|
|
|
device = CMTS_DEVICE.copy()
|
|
|
|
|
|
device.update({'host': host, 'username': username, 'password': password})
|
|
|
|
|
|
|
|
|
|
|
|
net_connect = ConnectHandler(**device)
|
|
|
|
|
|
command = f"show running-config interface cable ds-rf-port {target} | nomore"
|
2026-05-19 10:25:10 +00:00
|
|
|
|
raw_output = str(net_connect.send_command(command, read_timeout=60))
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
2026-05-31 06:24:10 +00:00
|
|
|
|
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
|
|
|
|
|
base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
|
|
|
|
|
|
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
2026-05-19 10:25:10 +00:00
|
|
|
|
"data": perfect_tree,
|
2026-04-30 08:27:18 +00:00
|
|
|
|
"raw_cli": raw_output.strip()
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return {"status": "error", "message": str(e)}
|
2026-05-31 06:24:10 +00:00
|
|
|
|
finally:
|
|
|
|
|
|
if net_connect:
|
|
|
|
|
|
net_connect.disconnect()
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/cmts-full-config")
|
2026-05-19 10:25:10 +00:00
|
|
|
|
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
|
2026-04-30 08:27:18 +00:00
|
|
|
|
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
|
2026-05-31 06:24:10 +00:00
|
|
|
|
net_connect = None
|
2026-04-30 08:27:18 +00:00
|
|
|
|
try:
|
|
|
|
|
|
device = CMTS_DEVICE.copy()
|
|
|
|
|
|
device.update({'host': host, 'username': username, 'password': password})
|
|
|
|
|
|
|
|
|
|
|
|
net_connect = ConnectHandler(**device)
|
2026-05-19 10:25:10 +00:00
|
|
|
|
|
|
|
|
|
|
if config_type == "full":
|
|
|
|
|
|
net_connect.send_command_timing("config")
|
|
|
|
|
|
command = "show full-configuration | nomore"
|
|
|
|
|
|
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
|
|
|
|
|
net_connect.send_command_timing("exit")
|
|
|
|
|
|
else:
|
|
|
|
|
|
command = "show running-config | nomore"
|
|
|
|
|
|
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
|
|
|
|
|
|
|
|
|
|
|
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
|
|
|
|
|
|
clean_output = clean_output.lstrip()
|
|
|
|
|
|
|
2026-05-31 06:24:10 +00:00
|
|
|
|
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
|
|
|
|
|
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
|
|
|
|
|
|
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
|
2026-04-30 08:27:18 +00:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
if not skip_filter:
|
2026-05-20 09:19:40 +00:00
|
|
|
|
hidden_keys = await load_tree_filters(config_type)
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
for path in hidden_keys:
|
2026-05-18 03:51:23 +00:00
|
|
|
|
keys = path.split('::')
|
2026-04-30 08:27:18 +00:00
|
|
|
|
current = perfect_tree
|
|
|
|
|
|
# 走到倒數第二層
|
|
|
|
|
|
for k in keys[:-1]:
|
|
|
|
|
|
if isinstance(current, dict) and k in current:
|
|
|
|
|
|
current = current[k]
|
|
|
|
|
|
else:
|
|
|
|
|
|
current = None
|
|
|
|
|
|
break
|
|
|
|
|
|
# 刪除最後一層的目標節點
|
|
|
|
|
|
if isinstance(current, dict) and keys[-1] in current:
|
|
|
|
|
|
current.pop(keys[-1], None)
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "data": perfect_tree}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return {"status": "error", "message": str(e)}
|
2026-05-31 06:24:10 +00:00
|
|
|
|
finally:
|
|
|
|
|
|
if net_connect:
|
|
|
|
|
|
net_connect.disconnect()
|
2026-05-29 16:42:13 +00:00
|
|
|
|
|
|
|
|
|
|
@router.get("/cmts-full-config/stream")
|
|
|
|
|
|
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
|
|
|
|
|
|
"""獲取整台 CMTS 的完整配置 (串流進度回報版)"""
|
|
|
|
|
|
|
|
|
|
|
|
async def config_streamer():
|
2026-05-31 06:24:10 +00:00
|
|
|
|
net_connect = None
|
2026-05-29 16:42:13 +00:00
|
|
|
|
try:
|
|
|
|
|
|
# 1. 廣播:正在連線
|
|
|
|
|
|
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
|
|
|
|
|
|
await asyncio.sleep(0.1) # 讓前端有時間渲染
|
|
|
|
|
|
|
|
|
|
|
|
device = CMTS_DEVICE.copy()
|
|
|
|
|
|
device.update({'host': host, 'username': username, 'password': password})
|
|
|
|
|
|
|
|
|
|
|
|
# 使用 asyncio.to_thread 將阻塞的 netmiko 連線放到背景執行,避免卡住其他非同步任務
|
|
|
|
|
|
net_connect = await asyncio.to_thread(ConnectHandler, **device)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 廣播:正在下載
|
|
|
|
|
|
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
if config_type == "full":
|
|
|
|
|
|
await asyncio.to_thread(net_connect.send_command_timing, "config")
|
|
|
|
|
|
command = "show full-configuration | nomore"
|
|
|
|
|
|
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
|
|
|
|
|
await asyncio.to_thread(net_connect.send_command_timing, "exit")
|
|
|
|
|
|
else:
|
|
|
|
|
|
command = "show running-config | nomore"
|
|
|
|
|
|
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 廣播:正在解析
|
|
|
|
|
|
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
|
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
|
|
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
|
|
|
|
|
|
clean_output = clean_output.lstrip()
|
|
|
|
|
|
|
2026-05-31 06:24:10 +00:00
|
|
|
|
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
|
|
|
|
|
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
|
|
|
|
|
|
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
2026-05-29 16:42:13 +00:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 🌟 深度過濾攔截器 (維持原樣)
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
if not skip_filter:
|
|
|
|
|
|
yield json.dumps({"status": "progress", "message": "🔍 正在套用系統過濾器規則..."}) + "\n"
|
|
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
|
|
hidden_keys = await load_tree_filters(config_type)
|
|
|
|
|
|
|
|
|
|
|
|
for path in hidden_keys:
|
|
|
|
|
|
keys = path.split('::')
|
|
|
|
|
|
current = perfect_tree
|
|
|
|
|
|
for k in keys[:-1]:
|
|
|
|
|
|
if isinstance(current, dict) and k in current:
|
|
|
|
|
|
current = current[k]
|
|
|
|
|
|
else:
|
|
|
|
|
|
current = None
|
|
|
|
|
|
break
|
|
|
|
|
|
if isinstance(current, dict) and keys[-1] in current:
|
|
|
|
|
|
current.pop(keys[-1], None)
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 廣播:完成並傳送最終資料
|
|
|
|
|
|
yield json.dumps({"status": "success", "data": perfect_tree}) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
|
2026-05-31 06:24:10 +00:00
|
|
|
|
finally:
|
|
|
|
|
|
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
|
|
|
|
|
|
if net_connect:
|
|
|
|
|
|
await asyncio.to_thread(net_connect.disconnect)
|
2026-05-29 16:42:13 +00:00
|
|
|
|
|
|
|
|
|
|
# 回傳 NDJSON 串流格式
|
|
|
|
|
|
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# 🌟 系統設定 API (System Settings)
|
2026-04-30 08:27:18 +00:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
class SettingsRequest(BaseModel):
|
|
|
|
|
|
hidden_keys: list[str]
|
2026-05-19 10:25:10 +00:00
|
|
|
|
config_type: str = "running" # 🌟 加入 config_type 參數
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
@router.get("/settings/tree-filters")
|
2026-05-19 10:25:10 +00:00
|
|
|
|
async def get_tree_filters(config_type: str = "running"):
|
2026-04-30 08:27:18 +00:00
|
|
|
|
"""獲取目前的樹狀圖隱藏名單"""
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# 🌟 根據 config_type 讀取對應的 JSON
|
2026-05-20 09:19:40 +00:00
|
|
|
|
hidden_keys = await load_tree_filters(config_type)
|
2026-05-19 10:25:10 +00:00
|
|
|
|
return {"status": "success", "data": hidden_keys}
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
@router.post("/settings/tree-filters")
|
|
|
|
|
|
async def update_tree_filters(req: SettingsRequest):
|
|
|
|
|
|
"""更新樹狀圖隱藏名單並存檔"""
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# 🌟 根據 config_type 寫入對應的 JSON
|
2026-05-20 09:19:40 +00:00
|
|
|
|
await save_tree_filters(req.config_type, req.hidden_keys)
|
2026-05-19 10:25:10 +00:00
|
|
|
|
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
|
2026-06-05 10:29:04 +00:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 🌟 伺服器日誌管控 API (Server Log Management)
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
class LogLevelRequest(BaseModel):
|
|
|
|
|
|
module: str
|
|
|
|
|
|
level: str
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/settings/logs")
|
|
|
|
|
|
async def api_get_log_levels():
|
|
|
|
|
|
"""獲取目前所有模組的日誌等級"""
|
|
|
|
|
|
return {"status": "success", "data": get_all_log_levels()}
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/settings/logs")
|
|
|
|
|
|
async def api_set_log_level(req: LogLevelRequest):
|
|
|
|
|
|
"""動態修改特定模組的日誌等級"""
|
|
|
|
|
|
success = set_log_level(req.module, req.level)
|
|
|
|
|
|
if success:
|
|
|
|
|
|
return {"status": "success", "message": f"已將 {req.module} 的日誌等級設為 {req.level}"}
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="未知的模組名稱")
|