-
-
+
+
diff --git a/routers/config.py b/routers/config.py
index b9400b4..6c73529 100644
--- a/routers/config.py
+++ b/routers/config.py
@@ -1,15 +1,47 @@
# --- routers/config.py ---
import re
+import json
+import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List
from netmiko import ConnectHandler
-# 💡 確保從 shared 引入 deep_split_tree
-from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings
+# 🌟 移除了舊的 load_settings, save_settings,改用專屬的雙軌機制
+from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree
from collections import defaultdict
router = APIRouter()
+# ==========================================
+# 🌟 雙軌過濾器檔案讀寫輔助函數
+# ==========================================
+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"
+
+def load_tree_filters(config_type: str) -> list:
+ 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 []
+
+def save_tree_filters(config_type: str, hidden_keys: list):
+ 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}")
+
+# ==========================================
+
class ConfigRequest(BaseModel):
script: str
host: str
@@ -24,7 +56,7 @@ class DiffItem(BaseModel):
class GenerateCliRequest(BaseModel):
diffs: List[DiffItem]
- interfaces_with_admin_state: List[str] = [] # 💡 接收前端傳來的動態探測名單
+ interfaces_with_admin_state: List[str] = []
@router.post("/cmts-config")
async def execute_cmts_config(req: ConfigRequest):
@@ -38,12 +70,10 @@ async def execute_cmts_config(req: ConfigRequest):
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
net_connect = ConnectHandler(**device)
- # 🌟 1. 手動進入設定模式 (恢復您原本正確的寫法)
+ # 1. 手動進入設定模式
net_connect.send_command_timing("config")
- # 🌟 2. 批次送出設定指令
- # 加上 enter_config_mode=False 與 exit_config_mode=False
- # 這樣 Netmiko 就不會自作主張去打 config terminal 了
+ # 2. 批次送出設定指令
output = net_connect.send_config_set(
commands,
read_timeout=60,
@@ -52,14 +82,13 @@ async def execute_cmts_config(req: ConfigRequest):
exit_config_mode=False
)
- # 🌟 3. 手動退出設定模式
+ # 3. 手動退出設定模式
net_connect.send_command_timing("exit")
net_connect.disconnect()
- # 🌟 新增:檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
+ # 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
lower_output = output.lower()
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output:
- # 回傳錯誤狀態,前端就會顯示紅色字體且「不會」自動關閉視窗
return {
"status": "error",
"message": "CMTS 拒絕了設定 (Commit Aborted)",
@@ -76,21 +105,16 @@ async def generate_cli(req: GenerateCliRequest):
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
並自動處理 admin-state 的安全生命週期 (先 down 後 up)
"""
- # 用來將同一個介面的變更群組化
interface_groups = defaultdict(list)
-
- # 用來記錄該介面最終的 admin-state 應該是什麼
interface_admin_states = {}
for diff in req.diffs:
- # 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency']
parts = diff.path.split('::')
param_name = parts[-1]
interface_path = " ".join(parts[:-1])
- # 🌟 新增這行:全域清除路徑中間可能夾帶的虛擬資料夾索引 (例如 [0], [1])
- # 這樣 "logging evt 1024 [1] active" 就會還原成 "logging evt 1024 active"
+ # 全域清除路徑中間可能夾帶的虛擬資料夾索引
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
if param_name == "admin-state":
@@ -98,21 +122,17 @@ async def generate_cli(req: GenerateCliRequest):
else:
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
- # 開始組裝最終的 CLI 腳本
cli_lines = []
cli_lines.append("! --- Auto-Generated Configuration Script ---")
for interface, changes in interface_groups.items():
cli_lines.append(f"! Configuring: {interface}")
- # 💡 核心邏輯:判斷這個特定區塊是否支援 admin-state
supports_admin_state = interface in req.interfaces_with_admin_state
- # 1. 安全機制:如果支援,才強制將介面 admin-state down
if supports_admin_state:
cli_lines.append(f"{interface} admin-state down")
- # 2. 寫入所有被修改的參數
for param, old_val, new_val in changes:
if re.match(r"^\[\d+\]$", param):
if new_val == "":
@@ -128,16 +148,13 @@ async def generate_cli(req: GenerateCliRequest):
else:
cli_lines.append(f"{interface} {param} {new_val}")
- # 3. 恢復狀態:如果支援,才恢復為 up 或使用者指定的值
if supports_admin_state:
final_state = interface_admin_states.get(interface, "up")
cli_lines.append(f"{interface} admin-state {final_state}")
- # Harmonic 必須要有 commit 才會生效
cli_lines.append("commit")
- cli_lines.append("!") # 空行分隔
+ cli_lines.append("!")
- # 將獨立修改 admin-state (沒有修改其他參數) 的情況也補上
for interface, state in interface_admin_states.items():
if interface not in interface_groups:
cli_lines.append(f"! Configuring state only: {interface}")
@@ -145,13 +162,11 @@ async def generate_cli(req: GenerateCliRequest):
cli_lines.append("commit")
cli_lines.append("!")
- # 將陣列組合成字串回傳
final_script = "\n".join(cli_lines)
-
return {"status": "success", "data": final_script}
# ==========================================
-# 💡 以下為 DS RF Port 動態樹狀查詢 API
+# DS RF Port 動態樹狀查詢 API
# ==========================================
@router.get("/cmts-ds-rf-port-list")
@@ -162,13 +177,10 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
-
- # 使用 include 只抓取關鍵字,並把等待時間延長至 60 秒
command = 'show running-config | include "cable ds-rf-port"'
- raw_output = net_connect.send_command(command, read_timeout=60)
+ raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
- # 使用正規表達式擷取 port 號碼 (例如 62:0/0)
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output)
unique_ports = sorted(list(set(ports)))
@@ -191,17 +203,15 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore"
- raw_output = net_connect.send_command(command, read_timeout=60)
+ raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
- # 1. 初步解析縮排
base_tree = parse_cli_to_tree(raw_output)
- # 2. 💡 深層解析空白字元,建立完美樹狀結構
perfect_tree = deep_split_tree(base_tree)
return {
"status": "success",
- "data": perfect_tree, # 回傳完美的樹狀結構
+ "data": perfect_tree,
"raw_cli": raw_output.strip()
}
except Exception as e:
@@ -209,26 +219,36 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
@router.get("/cmts-full-config")
-async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False):
+async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
- command = "show running-config | nomore"
- raw_output = net_connect.send_command(command, read_timeout=60)
+
+ 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))
+
net_connect.disconnect()
- base_tree = parse_cli_to_tree(raw_output)
+ clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
+ clean_output = clean_output.lstrip()
+
+ base_tree = parse_cli_to_tree(clean_output)
perfect_tree = deep_split_tree(base_tree)
# ==========================================
- # 💡 深度過濾攔截器:支援多層級路徑 (如 cable.mac-domain)
+ # 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
# ==========================================
if not skip_filter:
- settings = load_settings()
- hidden_keys = settings.get("hidden_keys", [])
+ hidden_keys = load_tree_filters(config_type)
for path in hidden_keys:
keys = path.split('::')
@@ -250,19 +270,22 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "error", "message": str(e)}
# ==========================================
-# 💡 系統設定 API (System Settings)
+# 🌟 系統設定 API (System Settings)
# ==========================================
class SettingsRequest(BaseModel):
hidden_keys: list[str]
+ config_type: str = "running" # 🌟 加入 config_type 參數
@router.get("/settings/tree-filters")
-async def get_tree_filters():
+async def get_tree_filters(config_type: str = "running"):
"""獲取目前的樹狀圖隱藏名單"""
- settings = load_settings()
- return {"status": "success", "data": settings["hidden_keys"]}
+ # 🌟 根據 config_type 讀取對應的 JSON
+ hidden_keys = load_tree_filters(config_type)
+ return {"status": "success", "data": hidden_keys}
@router.post("/settings/tree-filters")
async def update_tree_filters(req: SettingsRequest):
"""更新樹狀圖隱藏名單並存檔"""
- save_settings({"hidden_keys": req.hidden_keys})
- return {"status": "success", "message": "系統設定已更新"}
+ # 🌟 根據 config_type 寫入對應的 JSON
+ save_tree_filters(req.config_type, req.hidden_keys)
+ return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
diff --git a/routers/leaf_options.py b/routers/leaf_options.py
index 967fe26..22110ac 100644
--- a/routers/leaf_options.py
+++ b/routers/leaf_options.py
@@ -9,111 +9,132 @@ from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE
router = APIRouter(tags=["Options"])
-CACHE_FILE = "cmts_leaf_options_cache.json"
+
+# 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
+def get_cache_file(host: str, config_type: str):
+ safe_host = host.replace(".", "_")
+ return f"{safe_host}_{config_type}_cache.json"
# ==========================================
-# 🌟 全域廣播機制 (SSE)
+# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
# ==========================================
-IS_SCANNING = False
-active_clients = set()
+# 將狀態改為空字典,動態根據 host_configType 建立
+SCAN_STATUS = {}
+active_clients = {}
-async def broadcast_message(message_dict: dict):
- """將訊息推播給所有連線中的前端"""
+async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
+ """將訊息推播給特定頻道的連線前端"""
+ channel_key = f"{host}_{config_type}"
dead_clients = set()
- # 🌟 關鍵修復 1:SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
+ # SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
message_str = f"data: {json.dumps(message_dict)}\n\n"
- for client_queue in active_clients:
+ for client_queue in active_clients.get(channel_key, set()):
try:
await client_queue.put(message_str)
except Exception:
dead_clients.add(client_queue)
for dead in dead_clients:
- active_clients.discard(dead)
+ active_clients[channel_key].discard(dead)
@router.get("/cmts-leaf-options/stream")
-async def sse_stream(request: Request):
- """前端一載入就會連上這個路由,持續監聽廣播"""
+async def sse_stream(request: Request, host: str, config_type: str = "running"):
+ """前端一載入就會連上這個路由,持續監聽特定頻道的廣播"""
+ channel_key = f"{host}_{config_type}"
client_queue = asyncio.Queue()
- active_clients.add(client_queue)
+
+ if channel_key not in active_clients:
+ active_clients[channel_key] = set()
+ active_clients[channel_key].add(client_queue)
async def event_generator():
try:
while True:
- # 🌟 每次迴圈先檢查前端是否已經斷線 (按了 F5)
if await request.is_disconnected():
break
try:
- # 縮短 timeout 為 1 秒,讓檢查更靈敏
message = await asyncio.wait_for(client_queue.get(), timeout=1.0)
yield message
except asyncio.TimeoutError:
yield ": keepalive\n\n"
except asyncio.CancelledError:
- # 🌟 關鍵修復:當 Uvicorn 觸發 reload 關閉伺服器時,會拋出此例外
- # 我們捕捉它並默默退出,不讓伺服器卡住
pass
finally:
- active_clients.discard(client_queue)
+ if channel_key in active_clients:
+ active_clients[channel_key].discard(client_queue)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/scan-status")
-async def get_scan_status():
- global IS_SCANNING
- return {"is_scanning": IS_SCANNING}
+async def get_scan_status(host: str, config_type: str = "running"):
+ channel_key = f"{host}_{config_type}"
+ return {"is_scanning": SCAN_STATUS.get(channel_key, False)}
# ==========================================
-# 🌟 快取讀取與背景掃描任務
+# 🌟 3. 快取讀取與背景掃描任務
# ==========================================
class SyncOptionsRequest(BaseModel):
+ host: str # 🌟 新增 host 參數
+ username: str = "" # 🌟 新增 username 參數
+ password: str = "" # 🌟 新增 password 參數
leaf_paths: List[str]
+ config_type: str = "running"
@router.get("/cmts-leaf-options")
-async def get_leaf_options():
- if not os.path.exists(CACHE_FILE): return {}
- with open(CACHE_FILE, "r", encoding="utf-8") as f:
- return json.load(f)
+async def get_leaf_options(host: str, config_type: str = "running"):
+ cache_file = get_cache_file(host, config_type)
+ if not os.path.exists(cache_file): return {}
+
+ # 🌟 優化 3:將讀取動作丟到背景執行緒
+ def read_json_from_file(filepath):
+ with open(filepath, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ return await asyncio.to_thread(read_json_from_file, cache_file)
-async def run_scan_task(paths: List[str]):
+async def run_scan_task(host: str, username: str, password: str, paths: List[str], config_type: str):
"""在背景執行的爬蟲任務,並將進度廣播出去"""
- global IS_SCANNING
+ global SCAN_STATUS
+ channel_key = f"{host}_{config_type}"
try:
- await broadcast_message({"event": "scan_start"})
+ await broadcast_message({"event": "scan_start"}, host, config_type)
- # 呼叫爬蟲 (帶入設備連線資訊)
+ # 呼叫爬蟲 (帶入設備連線資訊與 config_type)
async for chunk in sync_cmts_leaves_async(
- host=CMTS_DEVICE.get("host"),
- username=CMTS_DEVICE.get("username"),
- password=CMTS_DEVICE.get("password"),
- leaf_paths=paths
+ host=host,
+ username=username,
+ password=password,
+ leaf_paths=paths,
+ config_type=config_type
):
if isinstance(chunk, str):
- await broadcast_message(json.loads(chunk))
+ await broadcast_message(json.loads(chunk), host, config_type)
else:
- await broadcast_message(chunk)
+ await broadcast_message(chunk, host, config_type)
- await broadcast_message({"event": "done"})
+ await broadcast_message({"event": "done"}, host, config_type)
except Exception as e:
- await broadcast_message({"event": "error", "message": str(e)})
+ await broadcast_message({"event": "error", "message": str(e)}, host, config_type)
finally:
- IS_SCANNING = False
+ # 解除特定頻道的鎖定
+ SCAN_STATUS[channel_key] = False
@router.post("/cmts-leaf-options/sync")
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
- """前端點擊掃描時,只負責觸發背景任務並立即回傳 JSON"""
- global IS_SCANNING
-
+ global SCAN_STATUS
if not request.leaf_paths:
raise HTTPException(status_code=400)
-
- if IS_SCANNING:
- return {"status": "busy", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}
- IS_SCANNING = True
+ channel_key = f"{request.host}_{request.config_type}"
+ if SCAN_STATUS.get(channel_key, False):
+ return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"}
+
+ SCAN_STATUS[channel_key] = True
+
+ # 保留您原本完美的路徑清理邏輯
cmts_query_paths = []
for p in request.leaf_paths:
clean_p = p.replace("::", " ")
@@ -122,21 +143,27 @@ async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: Backg
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
- # 將任務丟入背景執行,並立刻回傳 JSON 給前端
- background_tasks.add_task(run_scan_task, cmts_query_paths)
+ # 決定帳密 (優先使用 request 傳來的,否則 fallback 到 shared.CMTS_DEVICE)
+ # 🌟 加上 or "" 確保最終結果絕對是字串,消除 Pylance 警告
+ user = request.username or CMTS_DEVICE.get("username") or ""
+ pwd = request.password or CMTS_DEVICE.get("password") or ""
+
+ background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths, request.config_type)
return {"status": "started"}
# ==========================================
-# 🌟 清除快取 API (對應 api.js 的 POST 請求)
+# 🌟 4. 清除快取 API
# ==========================================
@router.post("/clear_cache")
-async def clear_specific_cache(paths_to_clear: list = Body(...)):
+async def clear_specific_cache(host: str, config_type: str = "running", paths_to_clear: list = Body(...)):
cleared_count = 0
- if not os.path.exists(CACHE_FILE):
+ cache_file = get_cache_file(host, config_type)
+
+ if not os.path.exists(cache_file):
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
try:
- with open(CACHE_FILE, "r", encoding="utf-8") as f:
+ with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
for path in paths_to_clear:
@@ -145,7 +172,7 @@ async def clear_specific_cache(paths_to_clear: list = Body(...)):
cleared_count += 1
if cleared_count > 0:
- with open(CACHE_FILE, "w", encoding="utf-8") as f:
+ with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=4)
return {"status": "success", "cleared_count": cleared_count}
diff --git a/routers/lock.py b/routers/lock.py
index 2b012e8..283c3aa 100644
--- a/routers/lock.py
+++ b/routers/lock.py
@@ -1,48 +1,49 @@
# --- routers/lock.py ---
import time
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Dict, Optional
-# 🌟 只定義自己的子路徑
router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table)
-# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} }
+# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
# ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {}
+LOCK_TIMEOUT = 120
-# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat,否則自動釋放
-LOCK_TIMEOUT = 30
-
+# 🌟 新增 host 欄位
class LockAcquireReq(BaseModel):
+ host: str
path: str
- user_id: str # 前端產生的 UUID (Session ID)
- username: str # 登入的帳號 (用於 UI 提示,例如 "admin")
+ user_id: str
+ username: str
class LockActionReq(BaseModel):
+ host: str
path: str
user_id: str
def clean_expired_locks():
- """清除已經超時的鎖 (被動式清理)"""
current_time = time.time()
- expired_paths = [path for path, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
- for path in expired_paths:
- del ACTIVE_LOCKS[path]
+ expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
+ for k in expired_keys:
+ del ACTIVE_LOCKS[k]
-def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[dict], str]:
- """
- 檢查路徑是否被其他人鎖定,並回傳 (衝突的鎖定資訊, 具體錯誤訊息)
- """
+def is_path_locked_by_others(target_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
clean_expired_locks()
+ prefix = f"{target_host}@@"
- for locked_path, lock_info in ACTIVE_LOCKS.items():
- if lock_info["user_id"] == user_id:
- continue # 自己鎖定的不算衝突
+ for locked_key, lock_info in ACTIVE_LOCKS.items():
+ # 🌟 只檢查同一台設備 (IP) 的鎖定
+ if not locked_key.startswith(prefix):
+ continue
+
+ locked_path = locked_key.split("@@", 1)[1]
+ if lock_info["user_id"] == user_id:
+ continue
- # 💡 拆分判斷,回傳更精準的錯誤訊息
if target_path == locked_path:
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
@@ -54,24 +55,15 @@ def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[d
return None, ""
-# ==========================================
-# 💡 API Endpoints
-# ==========================================
-
@router.post("/acquire")
async def acquire_lock(req: LockAcquireReq):
- """請求鎖定特定路徑"""
- # 接收回傳的鎖定資訊與具體訊息
- conflict_lock, error_msg = is_path_locked_by_others(req.path, req.user_id)
+ conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
if conflict_lock:
- raise HTTPException(
- status_code=409,
- detail=error_msg # 💡 將精準的錯誤訊息傳給前端
- )
+ raise HTTPException(status_code=409, detail=error_msg)
- # 寫入或更新鎖定狀態
- ACTIVE_LOCKS[req.path] = {
+ lock_key = f"{req.host}@@{req.path}"
+ ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id,
"username": req.username,
"expires_at": time.time() + LOCK_TIMEOUT
@@ -80,28 +72,33 @@ async def acquire_lock(req: LockAcquireReq):
@router.post("/heartbeat")
async def heartbeat_lock(req: LockActionReq):
- """延長鎖定時間 (心跳偵測)"""
clean_expired_locks()
+ lock_key = f"{req.host}@@{req.path}"
- if req.path not in ACTIVE_LOCKS:
+ if lock_key not in ACTIVE_LOCKS:
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
- if ACTIVE_LOCKS[req.path]["user_id"] != req.user_id:
+ if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
- # 延長鎖定時間
- ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT
+ ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
return {"status": "success", "message": "心跳更新成功"}
@router.post("/release")
async def release_lock(req: LockActionReq):
- """主動釋放鎖定"""
- if req.path in ACTIVE_LOCKS and ACTIVE_LOCKS[req.path]["user_id"] == req.user_id:
- del ACTIVE_LOCKS[req.path]
+ lock_key = f"{req.host}@@{req.path}"
+ if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
+ del ACTIVE_LOCKS[lock_key]
return {"status": "success", "message": "鎖定已釋放"}
@router.get("/status")
-async def get_lock_status():
- """獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)"""
+async def get_lock_status(host: str = Query(None)):
+ """獲取特定設備的鎖定狀態"""
clean_expired_locks()
- return {"status": "success", "data": ACTIVE_LOCKS}
+ if host:
+ prefix = f"{host}@@"
+ # 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 path,UI 不用改!
+ filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
+ return {"status": "success", "data": filtered_locks}
+
+ return {"status": "success", "data": {}}
diff --git a/static/api.js b/static/api.js
index 5391c5c..fd04699 100644
--- a/static/api.js
+++ b/static/api.js
@@ -3,56 +3,69 @@
// ==========================================
// 1. 鎖定管理 (Lock Management)
// ==========================================
-export async function apiAcquireLock(path, userId, username) {
- const response = await fetch('/api/v1/locks/acquire', {
+export async function apiAcquireLock(path, userId, username, host) {
+ const res = await fetch('/api/v1/locks/acquire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path, user_id: userId, username })
+ body: JSON.stringify({ path, user_id: userId, username, host })
});
- return { status: response.status, data: await response.json() };
+ const data = await res.json();
+ return { status: res.status, data };
}
-export async function apiReleaseLock(path, userId) {
+export async function apiReleaseLock(path, userId, host) {
return fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path, user_id: userId })
+ body: JSON.stringify({ path, user_id: userId, host })
});
}
-export async function apiSendHeartbeat(path, userId) {
+export async function apiSendHeartbeat(path, userId, host) {
return fetch('/api/v1/locks/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path, user_id: userId })
+ body: JSON.stringify({ path, user_id: userId, host })
});
}
+export async function apiGetLockStatus(host) {
+ // 帶入 host 參數,實現 IP 隔離查詢
+ const url = host ? `/api/v1/locks/status?host=${encodeURIComponent(host)}` : '/api/v1/locks/status';
+ const response = await fetch(url);
+ return response.json();
+}
+
// ==========================================
// 2. 樹狀圖與選項快取 (Tree & Options)
// ==========================================
-export async function apiGetFullConfig(host, username, password, skipFilter = false) {
- let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
+export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
+ // 🌟 將 config_type 加入 URL 參數中
+ let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`;
+
if (skipFilter) url += '&skip_filter=true';
const response = await fetch(url);
return response.json();
}
-export async function apiGetLeafOptions() {
- const response = await fetch('/api/v1/cmts-leaf-options');
+// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
+export async function apiGetLeafOptions(host, configType = 'running') {
+ const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
return response.json();
}
-export async function apiSyncLeafOptions(paths) {
+// 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
+export async function apiSyncLeafOptions(host, paths, configType = 'running') {
return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ leaf_paths: paths })
+ body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
}
-export async function apiClearCache(paths) {
- const response = await fetch('/api/v1/clear_cache', {
+// 🌟 3. 清除快取 (加上 configType 查詢參數)
+export async function apiClearCache(host, paths, configType = 'running') {
+ const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paths)
@@ -67,13 +80,12 @@ export async function apiGetCmtsVersion(host, username, password) {
return response.json();
}
-export async function apiGetScanStatus() {
+export async function apiGetScanStatus(host, configType = 'running') {
try {
- const response = await fetch('/api/v1/scan-status');
+ const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`);
const data = await response.json();
return data.is_scanning;
} catch (e) {
- console.warn("無法取得掃描狀態,預設為未掃描", e);
return false;
}
}
@@ -102,16 +114,18 @@ export async function apiExecuteConfig(script, host, username, password) {
// ==========================================
// 4. 系統設定與過濾器 (System Settings)
// ==========================================
-export async function apiGetTreeFilters() {
- const response = await fetch('/api/v1/settings/tree-filters');
+// 🌟 加上 configType 參數
+export async function apiGetTreeFilters(configType = 'running') {
+ const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
return response.json();
}
-export async function apiSaveTreeFilters(hiddenKeys) {
- const response = await fetch('/api/v1/settings/tree-filters', {
+// 🌟 加上 configType 參數
+export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
+ const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ hidden_keys: hiddenKeys })
+ body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
});
return response.json();
}
@@ -140,11 +154,12 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
// ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ==========================================
-export async function apiSyncLeafOptionsStream(paths, onProgress) {
+// 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
+export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ leaf_paths: paths })
+ body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
@@ -172,4 +187,4 @@ export async function apiSyncLeafOptionsStream(paths, onProgress) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/static/app.js b/static/app.js
index 780323e..1ad1639 100644
--- a/static/app.js
+++ b/static/app.js
@@ -12,7 +12,7 @@ import {
apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion,
- apiGetScanStatus
+ apiGetScanStatus, apiGetLockStatus
} from './api.js';
// 3. 共用工具模組 (Utils)
@@ -25,7 +25,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-u
import {
releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
- hideSideCLI, executeSideCLI, previewNodeCLI
+ hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
} from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain)
@@ -44,11 +44,12 @@ let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
-// 初始化全域 SSE 監聽器
-function initGlobalSSE() {
- if (globalSSE) return;
-
- globalSSE = new EventSource('/api/v1/cmts-leaf-options/stream');
+// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
+function initGlobalSSE(mode = 'running') {
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
+ if (globalSSE) globalSSE.close();
+ globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
@@ -101,11 +102,55 @@ window.addEventListener('beforeunload', () => {
}
});
+let lockPollingTimer = null;
+
+function startLockStatusPolling() {
+ if (lockPollingTimer) clearInterval(lockPollingTimer);
+
+ lockPollingTimer = setInterval(async () => {
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo || !connInfo.host) return;
+
+ try {
+ const result = await apiGetLockStatus(connInfo.host);
+ if (result.status === 'success') {
+ const activeLocks = result.data;
+
+ document.querySelectorAll('.edit-btn').forEach(btn => {
+ const path = btn.getAttribute('data-path');
+ if (activeLocks[path] && activeLocks[path].user_id !== SESSION_USER_ID) {
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.2';
+ btn.title = `🔒 此區塊正由 [${activeLocks[path].username}] 編輯中`;
+ btn.innerText = "🔒";
+ } else {
+ if (btn.innerText === "🔒") {
+ btn.style.pointerEvents = 'auto';
+ btn.style.opacity = '0.3';
+ btn.title = "鎖定並編輯此項目";
+ btn.innerText = "✏️";
+ }
+ }
+ });
+ }
+ } catch (error) {
+ console.warn("獲取鎖定狀態失敗:", error);
+ }
+ }, 15000);
+}
+
// 頁面載入與縮放初始化
window.onload = () => {
initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監聽
+ startLockStatusPolling(); // 啟動鎖定狀態輪詢
+
+ // 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
+ const configTaskSelect = document.getElementById('configTask');
+ if (configTaskSelect) {
+ configTaskSelect.dispatchEvent(new Event('change'));
+ }
};
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
@@ -115,9 +160,18 @@ function resetIdleTimer() {
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
}
-window.addEventListener('mousemove', resetIdleTimer);
-window.addEventListener('keydown', resetIdleTimer);
-window.addEventListener('click', resetIdleTimer);
+// 🌟 效能急救:節流閥 (Throttle) 機制
+let throttleTimer = false;
+function throttledReset() {
+ if (!throttleTimer) {
+ resetIdleTimer();
+ throttleTimer = true;
+ setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設
+ }
+}
+window.addEventListener('mousemove', throttledReset);
+window.addEventListener('keydown', throttledReset);
+window.addEventListener('click', throttledReset);
// ============================================================================
// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
@@ -143,17 +197,57 @@ function switchQueryTask() {
if (targetForm) targetForm.classList.add('active');
}
+// 🌟 新增一個變數來記錄上一次的樹狀圖模式
+let lastTreeMode = null;
+
// 切換「設備配置」內的子任務表單
function switchConfigTask() {
document.querySelectorAll('#config-tab .task-form').forEach(form => {
form.classList.remove('active');
form.style.display = 'none';
});
+
const selectedTaskId = document.getElementById('configTask').value;
- const targetForm = document.getElementById(selectedTaskId);
+
+ let targetFormId = selectedTaskId;
+ if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') {
+ targetFormId = 'form-tree-config';
+ }
+
+ const targetForm = document.getElementById(targetFormId);
if (targetForm) {
targetForm.classList.add('active');
targetForm.style.display = 'block';
+
+ const titleSpan = document.getElementById('tree-form-title');
+ const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running';
+
+ if (titleSpan) {
+ titleSpan.textContent = currentMode === 'running'
+ ? '🌳 設備配置樹狀圖 (running-config)'
+ : '🌳 完整設備配置樹狀圖 (full configuration)';
+ }
+
+ if (targetFormId === 'form-tree-config') {
+ // 🌟 魔法所在:純視覺切換,不銷毀資料!
+ const runningContainer = document.getElementById('tree-container-running');
+ const fullContainer = document.getElementById('tree-container-full');
+ if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none';
+ if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none';
+
+ if (lastTreeMode !== currentMode) {
+ const statusEl = document.getElementById('scan-status');
+ if (statusEl) statusEl.textContent = '';
+
+ // 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏!
+ const loadingMsg = document.getElementById('loading-message');
+ if (loadingMsg) loadingMsg.style.display = 'none';
+
+ lastTreeMode = currentMode;
+ }
+ }
+
+ initGlobalSSE(currentMode);
}
}
@@ -170,8 +264,13 @@ function startConfigTask() {
if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
resetAllManagerData();
loadMacDomainList();
- } else if (selectedTaskId === 'form-full-config') {
- fetchFullConfig();
+ }
+ // 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType
+ else if (selectedTaskId === 'form-running-config') {
+ fetchFullConfig('running');
+ }
+ else if (selectedTaskId === 'form-full-config') {
+ fetchFullConfig('full'); // 🌟 補上 full 參數
}
}
@@ -260,10 +359,11 @@ async function executeQuery(mode) {
}
}
-// 載入完整設備配置並生成樹狀圖
-async function fetchFullConfig() {
+// 載入完整設備配置並生成樹狀圖,預設為 running
+async function fetchFullConfig(configType = 'running') {
const loadingMsg = document.getElementById('loading-message');
- const treeContainer = document.getElementById('tree-container');
+ // 🌟 動態抓取當前模式的專屬容器
+ const treeContainer = document.getElementById(`tree-container-${configType}`);
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
@@ -279,7 +379,9 @@ async function fetchFullConfig() {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
- const optRes = await fetch('/api/v1/cmts-leaf-options');
+
+ // 🌟 修正 1:讀取選項快取時,必須加上 config_type 參數
+ const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
@@ -287,7 +389,10 @@ async function fetchFullConfig() {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
if (doClear) {
const allKeys = Object.keys(optData);
- await apiClearCache(allKeys);
+
+ // 🌟 修正 2:清空快取時,必須傳遞 configType 告訴後端清哪一個
+ await apiClearCache(connInfo.host, allKeys, configType);
+
alert("✅ 舊版快取已清空!系統載入配置後,將自動為您重新掃描選項。");
needAutoScan = true; // 🌟 觸發自動掃描旗標
}
@@ -298,27 +403,42 @@ async function fetchFullConfig() {
}
// --- 2. 載入完整配置 ---
- const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass);
+ const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass, false, configType);
+
+ // 🌟 新增:將原始資料存入全域變數,供全域掃描使用
+ window.currentTreeData = result.data;
+
+ // 🌟 新增防呆攔截:檢查使用者是否在等待期間切換了下拉選單
+ const currentSelectedTask = document.getElementById('configTask').value;
+ const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
+
+ if (currentSelectedTask !== expectedTask) {
+ console.warn(`[防呆攔截] 正在載入 ${configType},但使用者已切換至 ${currentSelectedTask},捨棄本次結果以防畫面錯亂。`);
+ return; // 🌟 發現模式不符,直接中斷,不要把舊樹狀圖貼上去!
+ }
if (result.status === 'success') {
if (treeContainer) {
- treeContainer.innerHTML = buildTree(result.data);
+ // 🌟 傳入 configType,讓 ID 加上前綴
+ treeContainer.innerHTML = buildTree(result.data, '', false, configType);
+
+ // 🌟 修正:樹狀圖一畫完,立刻隱藏載入訊息,讓使用者感覺「秒開」
+ if (loadingMsg) loadingMsg.style.display = 'none';
// 🌟 3. 檢查後端是否已經有人在掃描
- const isSomeoneScanning = await apiGetScanStatus();
+ const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
if (isSomeoneScanning) {
- // B 使用者情境:有人在掃描了,反灰按鈕並提示
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
- lockScanButton(60000); // 使用您現有的鎖定 UI 函數
+ lockScanButton(60000);
} else {
- // A 使用者情境:沒人在掃描,正常顯示按鈕
enableGlobalScanButton();
- // 如果剛清空快取,自動啟動掃描 (改為全域掃描)
+ // 如果剛清空快取,自動啟動掃描
if (needAutoScan) {
setTimeout(() => {
- scanGlobalMissingOptions();
- }, 500); // 給予 0.5 秒 UI 渲染緩衝
+ // 🌟 修正 3:全域掃描也必須知道現在要掃哪一棵樹
+ scanGlobalMissingOptions(configType);
+ }, 500);
}
}
}
@@ -336,10 +456,15 @@ async function fetchFullConfig() {
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================
-// 為現有的 input 加上下拉選項 (不破壞原有結構)
-async function enhanceInputWithOptions(path, elementId) {
+// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
+async function enhanceInputWithOptions(path, elementId, mode = 'running') {
+ // 🌟 1. 取得當前連線的 IP
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo || !connInfo.host) return;
+
try {
- const optRes = await fetch('/api/v1/cmts-leaf-options');
+ // 🌟 2. GET 請求:網址加上 host 參數
+ const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
@@ -362,10 +487,11 @@ async function enhanceInputWithOptions(path, elementId) {
dataList.innerHTML = leafCache.options.map(opt => `
- ${buildTree(value, currentPath, isCommandGroup)}
+
`;
} else {
+ // 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
- const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
+ const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
if (isNoCommand) {
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
@@ -167,7 +216,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
onmouseout="this.style.transform='scale(1)'">
🔍
-