diff --git a/all_code.txt b/all_code.txt
new file mode 100644
index 0000000..7e18c18
--- /dev/null
+++ b/all_code.txt
@@ -0,0 +1,6804 @@
+================================================================================
+PROJECT SOURCE CODE EXPORT
+================================================================================
+
+
+================================================================================
+FILE: database.py
+================================================================================
+import asyncpg
+import json
+import logging
+import uuid
+from typing import Dict, List, Optional, Any
+
+# ==========================================
+# 💡 PostgreSQL 連線與操作 (高可用性版)
+# ==========================================
+
+logger = logging.getLogger(__name__)
+
+# DB 設定 (從您的 init_db.py 中提取)
+DB_CONFIG = {
+ "database": "cmts_nms",
+ "user": "swpa",
+ "password": "swpa4920",
+ "host": "127.0.0.1",
+ "port": "5432"
+}
+
+_pool: Optional[asyncpg.Pool] = None
+
+async def init_db_pool():
+ """初始化非同步連線池"""
+ global _pool
+ try:
+ if _pool is None:
+ _pool = await asyncpg.create_pool(
+ database=DB_CONFIG["database"],
+ user=DB_CONFIG["user"],
+ password=DB_CONFIG["password"],
+ host=DB_CONFIG["host"],
+ port=int(DB_CONFIG["port"]),
+ min_size=1,
+ max_size=10
+ )
+ logger.info("✅ PostgreSQL Connection Pool Initialized.")
+ except Exception as e:
+ logger.error(f"❌ Failed to initialize DB Pool: {e}")
+
+async def close_db_pool():
+ """關閉非同步連線池"""
+ global _pool
+ if _pool:
+ await _pool.close()
+ _pool = None
+ logger.info("🔌 PostgreSQL Connection Pool Closed.")
+
+async def get_pool() -> Optional[asyncpg.Pool]:
+ if _pool is None:
+ await init_db_pool()
+ return _pool
+
+# ------------------------------------------
+# CRUD Functions for cmts_options
+# ------------------------------------------
+
+async def upsert_leaf_option(host: str, config_type: str, path: str, data: dict) -> bool:
+ """將選項寫入或更新至資料庫"""
+ pool = await get_pool()
+ if not pool:
+ return False
+
+ query = """
+ INSERT INTO cmts_options (host, config_type, path, data)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (host, config_type, path)
+ DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
+ """
+ try:
+ async with pool.acquire() as conn:
+ await conn.execute(query, host, config_type, path, json.dumps(data))
+ return True
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (upsert_leaf_option): {e}")
+ return False
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (upsert_leaf_option): {e}")
+ return False
+
+async def get_all_leaf_options(host: str, config_type: str) -> Optional[Dict[str, Any]]:
+ """取得特定 host 與 config_type 的所有選項"""
+ pool = await get_pool()
+ if not pool:
+ return None
+
+ query = """
+ SELECT path, data FROM cmts_options
+ WHERE host = $1 AND config_type = $2;
+ """
+ try:
+ async with pool.acquire() as conn:
+ records = await conn.fetch(query, host, config_type)
+ result = {}
+ for record in records:
+ # asyncpg returns strings for JSON if not explicitly configured with type mapping,
+ # but usually it's fine to just json.loads it.
+ data_val = record['data']
+ if isinstance(data_val, str):
+ result[record['path']] = json.loads(data_val)
+ else:
+ result[record['path']] = data_val
+ return result
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (get_all_leaf_options): {e}")
+ return None
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (get_all_leaf_options): {e}")
+ return None
+
+async def delete_leaf_options(host: str, config_type: str, paths: List[str]) -> int:
+ """刪除特定路徑的選項快取"""
+ pool = await get_pool()
+ if not pool or not paths:
+ return -1
+
+ query = """
+ DELETE FROM cmts_options
+ WHERE host = $1 AND config_type = $2 AND path = ANY($3);
+ """
+ try:
+ async with pool.acquire() as conn:
+ # Execute returns the command tag, e.g., 'DELETE 5'
+ status = await conn.execute(query, host, config_type, paths)
+ deleted_count = int(status.split()[-1])
+ return deleted_count
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (delete_leaf_options): {e}")
+ return -1
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (delete_leaf_options): {e}")
+ return -1
+
+# ------------------------------------------
+# CRUD Functions for device_status
+# ------------------------------------------
+
+async def upsert_device_status(host: str, config_type: str, metadata: dict) -> bool:
+ """更新設備的 metadata"""
+ pool = await get_pool()
+ if not pool:
+ return False
+
+ # Extract known fields
+ cmts_version = metadata.get("cmts_version", "unknown")
+ last_scanned = metadata.get("last_scanned", None)
+
+ query = """
+ INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (host, config_type)
+ DO UPDATE SET
+ cmts_version = EXCLUDED.cmts_version,
+ last_scanned = EXCLUDED.last_scanned,
+ updated_at = CURRENT_TIMESTAMP;
+ """
+ try:
+ async with pool.acquire() as conn:
+ await conn.execute(query, host, config_type, cmts_version, last_scanned)
+ return True
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (upsert_device_status): {e}")
+ return False
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
+ return False
+
+async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
+ """獲取設備 metadata"""
+ pool = await get_pool()
+ if not pool:
+ return None
+
+ query = """
+ SELECT cmts_version, last_scanned FROM device_status
+ WHERE host = $1 AND config_type = $2;
+ """
+ try:
+ async with pool.acquire() as conn:
+ record = await conn.fetchrow(query, host, config_type)
+ if record:
+ return {
+ "cmts_version": record["cmts_version"],
+ "last_scanned": record["last_scanned"]
+ }
+ return None
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (get_device_status): {e}")
+ return None
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (get_device_status): {e}")
+ return None
+
+# ------------------------------------------
+# CRUD Functions for system_filters (Tree Filters)
+# ------------------------------------------
+async def upsert_tree_filters(config_type: str, hidden_keys: List[str]) -> bool:
+ """寫入全域的樹狀圖隱藏節點名單"""
+ pool = await get_pool()
+ if not pool:
+ return False
+
+ # Use 'global' as a dummy host to keep schema simple if needed,
+ # but a dedicated system_filters table is better.
+ query = """
+ INSERT INTO system_filters (config_type, hidden_keys)
+ VALUES ($1, $2)
+ ON CONFLICT (config_type)
+ DO UPDATE SET hidden_keys = EXCLUDED.hidden_keys, updated_at = CURRENT_TIMESTAMP;
+ """
+ try:
+ async with pool.acquire() as conn:
+ await conn.execute(query, config_type, hidden_keys)
+ return True
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (upsert_tree_filters): {e}")
+ return False
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (upsert_tree_filters): {e}")
+ return False
+
+async def get_tree_filters(config_type: str) -> Optional[List[str]]:
+ """讀取全域的樹狀圖隱藏節點名單"""
+ pool = await get_pool()
+ if not pool:
+ return None
+
+ query = """
+ SELECT hidden_keys FROM system_filters
+ WHERE config_type = $1;
+ """
+ try:
+ async with pool.acquire() as conn:
+ record = await conn.fetchrow(query, config_type)
+ if record:
+ return record["hidden_keys"]
+ return []
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (get_tree_filters): {e}")
+ return None
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (get_tree_filters): {e}")
+ return None
+
+# ==========================================
+# CRUD Functions for config_backups (Phase 1 & 2)
+# ==========================================
+
+async def insert_config_backup(
+ host: str,
+ config_type: str,
+ raw_cli: str,
+ parsed_tree: dict,
+ snapshot_name: Optional[str] = None,
+ description: str = "", # 🟢 新增描述參數 (預設為空字串)
+ is_auto: bool = False
+) -> Optional[str]:
+ """新增一筆設備配置備份,回傳產生的 Backup ID"""
+ pool = await get_pool()
+ if not pool:
+ return None
+
+ backup_id = str(uuid.uuid4())
+
+ # 🟢 SQL 語句加入 description 與對應的 $5
+ query = """
+ INSERT INTO config_backups
+ (id, host, config_type, snapshot_name, description, is_auto, raw_cli, parsed_tree)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
+ """
+ try:
+ async with pool.acquire() as conn:
+ await conn.execute(
+ query,
+ backup_id,
+ host,
+ config_type,
+ snapshot_name,
+ description, # 🟢 傳入描述參數
+ is_auto,
+ raw_cli,
+ json.dumps(parsed_tree)
+ )
+ return backup_id
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (insert_config_backup): {e}")
+ return None
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (insert_config_backup): {e}")
+ return None
+
+async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
+ """取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
+ pool = await get_pool()
+ if not pool:
+ return None
+
+ try:
+ async with pool.acquire() as conn:
+ if config_type == "all":
+ # 🟢 SELECT 加入 description
+ query = """
+ SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
+ FROM config_backups
+ WHERE host = $1
+ ORDER BY timestamp DESC;
+ """
+ records = await conn.fetch(query, host)
+ else:
+ # 🟢 SELECT 加入 description
+ query = """
+ SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
+ FROM config_backups
+ WHERE host = $1 AND config_type = $2
+ ORDER BY timestamp DESC;
+ """
+ records = await conn.fetch(query, host, config_type)
+
+ return [
+ {
+ "id": str(r["id"]),
+ "host": r["host"],
+ "config_type": r["config_type"],
+ "timestamp": r["timestamp"].isoformat(),
+ "snapshot_name": r["snapshot_name"],
+ "description": r["description"], # 🟢 將資料庫的描述放入回傳字典
+ "is_auto": r["is_auto"]
+ }
+ for r in records
+ ]
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (get_config_backup_list): {e}")
+ return None
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (get_config_backup_list): {e}")
+ return None
+
+async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
+ """取得特定快照的完整內容 (包含 parsed_tree,供 Phase 3 還原使用)"""
+ pool = await get_pool()
+ if not pool:
+ return None
+
+ # 🟢 SELECT 加入 description
+ query = """
+ SELECT id, host, timestamp, snapshot_name, description, is_auto, parsed_tree
+ FROM config_backups
+ WHERE id = $1;
+ """
+ try:
+ async with pool.acquire() as conn:
+ record = await conn.fetchrow(query, backup_id)
+ if record:
+ data_val = record['parsed_tree']
+ parsed_tree = json.loads(data_val) if isinstance(data_val, str) else data_val
+
+ return {
+ "id": str(record["id"]),
+ "host": record["host"],
+ "timestamp": record["timestamp"].isoformat(),
+ "snapshot_name": record["snapshot_name"],
+ "description": record["description"], # 🟢 將資料庫的描述放入回傳字典
+ "is_auto": record["is_auto"],
+ "parsed_tree": parsed_tree
+ }
+ return None
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (get_config_backup_detail): {e}")
+ return None
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (get_config_backup_detail): {e}")
+ return None
+
+async def delete_config_backup(backup_id: str) -> bool:
+ """刪除指定的快照"""
+ pool = await get_pool()
+ if not pool:
+ return False
+
+ query = "DELETE FROM config_backups WHERE id = $1;"
+ try:
+ async with pool.acquire() as conn:
+ status = await conn.execute(query, backup_id)
+ # status 會是 'DELETE 1' 或 'DELETE 0'
+ return int(status.split()[-1]) > 0
+ except asyncpg.PostgresError as e:
+ logger.error(f"❌ DB Error (delete_config_backup): {e}")
+ return False
+ except Exception as e:
+ logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
+ return False
+
+
+================================================================================
+FILE: shared.py
+================================================================================
+# --- shared.py ---
+import asyncio
+import json
+import os
+import re
+from collections import defaultdict
+
+DEBUG_MODE = False
+USE_DB = True # PostgreSQL Feature Toggle
+
+def debug_print(msg: str):
+ if DEBUG_MODE:
+ print(msg)
+
+# 預設的 CMTS 連線樣板
+CMTS_DEVICE = {
+ 'device_type': 'cisco_ios',
+ 'host': '10.14.110.4',
+ 'username': 'admin',
+ 'password': 'nsgadmin',
+ 'port': 22,
+ 'fast_cli': False,
+ 'global_delay_factor': 2
+}
+
+def parse_cli_to_tree(cli_text: str) -> dict:
+ """
+ 將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
+ """
+ # ==========================================
+ # 🌟 階段 0:預處理 (清理雜訊,移除危險的自動黏合)
+ # ==========================================
+ cli_text = cli_text.replace('\r\n', '\n').replace('\r', '\n')
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
+
+ raw_lines = cli_text.split('\n')
+ fixed_lines = []
+
+ for line in raw_lines:
+ line = ansi_escape.sub('', line)
+ line = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]', '', line)
+
+ if not line.strip():
+ continue
+
+ # 💡 乾淨俐落:直接加入陣列,不再做危險的字串長度黏合判斷
+ fixed_lines.append(line)
+
+ lines = [line.rstrip() for line in fixed_lines if line.strip()]
+
+ # ==========================================
+ # 🌟 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
+ # ==========================================
+ def build_raw_tree(start_idx, current_indent):
+ nodes = []
+ i = start_idx
+ while i < len(lines):
+ line = lines[i]
+ stripped = line.strip()
+ indent = len(line) - len(line.lstrip())
+
+ if indent < current_indent and stripped != '!':
+ return nodes, i
+
+ if stripped == '!':
+ i += 1
+ continue
+
+ has_children = False
+ next_i = i + 1
+ while next_i < len(lines) and lines[next_i].strip() == '!':
+ next_i += 1
+
+ if next_i < len(lines):
+ next_line = lines[next_i]
+ next_indent = len(next_line) - len(next_line.lstrip())
+ if next_indent > indent:
+ has_children = True
+
+ if has_children:
+ children, next_i = build_raw_tree(i + 1, next_indent)
+ nodes.append({"type": "folder", "content": stripped, "children": children})
+ i = next_i
+ else:
+ nodes.append({"type": "leaf", "content": stripped})
+ i += 1
+ return nodes, i
+
+ raw_tree, _ = build_raw_tree(0, 0)
+
+ # ==========================================
+ # 🌟 階段 2:邏輯群組化與字典合併 (保留嚴格型別防護)
+ # ==========================================
+ def deep_merge(dict1, dict2):
+ for k, v in dict2.items():
+ if k in dict1:
+ if isinstance(dict1[k], dict) and isinstance(v, dict):
+ deep_merge(dict1[k], v)
+ else:
+ idx = 1
+ while f"{k} [{idx}]" in dict1:
+ idx += 1
+ dict1[f"{k} [{idx}]"] = v
+ else:
+ dict1[k] = v
+
+ def nest_folder_key(key_string, value_dict, target_dict):
+ parts = key_string.split()
+ current = target_dict
+ for i, part in enumerate(parts):
+ if i == len(parts) - 1:
+ if part not in current:
+ current[part] = value_dict
+ else:
+ if isinstance(current[part], dict) and isinstance(value_dict, dict):
+ deep_merge(current[part], value_dict)
+ else:
+ idx = 1
+ while f"{part} (衝突 {idx})" in current:
+ idx += 1
+ current[f"{part} (衝突 {idx})"] = value_dict
+ else:
+ if part not in current or not isinstance(current[part], dict):
+ if part in current and not isinstance(current[part], dict):
+ old_val = current[part]
+ current[part] = {"[0]": old_val}
+ else:
+ current[part] = {}
+ current = current[part]
+
+ def group_leaves(leaf_strings):
+ grouped = {}
+ first_word_map = defaultdict(list)
+
+ for s in leaf_strings:
+ parts = s.split(maxsplit=1)
+ first_word = parts[0]
+ remainder = parts[1] if len(parts) > 1 else ""
+ first_word_map[first_word].append(remainder)
+
+ for prefix, remainders in first_word_map.items():
+ if len(remainders) == 1:
+ grouped[prefix] = remainders[0]
+ else:
+ valid_remainders = [r for r in remainders if r]
+ empty_count = len(remainders) - len(valid_remainders)
+
+ group_dict = {}
+ if valid_remainders:
+ sub_grouped = group_leaves(valid_remainders)
+ is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
+
+ if is_all_empty_vals:
+ for i, k in enumerate(sub_grouped.keys()):
+ group_dict[f"[{i}]"] = k
+ else:
+ idx = 0
+ for k, v in sub_grouped.items():
+ if isinstance(v, dict):
+ group_dict[k] = v
+ elif v == "":
+ group_dict[f"[{idx}]"] = k
+ idx += 1
+ else:
+ group_dict[k] = v
+
+ for _ in range(empty_count):
+ idx = 0
+ while f"[{idx}]" in group_dict:
+ idx += 1
+ group_dict[f"[{idx}]"] = ""
+
+ grouped[prefix] = group_dict
+
+ return grouped
+
+ def fold_nodes(nodes):
+ result = {}
+ for node in [n for n in nodes if n["type"] == "folder"]:
+ folded_children = fold_nodes(node["children"])
+ nest_folder_key(node["content"], folded_children, result)
+
+ leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
+ if leaves:
+ leaf_dict = group_leaves(leaves)
+ deep_merge(result, leaf_dict)
+ return result
+
+ return fold_nodes(raw_tree)
+
+def deep_split_tree(data):
+ """將平坦的 CLI 字典,依照空白字元拆分成深層巢狀結構 (具備終極型態衝突保護)"""
+ if not isinstance(data, dict):
+ return data
+
+ nested_tree = {}
+ for key, value in data.items():
+ processed_value = deep_split_tree(value)
+
+ parts = str(key).strip().split()
+ if not parts:
+ continue
+
+ current = nested_tree
+ for i, part in enumerate(parts):
+ if i == len(parts) - 1:
+ if part not in current:
+ current[part] = processed_value
+ else:
+ # 💡 終極衝突保護:安全轉型,絕不遺失資料
+ if isinstance(current[part], dict) and isinstance(processed_value, dict):
+ current[part].update(processed_value)
+ elif isinstance(current[part], dict) and not isinstance(processed_value, dict):
+ # 原本是資料夾,新來的是字串 -> 塞入虛擬 key [0], [1]
+ idx = 0
+ while f"[{idx}]" in current[part]: idx += 1
+ current[part][f"[{idx}]"] = processed_value
+ elif not isinstance(current[part], dict) and isinstance(processed_value, dict):
+ # 原本是字串,新來的是資料夾 -> 升級成資料夾,並保留原字串至 [0]
+ old_val = current[part]
+ current[part] = processed_value
+ idx = 0
+ while f"[{idx}]" in current[part]: idx += 1
+ current[part][f"[{idx}]"] = old_val
+ else:
+ # 兩者都是字串 -> 升級成資料夾,包含兩個字串
+ old_val = current[part]
+ current[part] = {"[0]": old_val, "[1]": processed_value}
+ else:
+ if part not in current:
+ current[part] = {}
+ elif not isinstance(current[part], dict):
+ # 中間節點原本是字串,必須升級為資料夾,並保留原字串
+ old_val = current[part]
+ current[part] = {"[0]": old_val}
+ current = current[part]
+
+ return nested_tree
+
+# ==========================================
+# 💡 系統設定管理 (System Settings Manager)
+# ==========================================
+SETTINGS_FILE = "system_settings.json"
+
+def load_settings():
+ """讀取系統設定,若檔案不存在則回傳預設的黑名單"""
+ if os.path.exists(SETTINGS_FILE):
+ try:
+ with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ pass
+ # 預設隱藏這些比較雜亂且不常修改的系統層級設定
+ return {"hidden_keys": ["logging", "privilege", "aaa", "certificate", "line"]}
+
+def save_settings(settings_data):
+ """將系統設定寫入 JSON 檔案"""
+ with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
+ json.dump(settings_data, f, indent=4, ensure_ascii=False)
+
+# 全域非同步鎖:確保同一時間只有一人能執行設定寫入
+cmts_config_lock = asyncio.Lock()
+
+
+================================================================================
+FILE: index.html
+================================================================================
+
+
+
+
+
+ Harmonic CMTS Manager
+
+
+
+
+
+
+
+
+
+
+
+ 🎙️ Harmonic CMTS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚙️ 全域系統設定 (God Mode Filters)
+
+
+
+
+ 請勾選您希望在「完整設備配置樹狀圖」中 隱藏 的設定項目。
+ 💡 點擊下方按鈕載入設備實時配置,您可以深入展開並勾選任何層級的節點進行過濾。
+
+
+
+
+
+
+
+
+
+ ⏳ 正在抓取配置,請稍候...
+
+
+
+
+
+
+
+
+ ✅ 設定已成功儲存!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📸 建立新快照
+
+
+
+
+
+ ⏳ 正在連線設備並抓取設定,請稍候...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 時間 |
+ 快照名稱 |
+ 描述 |
+ 類型 |
+ 操作 |
+
+
+
+
+ | 請點擊「重新整理」載入歷史紀錄... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+================================================================================
+FILE: init_db.py
+================================================================================
+import asyncio
+import asyncpg
+import logging
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# DB 設定
+DB_CONFIG = {
+ "database": "cmts_nms",
+ "user": "swpa",
+ "password": "swpa4920",
+ "host": "127.0.0.1",
+ "port": "5432"
+}
+
+async def init_database():
+ try:
+ logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...")
+ conn = await asyncpg.connect(
+ database=DB_CONFIG["database"],
+ user=DB_CONFIG["user"],
+ password=DB_CONFIG["password"],
+ host=DB_CONFIG["host"],
+ port=int(DB_CONFIG["port"])
+ )
+
+ # 1. 建立選項快取表 cmts_options
+ logger.info("🛠️ 正在建立 cmts_options 資料表...")
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS cmts_options (
+ host VARCHAR(255) NOT NULL,
+ config_type VARCHAR(50) NOT NULL,
+ path VARCHAR(500) NOT NULL,
+ data JSONB NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (host, config_type, path)
+ );
+ """)
+
+ # 2. 建立設備狀態表 device_status
+ logger.info("🛠️ 正在建立 device_status 資料表...")
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS device_status (
+ host VARCHAR(255) NOT NULL,
+ config_type VARCHAR(50) NOT NULL,
+ cmts_version VARCHAR(100) DEFAULT 'unknown',
+ last_scanned VARCHAR(100),
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (host, config_type)
+ );
+ """)
+
+ # 3. 建立過濾器設定表 system_filters
+ logger.info("🛠️ 正在建立 system_filters 資料表...")
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS system_filters (
+ config_type VARCHAR(50) PRIMARY KEY,
+ hidden_keys TEXT[] DEFAULT '{}',
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 4. 建立設備配置備份表 config_backups (Phase 1 新增)
+ logger.info("🛠️ 正在建立 config_backups 資料表與索引...")
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS config_backups (
+ id UUID PRIMARY KEY,
+ host VARCHAR(255) NOT NULL,
+ config_type VARCHAR(50) NOT NULL DEFAULT 'running',
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ snapshot_name VARCHAR(255),
+ is_auto BOOLEAN NOT NULL DEFAULT FALSE,
+ raw_cli TEXT,
+ parsed_tree JSONB,
+ CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
+ );
+ """)
+
+ # 建立複合索引以加速列表查詢與排序
+ await conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
+ ON config_backups (host, config_type, timestamp DESC);
+ """)
+
+ await conn.close()
+ logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
+
+ except Exception as e:
+ logger.error(f"❌ 資料庫初始化失敗: {e}")
+
+if __name__ == "__main__":
+ asyncio.run(init_database())
+
+
+================================================================================
+FILE: cmts_scraper.py
+================================================================================
+# --- cmts_scraper.py ---
+import asyncio
+import asyncssh
+import re
+import json
+import os
+import time
+import database
+from shared import USE_DB
+
+def parse_question_mark_output(output: str) -> dict:
+ """解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
+ hint_lines = []
+ options = []
+ current_value = None
+ format_desc = None
+ is_format_only = False
+
+ state = "INIT"
+ has_description = False
+ has_bracket_value = False
+
+ # 🌟 Case-5 特殊處理:dhcp-relay 混合型欄位 (選項 + IP輸入)
+ # 直接在開頭掃描完整輸出,若包含 dhcp-relay,強制轉為純文字輸入框
+ if "dhcp-relay" in output.lower():
+ is_format_only = True
+ format_desc = "IP address or options"
+
+ for line in output.splitlines():
+ line = line.strip()
+ # 過濾雜訊與終端機提示字元
+ if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
+ continue
+
+ if line.startswith('Description:'):
+ state = "DESC"
+ has_description = True
+ hint_lines.append(line)
+ continue
+
+ if line.startswith('Possible completions:'):
+ state = "COMPLETIONS"
+ # 🌟 解除限制:無論有沒有 Description,都把這行加進 hint
+ hint_lines.append(line)
+ continue
+
+ if state == "DESC":
+ hint_lines.append(line)
+
+ elif state == "COMPLETIONS":
+ # Case 1~4: 無 Description 時,將後續選項說明也納入提示
+ # 🌟 解除限制:無論有沒有 Description,都把這行加進 hint
+ hint_lines.append(line)
+
+ # 規則 1: <格式說明>[當前值]
+ match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
+ if match_format:
+ is_format_only = True
+ format_desc = match_format.group(1).strip()
+ if match_format.group(2):
+ current_value = match_format.group(2).strip()
+ continue
+
+ # 規則 1.5: 型態, 最小值 .. 最大值
+ match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
+ if match_range:
+ is_format_only = True
+ format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
+ continue
+
+ # 規則 1.6: IP address 等純文字關鍵字
+ match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
+ if match_keyword:
+ is_format_only = True
+ format_desc = match_keyword.group(1)
+ continue
+
+ # 規則 2: 選項列表處理
+ match_current = re.search(r"^\[([^\]]+)\]", line)
+ if match_current:
+ has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單
+ if not current_value:
+ current_value = match_current.group(1).strip()
+ clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
+ else:
+ clean_line = line.strip()
+
+ # 🌟 Case-6 防呆:過濾垂直列表的說明文字
+ # 利用「3 個以上的連續空白」作為選項與說明文字的分界線
+ if re.search(r"\s{3,}", clean_line):
+ clean_line = re.split(r"\s{3,}", clean_line)[0]
+
+ parts = clean_line.split()
+ for p in parts:
+ if p and p not in ["|", ".."]:
+ options.append(p)
+
+ # 🌟 Case 2, 3, 4: 子命令防呆機制
+ # 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字
+ if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
+ is_format_only = True
+ options = []
+
+ # 確保現值一定包含在選項中(如果它是一般的下拉選單)
+ if current_value and options and current_value not in options:
+ options.append(current_value)
+
+ hint_text = "\n".join(hint_lines).strip()
+
+ return {
+ "hint": hint_text,
+ # 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
+ "options": list(dict.fromkeys(options)) if not is_format_only else [],
+ "current_value": current_value,
+ "format_desc": format_desc,
+ "is_format_only": is_format_only
+ }
+
+def parse_device_response(output: str) -> dict:
+ """支援多種編輯狀態格式解析"""
+ match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
+
+ if match:
+ enum_content = match.group(1)
+ desc_content = match.group(2)
+ current_value = match.group(3).strip()
+
+ if enum_content:
+ if enum_content.strip().lower() == "list":
+ return {"type": "list", "options": [], "current_value": current_value}
+ else:
+ return {
+ "type": "enum",
+ "options": [opt.strip() for opt in enum_content.split(",") if opt.strip()],
+ "current_value": current_value
+ }
+ elif desc_content:
+ return {
+ "type": "string_or_number",
+ "options": [],
+ "current_value": current_value,
+ "format_desc": desc_content.strip()
+ }
+
+ return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
+
+# 🌟 1. 函數簽名加上 config_type 參數,預設為 "running"
+async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
+ try:
+ total_paths = len(leaf_paths)
+ processed_count = 0
+ result_data = {}
+ BATCH_SIZE = 30
+ batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
+
+ cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
+
+ for batch_idx, batch in enumerate(batches):
+ print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
+ conn = None
+
+ try:
+ conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
+ process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
+
+ async def read_until_quiet(timeout=1.0):
+ output = ""
+ while True:
+ try:
+ chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
+ if not chunk: break
+ output += chunk
+ if "--More--" in chunk or "More" in chunk:
+ process.stdin.write(" ")
+ await process.stdin.drain()
+ except asyncio.TimeoutError:
+ break
+ return output
+
+ # 🌟 新增:在第一批次連線時,先抓取設備版本
+ if cmts_version == "unknown":
+ process.stdin.write("show version\n")
+ await process.stdin.drain()
+ version_output = await read_until_quiet(timeout=1.5)
+ match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
+ if match:
+ cmts_version = match.group(1)
+
+ process.stdin.write("config\n")
+ await process.stdin.drain()
+ await read_until_quiet(timeout=1.5)
+
+ for path in batch:
+ # 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
+ await asyncio.sleep(0.01)
+
+ # --- 步驟 1:發送 '?' 查詢 ---
+ command_to_send = f"{path} ?"
+ process.stdin.write(command_to_send)
+ await process.stdin.drain()
+
+ output_question = await read_until_quiet(timeout=1.0)
+ q_data = parse_question_mark_output(output_question)
+
+ backspaces = "\x08" * (len(command_to_send) + 5)
+ process.stdin.write(backspaces)
+ await process.stdin.drain()
+ await read_until_quiet(timeout=0.5)
+
+ parsed_data = {
+ "hint": q_data["hint"],
+ "options": q_data["options"],
+ "type": "list" if q_data["options"] else "unknown",
+ "current_value": q_data.get("current_value")
+ }
+
+ # --- 步驟 2:判斷是否需要發送 Enter ---
+ if q_data.get("is_format_only"):
+ parsed_data["type"] = "string_or_number"
+
+ elif not q_data["options"]:
+ process.stdin.write(f"{path}\n")
+ await process.stdin.drain()
+
+ output_enter = await read_until_quiet(timeout=1.0)
+ enter_data = parse_device_response(output_enter)
+
+ parsed_data["type"] = enter_data["type"]
+ parsed_data["options"] = enter_data["options"]
+ parsed_data["current_value"] = enter_data["current_value"]
+
+ if enter_data["type"] != "unknown":
+ process.stdin.write("\x03")
+ await process.stdin.drain()
+ else:
+ print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
+ process.stdin.write("\n")
+ await process.stdin.drain()
+
+ await read_until_quiet(timeout=0.5)
+
+ result_data[path] = parsed_data
+
+ processed_count += 1
+ event_data = {
+ "event": "progress",
+ "current": processed_count,
+ "total": total_paths,
+ "current_path": path
+ }
+ yield json.dumps(event_data) + "\n"
+
+ process.stdin.write("exit\n")
+ await process.stdin.drain()
+ await read_until_quiet(timeout=1.0)
+
+ except Exception as e:
+ yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
+ continue
+ finally:
+ if conn: conn.close()
+
+ # --- 步驟 3:寫入快取 (DB or JSON) ---
+ try:
+ db_success = False
+ current_ts = int(time.time())
+ formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+
+ # 1. 嘗試寫入資料庫
+ if USE_DB:
+ try:
+ # 寫入 metadata
+ if cmts_version != "unknown":
+ await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
+ else:
+ await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time})
+
+ # 寫入 options
+ db_write_count = 0
+ for p in batch:
+ if p in result_data:
+ result_data[p]["updated_at"] = current_ts
+ success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
+ if success:
+ db_write_count += 1
+
+ if db_write_count > 0:
+ db_success = True
+ print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
+ else:
+ print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
+ except Exception as e:
+ print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
+
+ # 2. 如果 DB 寫入失敗或 USE_DB=False,則 Fallback 寫入 JSON
+ if not db_success:
+ # 🌟 動態決定快取檔名 (加入 IP 隔離)
+ safe_host = host.replace(".", "_")
+ cache_file = f"{safe_host}_{config_type}_cache.json"
+
+ def read_json_from_file(filepath):
+ if os.path.exists(filepath):
+ with open(filepath, "r", encoding="utf-8") as f:
+ return json.load(f)
+ return {}
+
+ cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
+
+ # 🌟 新增:寫入 Metadata (版本與掃描時間)
+ if "__metadata__" not in cache_data:
+ cache_data["__metadata__"] = {}
+
+ if cmts_version != "unknown":
+ cache_data["__metadata__"]["cmts_version"] = cmts_version
+
+ cache_data["__metadata__"]["last_scanned"] = formatted_time
+
+ for p in batch:
+ if p in result_data:
+ result_data[p]["updated_at"] = current_ts
+ cache_data[p] = result_data[p]
+
+ def write_json_to_file(filepath, data):
+ with open(filepath, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=4, ensure_ascii=False)
+
+ await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
+ print(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
+
+ except Exception as e:
+ print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
+
+ if batch_idx < len(batches) - 1:
+ await asyncio.sleep(2)
+
+ yield json.dumps({"event": "done", "message": "快取更新完成"}) + "\n"
+
+ except Exception as e:
+ yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
+
+async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
+ """連線至設備並抓取完整的純文字設定檔"""
+ conn = None
+ try:
+ conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
+ process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
+
+ async def read_until_quiet(timeout=2.0):
+ output = ""
+ while True:
+ try:
+ chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
+ if not chunk: break
+ output += chunk
+ # 自動翻頁
+ if "--More--" in chunk or "More" in chunk:
+ process.stdin.write(" ")
+ await process.stdin.drain()
+ except asyncio.TimeoutError:
+ break
+ return output
+
+ # 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
+ await read_until_quiet(timeout=1.0)
+
+ # 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
+ if config_type == "full":
+ # 1. 先進入 config 模式
+ process.stdin.write("config\n")
+ await process.stdin.drain()
+ await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
+
+ # 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
+ cmd = "show full-configuration | nomore"
+ process.stdin.write(f"{cmd}\n")
+ await process.stdin.drain()
+ raw_output = await read_until_quiet(timeout=3.0)
+
+ # 3. 抓完後退回上一層 (保持良好習慣)
+ process.stdin.write("exit\n")
+ await process.stdin.drain()
+
+ else:
+ # running-config 在一般模式即可下達 (🌟 加上 | nomore)
+ cmd = "show running-config | nomore"
+ process.stdin.write(f"{cmd}\n")
+ await process.stdin.drain()
+ raw_output = await read_until_quiet(timeout=3.0)
+
+ # 最終退出設備
+ process.stdin.write("exit\n")
+ await process.stdin.drain()
+
+ # 簡單清理頭尾的雜訊 (例如指令本身的 echo)
+ # 🌟 關鍵修正 1:強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
+ cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
+
+ # 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
+ cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
+
+ # 3. 清除純文字的 --More-- (防呆)
+ cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
+
+ # 🌟 新增 4:清除 CableOS 終端機特有的 (END) 結尾標記
+ cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
+
+ # 關鍵修正:這裡改用 cleaned_output 來切行!
+ lines = cleaned_output.splitlines()
+
+ # 🌟 關鍵修正 2:加入 strip() 避免空白干擾,確保精準踢掉提示字元
+ clean_lines = [
+ line for line in lines
+ if not line.strip().startswith(cmd)
+ and not line.strip().startswith("admin@")
+ ]
+ return "\n".join(clean_lines).strip()
+
+ except Exception as e:
+ raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
+ finally:
+ if conn:
+ conn.close()
+
+def parse_config_to_tree(raw_cli: str) -> dict:
+ """將純文字設定檔轉換為簡單的階層式 JSON 樹狀圖 (Phase 3 會用到)"""
+ # 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化
+ tree = {}
+ # 暫時回傳空字典,確保 Phase 1 & 2 能順利走通
+ # 真正的樹狀解析邏輯我們可以在 Phase 3 完善
+ return {"_raw_length": len(raw_cli), "status": "pending_parser"}
+
+================================================================================
+FILE: main.py
+================================================================================
+# --- main.py ---
+import os
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import HTMLResponse
+from contextlib import asynccontextmanager
+import database
+
+# 引入我們剛剛拆分出來的路由模組
+from routers import query, config, terminal, lock, leaf_options, backup
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # Startup
+ await database.init_db_pool()
+ yield
+ # Shutdown
+ await database.close_db_pool()
+
+app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan)
+
+# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+# 🌟 統一標準:所有 REST API 都在這裡掛載 /api/v1 前綴
+app.include_router(query.router, prefix="/api/v1")
+app.include_router(config.router, prefix="/api/v1")
+app.include_router(leaf_options.router, prefix="/api/v1")
+app.include_router(lock.router, prefix="/api/v1")
+app.include_router(backup.router, prefix="/api/v1")
+
+# WebSocket 通常獨立於 API 版本之外,所以不加前綴
+app.include_router(terminal.router)
+
+# 根目錄路由:回傳前端 UI
+@app.get("/", response_class=HTMLResponse, tags=["UI"])
+async def read_root():
+ # 讀取同層級的 index.html
+ html_path = os.path.join(os.path.dirname(__file__), "index.html")
+ with open(html_path, "r", encoding="utf-8") as f:
+ return f.read()
+
+if __name__ == "__main__":
+ import uvicorn
+ # 啟動伺服器
+ uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)
+
+
+================================================================================
+FILE: test_scraper.py
+================================================================================
+from cmts_scraper import sync_cmts_leaves
+
+# 💡 直接套用您在 shared.py 中驗證過的完美設定
+CMTS_DEVICE = {
+ 'device_type': 'cisco_ios',
+ 'host': '10.14.110.4',
+ 'username': 'admin',
+ 'password': 'nsgadmin',
+ 'port': 22,
+ 'fast_cli': False,
+ 'global_delay_factor': 2
+}
+
+# 測試用的葉節點路徑
+TEST_LEAVES = [
+ "clock timezone",
+ "cable mac-domain 13:0/0.0 admin-state"
+]
+
+if __name__ == "__main__":
+ print("🚀 開始執行 CMTS 爬蟲測試...")
+ result = sync_cmts_leaves(CMTS_DEVICE, TEST_LEAVES)
+
+ print("\n📊 最終測試結果:")
+ import json
+ print(json.dumps(result, indent=4, ensure_ascii=False))
+
+
+================================================================================
+FILE: PROJECT_STATE.md
+================================================================================
+# 📖 Harmonic CMTS Manager - Project State (Living Document)
+
+> ⚠️ **AI 助手請注意**:本專案的核心架構與開發規範已移至 `.clinerules`。此檔案僅作為「專案進度存檔」與「待辦事項追蹤」使用。
+
+---
+
+## ✅ 已完成開發階段 (Completed Phases)
+
+### Phase 1: PostgreSQL 高可用性架構升級 (Completed)
+- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
+- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
+- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
+- [x] 調整 `init_db.py` 為非同步啟動腳本。
+
+### Phase 2: 設備配置備份與快照機制 (Completed)
+- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`)。
+- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
+- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。
+- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
+
+---
+
+## 🚧 目前開發階段 (Current Phase)
+
+### Phase 3: 智慧差異還原與歷史預覽 (Smart Diff Recovery & Preview)
+- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
+- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)。
+- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
+- [ ] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 ReadableStream 即時渲染後端傳來的 SSH 逐行執行 Log。
+- [ ] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:
+ - 實作 `/api/v1/backups/{id}/restore` API,改為 Streaming Response (串流回應)。
+ - 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止並回傳錯誤 Chunk。
+
+---
+
+## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
+- **[未來計畫] 自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。
+
+
+================================================================================
+FILE: static/api.js
+================================================================================
+// --- static/api.js ---
+
+// ==========================================
+// 1. 鎖定管理 (Lock Management)
+// ==========================================
+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, host })
+ });
+ const data = await res.json();
+ return { status: res.status, data };
+}
+
+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, host })
+ });
+}
+
+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, 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, 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();
+}
+
+// 🌟 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();
+}
+
+// 🌟 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({ host, leaf_paths: paths, config_type: configType })
+ });
+}
+
+// 🌟 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)
+ });
+ if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
+ return response.json();
+}
+
+export async function apiGetCmtsVersion(host, username, password) {
+ const url = `/api/v1/cmts-version?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
+ const response = await fetch(url);
+ return response.json();
+}
+
+export async function apiGetScanStatus(host, configType = 'running') {
+ try {
+ 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) {
+ return false;
+ }
+}
+
+// ==========================================
+// 3. 指令生成與執行 (CLI Generation & Execution)
+// ==========================================
+export async function apiGenerateCli(diffs, interfaces) {
+ const response = await fetch('/api/v1/cmts-generate-cli', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ diffs, interfaces_with_admin_state: interfaces })
+ });
+ return response.json();
+}
+
+export async function apiExecuteConfig(script, host, username, password) {
+ const response = await fetch('/api/v1/cmts-config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ script, host, username, password })
+ });
+ return response.json();
+}
+
+// ==========================================
+// 4. 系統設定與過濾器 (System Settings)
+// ==========================================
+// 🌟 加上 configType 參數
+export async function apiGetTreeFilters(configType = 'running') {
+ const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
+ return response.json();
+}
+
+// 🌟 加上 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, config_type: configType })
+ });
+ return response.json();
+}
+
+// ==========================================
+// 5. MAC Domain 與綜合查詢 (MAC Domain & Query)
+// ==========================================
+export async function apiGetMacDomainList(host, username, password) {
+ const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
+ const response = await fetch(url);
+ return response.json();
+}
+
+export async function apiGetMacDomainConfig(target, host, username, password) {
+ const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
+ const response = await fetch(url);
+ return response.json();
+}
+
+export async function apiExecuteQuery(queryType, target, host, username, password) {
+ const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
+ const response = await fetch(url);
+ return response.json();
+}
+
+// ==========================================
+// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
+// ==========================================
+// 🌟 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({ host, leaf_paths: paths, config_type: configType })
+ });
+
+ if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
+
+ // 🌟 核心修改:讀取串流資料
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break; // 伺服器斷開連線 (任務完成)
+
+ // 解碼二進位資料為文字
+ const chunk = decoder.decode(value, { stream: true });
+
+ // 因為一次可能收到多行 JSON,我們用換行符號切開
+ const lines = chunk.split("\n").filter(line => line.trim() !== "");
+
+ for (const line of lines) {
+ try {
+ const data = JSON.parse(line);
+ onProgress(data); // 將解析後的資料傳給 UI 介面
+ } catch (e) {
+ console.error("JSON 解析錯誤:", e, line);
+ }
+ }
+ }
+}
+
+
+================================================================================
+FILE: static/app.js
+================================================================================
+/* --- static/app.js --- */
+
+// ============================================================================
+// 📦 模組匯入區 (Imports)
+// ============================================================================
+
+// 1. 終端機模組 (Terminal)
+import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, downloadTerminal, isConnected } from './terminal.js';
+
+// 2. API 網路請求模組 (API)
+import {
+ apiGetFullConfig, apiClearCache, apiExecuteQuery,
+ apiGetTreeFilters, apiSaveTreeFilters,
+ apiSyncLeafOptionsStream, apiGetCmtsVersion,
+ apiGetScanStatus, apiGetLockStatus
+} from './api.js';
+
+// 3. 共用工具模組 (Utils)
+import { getGlobalConnectionInfo } from './utils.js';
+
+// 4. 樹狀圖 UI 模組 (Tree UI)
+import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-ui.js';
+
+// 5. 編輯模式與鎖定模組 (Edit Mode)
+import {
+ releaseAllLocks, startEditFolder, startEditLeaf,
+ cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
+ hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
+} from './edit-mode.js';
+
+// 6. MAC Domain 配置模組 (MAC Domain)
+import {
+ hasMacDomainData, resetMacDomainData, loadMacDomainList, fetchMacDomainConfig,
+ populateFormFromData, revertFormChanges, toggleGroupVisibility,
+ loadDsGroupData, loadUsGroupData, generateMacDomainCLI, executeBondingConfig
+} from './mac-domain.js';
+
+
+// ============================================================================
+// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
+// ============================================================================
+
+let idleTimer;
+const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
+let globalSSE = null;
+
+// 初始化全域 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);
+ const statusEl = document.getElementById('scan-status');
+
+ // 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
+ if (data.event === 'scan_start' || data.event === 'progress') {
+ setAdminButtonsState(true, "⏳ 系統更新中...");
+ }
+
+ if (data.event === 'scan_start') {
+ if (statusEl) {
+ statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
+ statusEl.style.color = "#f39c12";
+ }
+ }
+ else if (data.event === 'progress') {
+ if (statusEl) {
+ statusEl.innerHTML = `⏳ 背景掃描進度:${data.current} / ${data.total} (正在處理: ${data.current_path})`;
+ statusEl.style.color = "#f39c12";
+ }
+ }
+ else if (data.event === 'done') {
+ if (statusEl) {
+ statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
+ statusEl.style.color = "#27ae60";
+ setTimeout(() => statusEl.textContent = "", 5000);
+ }
+ // 掃描完成才解鎖
+ setAdminButtonsState(false);
+ localStorage.removeItem('scanLockUntil');
+ }
+ else if (data.event === 'error') {
+ if (statusEl) {
+ statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
+ statusEl.style.color = "#e74c3c";
+ }
+ // 發生錯誤也要解鎖
+ setAdminButtonsState(false);
+ localStorage.removeItem('scanLockUntil');
+ }
+ };
+}
+
+// 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線
+window.addEventListener('beforeunload', () => {
+ if (globalSSE) {
+ globalSSE.close();
+ globalSSE = null;
+ }
+});
+
+// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」
+let activeLockState = new Set();
+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 currentLocks = result.data;
+ const newLockState = new Set();
+
+ // 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點
+ for (const [path, lockInfo] of Object.entries(currentLocks)) {
+ if (lockInfo.user_id !== SESSION_USER_ID) {
+ newLockState.add(path);
+ const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
+
+ targetBtns.forEach(btn => {
+ if (btn.innerText !== "🔒") {
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.2';
+ btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
+ btn.innerText = "🔒";
+ }
+ });
+ }
+ }
+
+ // 🔓 任務 2:處理「解除鎖定」的節點
+ activeLockState.forEach(oldPath => {
+ if (!newLockState.has(oldPath)) {
+ const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
+
+ targetBtns.forEach(btn => {
+ if (btn.innerText === "🔒") {
+ btn.style.pointerEvents = 'auto';
+ btn.style.opacity = '0.3';
+ btn.title = "鎖定並編輯此項目";
+ btn.innerText = "✏️";
+ }
+ });
+ }
+ });
+
+ // 💾 任務 3:更新特務的記憶
+ activeLockState = newLockState;
+ }
+ } 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(); };
+
+// 閒置計時器重置邏輯 (超時將自動釋放所有編輯鎖定)
+function resetIdleTimer() {
+ clearTimeout(idleTimer);
+ idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
+}
+
+// 🌟 效能急救:節流閥 (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)
+// ============================================================================
+
+// 切換主頁籤 (終端機 / 設備查詢 / 設備配置 / 系統設定)
+function switchTab(tabId, element) {
+ document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
+ document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
+
+ const targetTab = document.getElementById(tabId);
+ if (targetTab) targetTab.classList.add('active');
+ if (element) element.classList.add('active');
+
+ if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
+}
+
+// 切換「設備查詢」內的子任務表單
+function switchQueryTask() {
+ document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
+ const selectedTaskId = document.getElementById('queryTask').value;
+ const targetForm = document.getElementById(selectedTaskId);
+ 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;
+
+ 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);
+ }
+}
+
+// 啟動配置任務 (樹狀圖載入 或 MAC Domain 精靈)
+function startConfigTask() {
+ if (!isConnected) {
+ alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!");
+ return;
+ }
+ switchConfigTask();
+ const selectedTaskId = document.getElementById('configTask').value;
+
+ if (selectedTaskId === 'form-bonding-config') {
+ if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
+ resetAllManagerData();
+ loadMacDomainList();
+ }
+ // 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType
+ else if (selectedTaskId === 'form-running-config') {
+ fetchFullConfig('running');
+ }
+ else if (selectedTaskId === 'form-full-config') {
+ fetchFullConfig('full'); // 🌟 補上 full 參數
+ }
+}
+
+// 切換查詢輸入框的啟用狀態 (全域指令不需輸入目標)
+function toggleQueryInputs(mode) {
+ const type = document.getElementById('queryType' + mode).value;
+ const targetInput = document.getElementById('queryTarget' + mode);
+ const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum'];
+
+ if (globalTypes.includes(type)) {
+ targetInput.disabled = true;
+ targetInput.style.backgroundColor = '#e8ecef';
+ targetInput.placeholder = "此查詢為全域指令,不需輸入目標";
+ targetInput.value = "";
+ } else {
+ targetInput.disabled = false;
+ targetInput.style.backgroundColor = '#f8f9fa';
+ targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)";
+ }
+}
+
+// 重置所有管理介面的資料與 UI 狀態
+function resetAllManagerData() {
+ resetMacDomainData();
+
+ const configArea = document.getElementById('macDomainConfigArea');
+ if (configArea) configArea.style.display = 'none';
+
+ const previewEl = document.getElementById('cliPreviewContainer');
+ if (previewEl) {
+ previewEl.textContent = '';
+ previewEl.style.display = 'none';
+ }
+
+ const deployBtn = document.getElementById('btnDeployConfig');
+ if (deployBtn) {
+ deployBtn.style.display = 'none';
+ deployBtn.disabled = true;
+ }
+
+ const mdSelect = document.getElementById('cfgMacDomain');
+ if (mdSelect) mdSelect.innerHTML = '';
+
+ const fetchStatus = document.getElementById('fetchStatus');
+ if (fetchStatus) {
+ fetchStatus.textContent = "請先讀取設備狀態...";
+ fetchStatus.style.color = "#7f8c8d";
+ }
+
+ document.getElementById('queryTargetCm').value = '';
+ document.getElementById('queryTargetRpd').value = '';
+
+ // 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
+ if (typeof loadBackupHistory === 'function') {
+ loadBackupHistory();
+ }
+}
+
+// ============================================================================
+// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
+// ============================================================================
+
+// 執行綜合查詢 (CM / RPD)
+async function executeQuery(mode) {
+ if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕!");
+
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo) return;
+
+ const queryType = document.getElementById('queryType' + mode).value;
+ const target = document.getElementById('queryTarget' + mode).value.trim();
+ const isDebug = document.getElementById('debugQuery' + mode).checked;
+
+ openModal(connInfo.host);
+ const outputEl = document.getElementById('modalOutput');
+ outputEl.innerHTML = `正在向 ${connInfo.host} 查詢中,請稍候...`;
+
+ try {
+ const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
+ if (result.status === 'success') {
+ outputEl.style.color = "#e0e0e0";
+ outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data;
+ } else {
+ outputEl.style.color = "#e74c3c";
+ outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
+ }
+ } catch (error) {
+ outputEl.style.color = "#e74c3c";
+ outputEl.textContent = `連線失敗: ${error}`;
+ }
+}
+
+// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀)
+async function fetchFullConfig(configType = 'running') {
+ const loadingMsg = document.getElementById('loading-message');
+ const treeContainer = document.getElementById(`tree-container-${configType}`);
+
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo) return;
+
+ if (loadingMsg) {
+ loadingMsg.style.display = 'inline-block';
+ loadingMsg.innerHTML = '⏳ 準備連線至設備...';
+ }
+ if (treeContainer) treeContainer.innerHTML = '';
+
+ let needAutoScan = false;
+ let progressInterval = null;
+
+ try {
+ // --- 1. 版本守門員 (維持原樣) ---
+ try {
+ 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?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
+ const optData = await optRes.json();
+ const cachedVersion = optData.__metadata__?.cmts_version;
+
+ if (cachedVersion && cachedVersion !== currentVersion) {
+ const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
+ if (doClear) {
+ const allKeys = Object.keys(optData);
+ await apiClearCache(connInfo.host, allKeys, configType);
+ needAutoScan = true;
+ }
+ }
+ }
+ } catch (vErr) {
+ console.warn("版本檢查失敗,略過", vErr);
+ }
+
+ // --- 2. 載入完整配置 (ReadableStream 串流接收) ---
+ const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
+
+ if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ let buffer = "";
+ let finalTreeData = null;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ let lines = buffer.split('\n');
+ buffer = lines.pop();
+
+ for (let line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const data = JSON.parse(line);
+
+ if (data.status === 'progress') {
+ if (data.message.includes('正在下載')) {
+ let progress = 0;
+ if (progressInterval) clearInterval(progressInterval);
+
+ progressInterval = setInterval(() => {
+ progress += (95 - progress) * 0.06;
+ if (loadingMsg) {
+ loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
+ }
+ }, 500);
+ } else {
+ // 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
+ if (progressInterval) {
+ clearInterval(progressInterval);
+ progressInterval = null;
+
+ // 1. 強制填滿 100% 並給予成功提示
+ if (loadingMsg) {
+ loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
+ loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感
+ }
+
+ // 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
+ await new Promise(resolve => setTimeout(resolve, 600));
+
+ // 3. 恢復原本的顏色
+ if (loadingMsg) loadingMsg.style.color = "";
+ }
+
+ // 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
+ if (loadingMsg) loadingMsg.innerHTML = data.message;
+ }
+ }
+ else if (data.status === 'success') {
+ finalTreeData = data.data;
+ }
+ else if (data.status === 'error') {
+ throw new Error(data.message);
+ }
+ } catch (e) {
+ console.warn("解析串流 JSON 失敗:", line);
+ }
+ }
+ }
+
+ if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
+
+ window.currentTreeData = finalTreeData;
+
+ // --- 3. 防呆攔截與畫面渲染 ---
+ const currentSelectedTask = document.getElementById('configTask').value;
+ const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
+
+ if (currentSelectedTask !== expectedTask) return;
+
+ if (treeContainer) {
+ treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
+ if (loadingMsg) loadingMsg.style.display = 'none';
+
+ const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
+ if (isSomeoneScanning) {
+ alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
+ lockScanButton(60000);
+ } else {
+ enableGlobalScanButton();
+ if (needAutoScan) {
+ setTimeout(() => scanGlobalMissingOptions(configType), 500);
+ }
+ }
+ }
+
+ } catch (error) {
+ if (treeContainer) treeContainer.innerHTML = `❌ 連線或解析失敗: ${error.message}
`;
+ } finally {
+ if (progressInterval) clearInterval(progressInterval);
+ if (loadingMsg) {
+ loadingMsg.style.display = 'none';
+ loadingMsg.style.color = ""; // 確保重置顏色
+ }
+ }
+}
+
+// ============================================================================
+// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
+// ============================================================================
+
+// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
+async function enhanceInputWithOptions(path, elementId, mode = 'running') {
+ // 🌟 1. 取得當前連線的 IP
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo || !connInfo.host) return;
+
+ try {
+ // 🌟 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, '');
+ const leafCache = optData[cacheKey];
+
+ if (leafCache && leafCache.options && leafCache.options.length > 0) {
+ const container = document.getElementById(`container-${elementId}`);
+ const input = container.querySelector('.edit-input');
+
+ if (input) {
+ const listId = `dl-${elementId}`;
+ input.setAttribute('list', listId);
+
+ let dataList = document.getElementById(listId);
+ if (!dataList) {
+ dataList = document.createElement('datalist');
+ dataList.id = listId;
+ container.appendChild(dataList);
+ }
+ dataList.innerHTML = leafCache.options.map(opt => `⏳ 查詢設備中...';
+ statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
+ statusSpan.style.color = "#f39c12";
+
+ try {
+ const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
+ if (result.status === 'success' && result.data.length > 0) {
+ mdSelect.innerHTML = '';
+ result.data.forEach(md => {
+ const option = document.createElement('option');
+ option.value = md;
+ option.textContent = md;
+ mdSelect.appendChild(option);
+ });
+ statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
+ statusSpan.style.color = "#27ae60";
+ } else {
+ mdSelect.innerHTML = '❌ 找不到資料';
+ statusSpan.textContent = "無法解析清單,請確認設備狀態。";
+ statusSpan.style.color = "#e74c3c";
+ }
+ } catch (error) {
+ mdSelect.innerHTML = '❌ 連線錯誤';
+ statusSpan.textContent = "連線失敗:" + error;
+ statusSpan.style.color = "#e74c3c";
+ }
+}
+
+export async function fetchMacDomainConfig() {
+ const target = document.getElementById('cfgMacDomain').value.trim();
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo || !target) return alert("請確認設備連線資訊與 MAC Domain!");
+
+ document.getElementById('fetchStatus').textContent = "⏳ 正在讀取並解析設定中...";
+ document.getElementById('fetchStatus').style.color = "#f39c12";
+
+ try {
+ const result = await apiGetMacDomainConfig(target, connInfo.host, connInfo.user, connInfo.pass);
+ if (result.status === 'success') {
+ currentMacDomainData = result.data;
+ populateFormFromData();
+ document.getElementById('macDomainConfigArea').style.display = 'block';
+ document.getElementById('fetchStatus').textContent = "✅ 讀取成功!";
+ document.getElementById('fetchStatus').style.color = "#27ae60";
+ } else {
+ alert("讀取失敗:" + result.detail);
+ document.getElementById('fetchStatus').textContent = "❌ 讀取失敗";
+ }
+ } catch (error) {
+ alert("連線錯誤:" + error);
+ }
+}
+
+export function populateFormFromData() {
+ const d = currentMacDomainData;
+ if (!d) return;
+
+ document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack';
+ document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled';
+ document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled';
+ document.getElementById('g_bat30').value = d.common_settings['cm-battery-mode-30-support'] || 'disabled';
+ document.getElementById('g_docsis40').value = d.common_settings['docsis40'] || 'disabled';
+ document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled';
+ document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled';
+
+ document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down';
+ document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || '';
+ document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || '';
+ document.getElementById('b_us_phy').value = d.basic_channel_sets['us-phy-channel-set'] || '';
+ document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || '';
+ document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || '';
+
+ const dsSelect = document.getElementById('select_ds_group');
+ dsSelect.innerHTML = '[➕ 新增 DS Group]';
+ Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += `${grp}`);
+ if (Object.keys(d.static_ds_bonding_groups).length > 0) dsSelect.value = Object.keys(d.static_ds_bonding_groups)[0];
+
+ const usSelect = document.getElementById('select_us_group');
+ usSelect.innerHTML = '[➕ 新增 US Group]';
+ Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `${grp}`);
+ if (Object.keys(d.static_us_bonding_groups).length > 0) usSelect.value = Object.keys(d.static_us_bonding_groups)[0];
+
+ toggleGroupVisibility();
+ loadDsGroupData();
+ loadUsGroupData();
+}
+
+export function revertFormChanges() {
+ if (!currentMacDomainData) return;
+ if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) return;
+
+ populateFormFromData();
+
+ const previewEl = document.getElementById('cliPreviewContainer');
+ if (previewEl) {
+ previewEl.textContent = '';
+ previewEl.style.display = 'none';
+ }
+
+ const deployBtn = document.getElementById('btnDeployConfig');
+ if (deployBtn) {
+ deployBtn.style.display = 'none';
+ deployBtn.disabled = true;
+ }
+
+ const fetchStatus = document.getElementById('fetchStatus');
+ fetchStatus.textContent = "🔄 已還原為原始設定!";
+ fetchStatus.style.color = "#2980b9";
+
+ setTimeout(() => {
+ fetchStatus.textContent = "✅ 讀取成功!";
+ fetchStatus.style.color = "#27ae60";
+ }, 3000);
+}
+
+export function toggleGroupVisibility() {
+ const dsDyn = document.getElementById('g_ds_dyn').value;
+ const usDyn = document.getElementById('g_us_dyn').value;
+ document.getElementById('section_group_ds').style.display = (dsDyn === 'disabled') ? 'block' : 'none';
+ document.getElementById('section_group_us').style.display = (usDyn === 'disabled') ? 'block' : 'none';
+}
+
+export function loadDsGroupData() {
+ const grp = document.getElementById('select_ds_group').value;
+ const newGrpInput = document.getElementById('input_new_ds_group');
+ if (grp === '_new_') {
+ newGrpInput.style.display = 'inline-block';
+ document.getElementById('ds_g_admin').value = 'down';
+ document.getElementById('ds_g_down').value = '';
+ document.getElementById('ds_g_ofdm').value = '';
+ document.getElementById('ds_g_fdx').value = '';
+ } else {
+ newGrpInput.style.display = 'none';
+ const data = currentMacDomainData.static_ds_bonding_groups[grp];
+ document.getElementById('ds_g_admin').value = data['admin-state'] || 'down';
+ document.getElementById('ds_g_down').value = data['down-channel-set'] || '';
+ document.getElementById('ds_g_ofdm').value = data['ofdm-channel-set'] || '';
+ document.getElementById('ds_g_fdx').value = data['fdx-ofdm-channel-set'] || '';
+ }
+}
+
+export function loadUsGroupData() {
+ const grp = document.getElementById('select_us_group').value;
+ const newGrpInput = document.getElementById('input_new_us_group');
+ if (grp === '_new_') {
+ newGrpInput.style.display = 'inline-block';
+ document.getElementById('us_g_admin').value = 'down';
+ document.getElementById('us_g_us').value = '';
+ document.getElementById('us_g_ofdma').value = '';
+ document.getElementById('us_g_fdx').value = '';
+ } else {
+ newGrpInput.style.display = 'none';
+ const data = currentMacDomainData.static_us_bonding_groups[grp];
+ document.getElementById('us_g_admin').value = data['admin-state'] || 'down';
+ document.getElementById('us_g_us').value = data['us-channel-set'] || '';
+ document.getElementById('us_g_ofdma').value = data['ofdma-channel-set'] || '';
+ document.getElementById('us_g_fdx').value = data['fdx-ofdma-channel-set'] || '';
+ }
+}
+
+export function generateMacDomainCLI() {
+ const md = document.getElementById('cfgMacDomain').value.trim();
+ let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`;
+
+ cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\n`;
+ cli += `cable mac-domain ${md} admin-state down\ncommit\n`;
+
+ const baseCmds = [
+ `ip-provisioning-mode ${document.getElementById('g_ip_prov').value}`,
+ `diplexer-band-edge control ${document.getElementById('g_diplexer').value}`,
+ `cm-battery-mode-31-support ${document.getElementById('g_bat31').value}`,
+ `cm-battery-mode-30-support ${document.getElementById('g_bat30').value}`,
+ `docsis40 ${document.getElementById('g_docsis40').value}`,
+ `ds-dynamic-bonding-group ${document.getElementById('g_ds_dyn').value}`,
+ `us-dynamic-bonding-group ${document.getElementById('g_us_dyn').value}`,
+ `ds-primary-set ${document.getElementById('b_ds_pri').value}`,
+ `ds-non-primary-set ${document.getElementById('b_ds_non_pri').value}`,
+ `us-phy-channel-set ${document.getElementById('b_us_phy').value}`,
+ `ds-ofdm-set ${document.getElementById('b_ds_ofdm').value}`,
+ `us-ofdma-set ${document.getElementById('b_us_ofdma').value}`
+ ];
+ baseCmds.forEach(cmd => {
+ if (!cmd.endsWith(" ")) cli += `cable mac-domain ${md} ${cmd}\n`;
+ });
+ cli += `cable mac-domain ${md} admin-state ${document.getElementById('b_admin').value}\ncommit\n`;
+
+ if (document.getElementById('g_ds_dyn').value === 'disabled') {
+ const isNewDs = document.getElementById('select_ds_group').value === '_new_';
+ let dsName = isNewDs ? document.getElementById('input_new_ds_group').value.trim() : document.getElementById('select_ds_group').value;
+
+ if (dsName) {
+ cli += `\n! 2. [區塊] DS Bonding Group [${dsName}] 設定\n`;
+ if (!isNewDs) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state down\ncommit\n`;
+
+ if (document.getElementById('ds_g_down').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} down-channel-set ${document.getElementById('ds_g_down').value}\n`;
+ if (document.getElementById('ds_g_ofdm').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} ofdm-channel-set ${document.getElementById('ds_g_ofdm').value}\n`;
+ if (document.getElementById('ds_g_fdx').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} fdx-ofdm-channel-set ${document.getElementById('ds_g_fdx').value}\n`;
+
+ cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state ${document.getElementById('ds_g_admin').value}\ncommit\n`;
+ }
+ }
+
+ if (document.getElementById('g_us_dyn').value === 'disabled') {
+ const isNewUs = document.getElementById('select_us_group').value === '_new_';
+ let usName = isNewUs ? document.getElementById('input_new_us_group').value.trim() : document.getElementById('select_us_group').value;
+
+ if (usName) {
+ cli += `\n! 3. [區塊] US Bonding Group [${usName}] 設定\n`;
+ if (!isNewUs) cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state down\ncommit\n`;
+
+ if (document.getElementById('us_g_us').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} us-channel-set ${document.getElementById('us_g_us').value}\n`;
+ if (document.getElementById('us_g_ofdma').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} ofdma-channel-set ${document.getElementById('us_g_ofdma').value}\n`;
+ if (document.getElementById('us_g_fdx').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} fdx-ofdma-channel-set ${document.getElementById('us_g_fdx').value}\n`;
+
+ cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state ${document.getElementById('us_g_admin').value}\ncommit\n`;
+ }
+ }
+
+ const previewEl = document.getElementById('cliPreviewContainer');
+ previewEl.textContent = cli;
+ previewEl.style.display = 'block';
+
+ const deployBtn = document.getElementById('btnDeployConfig');
+ deployBtn.style.display = 'inline-block';
+ deployBtn.disabled = false;
+}
+
+export async function executeBondingConfig() {
+ if (!confirm("⚠️ 警告:此操作將導致 MAC Domain 重新啟動,影響用戶服務。確定要執行嗎?")) return;
+
+ const cliScript = document.getElementById('cliPreviewContainer').textContent;
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo) return;
+
+ // 開啟 Modal 顯示執行進度
+ document.getElementById('outputModal').classList.add('active');
+ document.getElementById('modalTargetInfo').textContent = connInfo.host ? `(${connInfo.host})` : '';
+ document.body.style.overflow = 'hidden';
+
+ const outputEl = document.getElementById('modalOutput');
+ outputEl.innerHTML = `🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...\n\n${cliScript}`;
+
+ try {
+ const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
+ if (result.status === 'success') {
+ outputEl.style.color = "#e0e0e0";
+ outputEl.textContent = `✅ 設定執行完成!\n==================================================\n${result.data}`;
+ } else {
+ outputEl.style.color = "#e74c3c";
+ outputEl.textContent = `❌ 設定執行失敗:\n${result.message}`;
+ }
+ } catch (error) {
+ outputEl.style.color = "#e74c3c";
+ outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
+ }
+}
+
+
+================================================================================
+FILE: static/utils.js
+================================================================================
+// --- static/utils.js ---
+
+// 加上 export,讓其他檔案可以使用這個函數
+export function getGlobalConnectionInfo() {
+ const host = document.getElementById('cmtsHost').value.trim();
+ const user = document.getElementById('cmtsUser').value.trim();
+ const pass = document.getElementById('cmtsPass').value.trim();
+
+ if (!host || !user || !pass) {
+ alert("請在畫面上方完整輸入目標設備的 IP、帳號與密碼!");
+ return null;
+ }
+ return { host, user, pass };
+}
+
+
+================================================================================
+FILE: static/tree-ui.js
+================================================================================
+// --- static/tree-ui.js ---
+
+// ==========================================
+// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
+// ==========================================
+export const folderDataCache = {};
+export const filterFolderCache = {};
+
+// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
+window.lazyLoadFolder = function(elementId, isFilter = false) {
+ const detailsEl = document.getElementById(`details-${elementId}`);
+ // 如果已經載入過,就不重複渲染
+ if (!detailsEl || detailsEl.dataset.loaded === 'true') return;
+
+ // 從 dataset 讀取當初存下來的屬性
+ const currentPath = detailsEl.dataset.path;
+ const isCommandGroup = detailsEl.dataset.isGroup === 'true';
+ const mode = detailsEl.dataset.mode;
+
+ const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
+ const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
+
+ if (contentDiv && cache) {
+ if (isFilter) {
+ contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
+ } else {
+ contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
+ }
+ detailsEl.dataset.loaded = 'true'; // 標記為已載入
+ }
+};
+
+// ==========================================
+// 🌟 輔助函數:全部展開與全部收合 (支援延遲渲染)
+// ==========================================
+export function expandAll(elementId) {
+ const details = document.getElementById(`details-${elementId}`);
+ if (details) {
+ // 如果還沒載入,先強制載入它
+ if (!details.dataset.loaded) {
+ const isFilter = details.id.includes('filter-');
+ window.lazyLoadFolder(elementId, isFilter);
+ }
+ details.open = true;
+
+ // 找出剛剛渲染出來的第一層子資料夾,遞迴展開
+ const contentDiv = details.querySelector(':scope > div');
+ if (contentDiv) {
+ const childDetails = contentDiv.querySelectorAll(':scope > details');
+ childDetails.forEach(d => {
+ const subId = d.id.replace('details-', '');
+ expandAll(subId); // 遞迴展開
+ });
+ }
+ }
+}
+
+export function collapseAll(elementId) {
+ const details = document.getElementById(`details-${elementId}`);
+ if (details) {
+ const subDetails = details.querySelectorAll('details');
+ subDetails.forEach(d => d.open = false);
+ details.open = false;
+ }
+}
+
+// ==========================================
+// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
+// ==========================================
+export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
+ let html = '';
+ if (!node || typeof node !== 'object') return html;
+
+ for (const [key, value] of Object.entries(node)) {
+ const currentPath = path ? `${path}::${key}` : key;
+ let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
+
+ if (typeof value === 'object' && value !== null) {
+ const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
+ const isCommandGroup = isParentCommandGroup || !hasNestedObject;
+ const elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
+ const folderSuffix = isCommandGroup ? '(指令群組)' : '';
+
+ // 🌟 將資料存入快取,供延遲渲染使用
+ folderDataCache[elementId] = value;
+
+ const svgFolderClosed = ``;
+ const svgFolderOpen = ``;
+ const svgGroupClosed = ``;
+ const svgGroupOpen = ``;
+
+ const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
+ const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
+
+ const expandAllIcon = ``;
+ const collapseAllIcon = ``;
+
+ const expandCollapseBtns = `
+
+
+ ${expandAllIcon}
+
+
+ ${collapseAllIcon}
+
+
+ `;
+
+ // 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
+ html += `
+
+
+
+
+
+
+ `;
+ } else {
+ // 葉節點邏輯保持不變
+ const isVirtualIndex = /^\[\d+\]$/.test(key);
+ const isNoCommand = (key === 'no');
+ const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
+ const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
+
+ if (isNoCommand) {
+ let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
+ cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
+ }
+
+ const svgLeaf = ``;
+ const svgDisabled = ``;
+
+ const iconHtml = (icon) => `${icon}`;
+ const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
+
+ let displayContent = '';
+
+ if (isVirtualIndex) {
+ displayContent = `
+ ${iconHtml(svgLeaf)}
+
+ ${safeValue}
+
+ `;
+ } else if (isNoCommand) {
+ displayContent = `
+
+ ${iconHtml(svgDisabled)}
+ no:
+
+ ${safeValue}
+
+
+ `;
+ } else {
+ displayContent = `
+ ${iconHtml(svgLeaf)} ${key}:
+
+ ${safeValue}
+
+ `;
+ }
+
+ html += `
+
+ ${displayContent}
+
+
+
+ 🔍
+
+
+ ✏️
+
+
+
+
+
+
+
+ `;
+ }
+ }
+ return html;
+}
+
+// ==========================================
+// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
+// ==========================================
+export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
+ if (typeof data !== 'object' || data === null) return '';
+
+ let html = ``;
+
+ for (const key in data) {
+ const value = data[key];
+ const currentPath = parentPath ? `${parentPath}::${key}` : key;
+ const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
+
+ let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
+
+ const svgFolderClosed = `
`;
+ const svgFolderOpen = `
`;
+ const svgGroupClosed = `
`;
+ const svgGroupOpen = `
`;
+ const svgLeaf = `
`;
+
+ const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
+ const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
+ const checkboxHtml = `
`;
+
+ if (isFolder) {
+ const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
+ const isCommandGroup = isParentCommandGroup || !hasNestedObject;
+ const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
+
+ // 🌟 存入過濾器專用快取
+ filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
+
+ const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
+ const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
+ const folderSuffix = isCommandGroup ? `
(指令群組)` : '';
+
+ const expandAllIcon = `
`;
+ const collapseAllIcon = `
`;
+
+ const expandCollapseBtns = `
+
+
+ ${expandAllIcon}
+
+
+ ${collapseAllIcon}
+
+
+ `;
+
+ // 🌟 加入 lazyLoadFolder 觸發
+ html += `
+
+
+
+
+
+
+ `;
+ } else {
+ // 葉節點邏輯保持不變
+ const isVirtualIndex = /^\[\d+\]$/.test(key);
+ let displayName = isVirtualIndex ? `
項目 ${key}` : `
${key}`;
+ const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
+
+ const isNoCommand = (key === 'no');
+
+ let valueHtml = `
${safeValue}`;
+ let colonHtml = `:`;
+ let currentIcon = `
`;
+
+ if (isNoCommand) {
+ let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
+ cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
+
+ displayName = `
no`;
+ colonHtml = `:`;
+ valueHtml = `
${safeValue}`;
+ currentIcon = `
`;
+ }
+
+ html += `
+
+ ${checkboxHtml}
+
+ ${currentIcon}
+
+ ${displayName}${colonHtml}
+ ${valueHtml}
+
+ `;
+ }
+ }
+ html += '
';
+ return html;
+}
+
+================================================================================
+FILE: routers/backup.py
+================================================================================
+import logging
+import asyncssh
+import asyncio
+import re
+import json
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from typing import Optional, List
+
+# 引入 DB 函數
+from database import (
+ insert_config_backup,
+ get_config_backup_list,
+ get_config_backup_detail,
+ delete_config_backup
+)
+
+from cmts_scraper import fetch_raw_config
+from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(
+ prefix="/backups",
+ tags=["Backups"]
+)
+
+# ==========================================
+# 📦 Pydantic Models
+# ==========================================
+class SnapshotRequest(BaseModel):
+ host: str
+ username: str
+ password: str
+ snapshot_name: str
+ description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述
+ config_type: str = "running"
+
+class RestoreRequest(BaseModel):
+ host: str
+ username: str
+ password: str
+
+class ExecuteRestoreRequest(BaseModel):
+ host: str
+ username: str
+ password: str
+ commands: List[str] # 接收前端確認後的差異指令清單
+
+# ==========================================
+# 🛠️ 核心演算法:深度差異比對 (Deep Diff)
+# ==========================================
+# 🌟 新增小工具:拔除 [0], [1] 虛擬後綴,還原真實 CLI
+def clean_virtual_key(key: str) -> str:
+ return re.sub(r'\s*\[\d+\]$', '', key)
+
+def build_add_commands(node: dict, current_path: str) -> list:
+ """遞迴展開所有需要新增的絕對路徑指令"""
+ commands = []
+ if not node:
+ commands.append(current_path)
+ return commands
+
+ for key, val in node.items():
+ if key.startswith('_'):
+ continue
+
+ # 🌟 使用乾淨的 key 來組合路徑
+ real_key = clean_virtual_key(key)
+ path = f"{current_path} {real_key}".strip()
+
+ if isinstance(val, dict):
+ commands.extend(build_add_commands(val, path))
+ else:
+ val_str = f" {val}" if val else ""
+ commands.append(f"{path}{val_str}")
+ return commands
+
+def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list:
+ """比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
+ commands = []
+
+ for key, curr_val in current_node.items():
+ if key.startswith('_'):
+ continue
+
+ # 🌟 使用乾淨的 key
+ real_key = clean_virtual_key(key)
+ path = f"{current_path} {real_key}".strip()
+
+ if key not in snapshot_node:
+ commands.append(f"no {path}")
+ else:
+ snap_val = snapshot_node[key]
+ if isinstance(curr_val, dict) and isinstance(snap_val, dict):
+ commands.extend(generate_diff_commands(curr_val, snap_val, path))
+ elif isinstance(curr_val, dict) or isinstance(snap_val, dict):
+ commands.append(f"no {path}")
+ if isinstance(snap_val, dict):
+ commands.extend(build_add_commands(snap_val, path))
+ else:
+ val_str = f" {snap_val}" if snap_val else ""
+ commands.append(f"{path}{val_str}")
+ elif curr_val != snap_val:
+ val_str = f" {snap_val}" if snap_val else ""
+ commands.append(f"{path}{val_str}")
+
+ for key, snap_val in snapshot_node.items():
+ if key.startswith('_'):
+ continue
+
+ # 🌟 使用乾淨的 key
+ real_key = clean_virtual_key(key)
+ path = f"{current_path} {real_key}".strip()
+
+ if key not in current_node:
+ if isinstance(snap_val, dict):
+ commands.extend(build_add_commands(snap_val, path))
+ else:
+ val_str = f" {snap_val}" if snap_val else ""
+ commands.append(f"{path}{val_str}")
+
+ return commands
+
+# ==========================================
+# 🚀 API 路由
+# ==========================================
+@router.get("/", summary="取得歷史快照列表")
+async def list_backups(host: str, config_type: str = "running"):
+ records = await get_config_backup_list(host, config_type)
+ if records is None:
+ return {"status": "error", "message": "資料庫查詢失敗"}
+ return {"status": "success", "data": records}
+
+@router.get("/{backup_id}", summary="取得特定快照詳細內容")
+async def get_backup_detail(backup_id: str):
+ record = await get_config_backup_detail(backup_id)
+ if not record:
+ return {"status": "error", "message": "找不到該筆備份資料"}
+ return {"status": "success", "data": record}
+
+@router.delete("/{backup_id}", summary="刪除特定快照")
+async def delete_backup(backup_id: str):
+ success = await delete_config_backup(backup_id)
+ if not success:
+ return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
+ return {"status": "success", "message": "快照已成功刪除"}
+
+@router.post("/snapshot", summary="手動建立設備快照")
+async def create_snapshot(req: SnapshotRequest):
+ try:
+ raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
+ parsed_tree = parse_cli_to_tree(raw_cli)
+
+ backup_id = await insert_config_backup(
+ host=req.host,
+ config_type=req.config_type,
+ raw_cli=raw_cli,
+ parsed_tree=parsed_tree,
+ snapshot_name=req.snapshot_name,
+ description=req.description, # 🟢 將描述傳遞給資料庫函數
+ is_auto=False
+ )
+
+ if not backup_id:
+ return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}
+
+ return {
+ "status": "success",
+ "message": f"快照 '{req.snapshot_name}' 建立成功!",
+ "backup_id": backup_id,
+ "host": req.host
+ }
+
+ except Exception as e:
+ logger.error(f"❌ 建立快照失敗: {e}")
+ return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}
+
+@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
+async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
+ try:
+ backup_record = await get_config_backup_detail(backup_id)
+ if not backup_record:
+ return {"status": "error", "message": "找不到指定的備份紀錄"}
+
+ snapshot_tree = backup_record.get("parsed_tree")
+ if not snapshot_tree:
+ return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
+
+ # 🌟 關鍵修正:動態取得該快照的 config_type,避免蘋果比橘子
+ snapshot_config_type = backup_record.get("config_type", "running")
+
+ # 🌟 關鍵修正:將硬編碼的 "running" 改為 snapshot_config_type
+ raw_current = await fetch_raw_config(req.host, req.username, req.password, snapshot_config_type)
+
+ if not raw_current:
+ return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
+
+ current_tree = parse_cli_to_tree(raw_current)
+ diff_commands = generate_diff_commands(current_tree, snapshot_tree)
+
+ return {
+ "status": "success",
+ "data": {
+ "commands": diff_commands
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"差異分析失敗: {str(e)}")
+ return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"}
+
+
+
+@router.post("/{backup_id}/restore", summary="執行差異還原 (串流即時回報)")
+async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
+ commands = req.commands
+
+ if not commands:
+ async def empty_success():
+ yield json.dumps({"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}) + "\n"
+ return StreamingResponse(empty_success(), media_type="application/x-ndjson")
+
+ async def restore_generator():
+ # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
+ if cmts_config_lock.locked():
+ yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
+ return
+
+ async with cmts_config_lock:
+ try:
+ 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')
+
+ async def read_until_quiet(timeout=1.0):
+ output = ""
+ while True:
+ try:
+ chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
+ if not chunk: break
+ output += chunk
+ if "--More--" in chunk or "More" in chunk:
+ process.stdin.write(" ")
+ await process.stdin.drain()
+ except asyncio.TimeoutError:
+ break
+ return output
+
+ # 2. 進入設定模式
+ process.stdin.write("config\n")
+ await process.stdin.drain()
+ await read_until_quiet(timeout=1.5)
+ yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
+
+ # 3. 逐行寫入並進行 Fail-safe 檢查
+ for cmd in commands:
+ process.stdin.write(f"{cmd}\n")
+ await process.stdin.drain()
+ out = await read_until_quiet(timeout=0.2)
+
+ clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
+
+ # 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
+ if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
+ # 1. 先通知前端正在撤銷
+ yield json.dumps({
+ "status": "progress",
+ "message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
+ }) + "\n"
+
+ # 2. 對設備下達 abort 指令
+ process.stdin.write("abort\n")
+ await process.stdin.drain()
+ await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式
+
+ # 3. 回報最終錯誤並關閉連線
+ yield json.dumps({
+ "status": "error",
+ "message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
+ "output": clean_out
+ }) + "\n"
+ conn.close()
+ return
+
+ yield json.dumps({
+ "status": "progress",
+ "message": f"執行: {cmd}",
+ "output": clean_out
+ }) + "\n"
+
+ # 稍微讓出控制權,確保串流順暢
+ await asyncio.sleep(0.05)
+
+ # 4. 提交變更 (Commit)
+ yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
+ process.stdin.write("commit\n")
+ await process.stdin.drain()
+ commit_out = await read_until_quiet(timeout=6.0)
+ clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
+
+ # 5. 退出並關閉連線
+ process.stdin.write("exit\n")
+ await process.stdin.drain()
+ conn.close()
+
+ yield json.dumps({
+ "status": "success",
+ "message": "✅ 所有差異還原指令已成功送出並 Commit!",
+ "output": clean_commit
+ }) + "\n"
+
+ except Exception as e:
+ logger.error(f"還原執行失敗: {str(e)}")
+ yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n"
+
+ return StreamingResponse(restore_generator(), media_type="application/x-ndjson")
+
+
+
+================================================================================
+FILE: routers/terminal.py
+================================================================================
+import asyncio
+import asyncssh
+import traceback
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+
+router = APIRouter()
+
+@router.websocket("/ws/terminal")
+async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
+ await websocket.accept()
+ conn = None
+ try:
+ # 建立連線
+ conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
+
+ # 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height)
+ process = await conn.create_process(
+ term_type='xterm-256color',
+ term_size=(80, 24), # 正確的 asyncssh 參數格式
+ encoding=None
+ )
+
+ async def forward_to_ws():
+ try:
+ while True:
+ data_bytes = await process.stdout.read(8192)
+ if not data_bytes:
+ print("[DEBUG] 設備端主動關閉了 stdout 通道")
+ break
+
+ safe_text = data_bytes.decode('utf-8', errors='replace')
+ await websocket.send_text(safe_text)
+ except Exception as e:
+ print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
+ traceback.print_exc()
+
+ async def forward_to_ssh():
+ try:
+ while True:
+ data_text = await websocket.receive_text()
+ data_bytes = data_text.encode('utf-8')
+
+ # 攔截 xterm.js 預設的 DEL (\x7f),轉換為 BS (\x08)
+ if b'\x7f' in data_bytes:
+ data_bytes = data_bytes.replace(b'\x7f', b'\x08')
+
+ process.stdin.write(data_bytes)
+ await process.stdin.drain()
+ except WebSocketDisconnect:
+ pass
+ except Exception as e:
+ print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
+ traceback.print_exc()
+
+ ws_task = asyncio.create_task(forward_to_ws())
+ ssh_task = asyncio.create_task(forward_to_ssh())
+
+ done, pending = await asyncio.wait(
+ [ws_task, ssh_task],
+ return_when=asyncio.FIRST_COMPLETED
+ )
+
+ for task in pending:
+ task.cancel()
+
+ except Exception as e:
+ error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
+ try:
+ await websocket.send_text(error_msg)
+ except:
+ pass
+ print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
+ traceback.print_exc()
+ finally:
+ if conn:
+ conn.close()
+ try:
+ await websocket.close()
+ except:
+ pass
+
+
+================================================================================
+FILE: routers/lock.py
+================================================================================
+# --- routers/lock.py ---
+import time
+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)
+# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
+# ==========================================
+ACTIVE_LOCKS: Dict[str, dict] = {}
+LOCK_TIMEOUT = 120
+
+# 🌟 新增 host 欄位
+class LockAcquireReq(BaseModel):
+ host: str
+ path: str
+ user_id: str
+ username: str
+
+class LockActionReq(BaseModel):
+ host: str
+ path: str
+ user_id: str
+
+def clean_expired_locks():
+ current_time = time.time()
+ 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_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
+ clean_expired_locks()
+ prefix = f"{target_host}@@"
+
+ 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']}] 編輯中"
+
+ if target_path.startswith(locked_path + "::"):
+ return lock_info, f"父層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法編輯子區塊"
+
+ if locked_path.startswith(target_path + "::"):
+ return lock_info, f"子層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法鎖定整個父區塊"
+
+ return None, ""
+
+@router.post("/acquire")
+async def acquire_lock(req: LockAcquireReq):
+ 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)
+
+ 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
+ }
+ return {"status": "success", "message": f"成功鎖定路徑: {req.path}"}
+
+@router.post("/heartbeat")
+async def heartbeat_lock(req: LockActionReq):
+ clean_expired_locks()
+ lock_key = f"{req.host}@@{req.path}"
+
+ if lock_key not in ACTIVE_LOCKS:
+ raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
+
+ if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
+ raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
+
+ ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
+ return {"status": "success", "message": "心跳更新成功"}
+
+@router.post("/release")
+async def release_lock(req: LockActionReq):
+ 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(host: str = Query(None)):
+ """獲取特定設備的鎖定狀態"""
+ clean_expired_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": {}}
+
+
+================================================================================
+FILE: routers/query.py
+================================================================================
+# --- routers/query.py ---
+import re
+from fastapi import APIRouter, HTTPException, Query
+from netmiko import ConnectHandler
+from shared import CMTS_DEVICE
+
+router = APIRouter()
+
+def parse_cm_output(raw_text: str) -> list:
+ parsed_data = []
+ lines = raw_text.splitlines()
+ data_started = False
+ for line in lines:
+ if not line.strip(): continue
+ if line.strip().startswith('---'):
+ data_started = True
+ continue
+ if line.strip().startswith('==='): break
+ if data_started:
+ columns = re.split(r'\s{2,}', line.strip())
+ if len(columns) >= 8:
+ cm_info = {
+ "downstream": columns[0], "upstream": columns[1],
+ "bond_cap": columns[2], "ofdm_cap": columns[3],
+ "mac_address": columns[4], "ip_address": columns[5],
+ "num_cpe": int(columns[6]), "state": columns[7]
+ }
+ parsed_data.append(cm_info)
+ return parsed_data
+
+@router.get("/cable-modems")
+async def get_cable_modems():
+ try:
+ net_connect = ConnectHandler(**CMTS_DEVICE)
+ raw_output = str(net_connect.send_command("show cable modem"))
+ net_connect.disconnect()
+ structured_data = parse_cm_output(raw_output)
+ return {"status": "success", "total_count": len(structured_data), "data": structured_data}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
+
+@router.get("/configuration")
+async def get_configuration():
+ try:
+ net_connect = ConnectHandler(**CMTS_DEVICE)
+ net_connect.send_command_timing("config")
+ raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120)
+ net_connect.send_command_timing("exit")
+ net_connect.disconnect()
+ return {"status": "success", "data": raw_output}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}")
+
+@router.get("/cmts-query")
+async def get_cmts_query(
+ query_type: str = Query(..., description="查詢動作類型"),
+ target: str = Query("", description="MAC Address 或 VC:VS (選填)"),
+ host: str = Query(...),
+ username: str = Query(...),
+ password: str = Query(...)
+):
+ try:
+ device = CMTS_DEVICE.copy()
+ device.update({'host': host, 'username': username, 'password': password})
+ target_str = f" {target.strip()}" if target.strip() else ""
+
+ commands = {
+ "base": f"show cable modem{target_str}",
+ "cpe": f"show cable modem{target_str} cpe",
+ "cpe_dhcp": f"show cable modem{target_str} cpe dhcp",
+ "cpe_ipv6": f"show cable modem{target_str} cpe ipv6",
+ "bonding": f"show cable modem{target_str} bonding",
+ "bonding_ds": f"show cable modem{target_str} bonding downstream",
+ "bonding_us": f"show cable modem{target_str} bonding upstream",
+ "phy": f"show cable modem{target_str} phy",
+ "verbose": f"show cable modem{target_str} verbose",
+ "ofdm_profile": f"show cable modem{target_str} ofdm-profile",
+ "ofdma_profile": f"show cable modem{target_str} ofdma-profile",
+ "dhcp_verbose": f"show cable modem{target_str} dhcp verbose",
+ "service_flow": f"show cable modem{target_str} service-flow",
+ "service_flow_verbose": f"show cable modem{target_str} service-flow verbose",
+ "qos": f"show cable modem{target_str} qos",
+ "uptime": f"show cable modem{target_str} uptime",
+ "ugs": f"show cable modem{target_str} ugs",
+ "cm_status": f"show cable modem{target_str} cm-status",
+ "partial_mode": "show cable modem partial-mode",
+ "hop": "show cable hop",
+ "flap_list": "show cable flap-list",
+ "flap_sum": "show cable flap-sum",
+ "rpd_base": f"show cable rpd{target_str}",
+ "rpd_verbose": f"show cable rpd{target_str} verbose",
+ "rpd_ptp_time": f"show cable rpd{target_str} ptp time-property",
+ "rpd_ptp_verbose": f"show cable rpd{target_str} ptp verbose",
+ "rpd_counters_map": f"show cable rpd{target_str} counters map",
+ "rpd_capabilities": f"show cable rpd{target_str} capabilities",
+ "rpd_video_counters": f"show cable rpd{target_str} video-channel counters",
+ "rpd_env_temp": f"show cable rpd{target_str} environment temperature",
+ "rpd_env_volt": f"show cable rpd{target_str} environment voltage",
+ "rpd_session": f"show cable rpd{target_str} session",
+ "rpd_reset_history": f"show cable rpd{target_str} reset-history",
+ "rpd_port_transceiver": f"show cable rpd{target_str} port-transceiver",
+ }
+
+ if query_type not in commands:
+ raise ValueError(f"未知的查詢類型: {query_type}")
+
+ cli_command = commands[query_type] + " | nomore"
+ net_connect = ConnectHandler(**device)
+ raw_output = net_connect.send_command(cli_command, read_timeout=15)
+ net_connect.disconnect()
+
+ return {"status": "success", "command": cli_command, "data": raw_output}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}")
+
+@router.get("/cmts-mac-domain-config")
+async def get_mac_domain_config(target: str, host: str, username: str, password: str):
+ try:
+ device = CMTS_DEVICE.copy()
+ device.update({'host': host, 'username': username, 'password': password})
+
+ cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
+ net_connect = ConnectHandler(**device)
+ raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
+ net_connect.disconnect()
+
+ # 💡 正名工程:更新字典的 Key 為一致性的命名
+ config = {
+ "common_settings": { "ip-provisioning-mode": "dual-stack", "diplexer-band-edge": "enabled", "cm-battery-mode-31-support": "disabled", "cm-battery-mode-30-support": "disabled", "docsis40": "disabled", "ds-dynamic-bonding-group": "disabled", "us-dynamic-bonding-group": "disabled" },
+ "basic_channel_sets": { "admin-state": "down", "ds-primary-set": "", "ds-non-primary-set": "", "us-phy-channel-set": "", "ds-ofdm-set": "", "us-ofdma-set": "" },
+ "static_ds_bonding_groups": {},
+ "static_us_bonding_groups": {}
+ }
+
+ current_group_type = None
+ current_group_name = None
+
+ for line in raw_output.splitlines():
+ line = line.strip()
+ if not line or line.startswith("cable mac-domain"): continue
+
+ if line.startswith("ds-bonding-group"):
+ current_group_type = "static_ds_bonding_groups"
+ current_group_name = line.split()[1]
+ config["static_ds_bonding_groups"][current_group_name] = {"admin-state": "down"}
+ continue
+ elif line.startswith("us-bonding-group"):
+ current_group_type = "static_us_bonding_groups"
+ current_group_name = line.split()[1]
+ config["static_us_bonding_groups"][current_group_name] = {"admin-state": "down"}
+ continue
+ elif line == "!":
+ current_group_type = None
+ current_group_name = None
+ continue
+
+ if current_group_type and current_group_name:
+ parts = line.split(maxsplit=1)
+ if len(parts) == 2: config[current_group_type][current_group_name][parts[0]] = parts[1]
+ else:
+ # 💡 正名工程:將解析結果存入對應的新 Key 中
+ if line.startswith("ip-provisioning-mode"): config["common_settings"]["ip-provisioning-mode"] = line.split(maxsplit=1)[1]
+ elif line.startswith("diplexer-band-edge control"): config["common_settings"]["diplexer-band-edge"] = line.split(maxsplit=2)[2]
+ elif line.startswith("cm-battery-mode-31-support"): config["common_settings"]["cm-battery-mode-31-support"] = line.split(maxsplit=1)[1]
+ elif line.startswith("cm-battery-mode-30-support"): config["common_settings"]["cm-battery-mode-30-support"] = line.split(maxsplit=1)[1]
+ elif line.startswith("docsis40"): config["common_settings"]["docsis40"] = line.split(maxsplit=1)[1]
+ elif line.startswith("ds-dynamic-bonding-group"): config["common_settings"]["ds-dynamic-bonding-group"] = line.split(maxsplit=1)[1]
+ elif line.startswith("us-dynamic-bonding-group"): config["common_settings"]["us-dynamic-bonding-group"] = line.split(maxsplit=1)[1]
+
+ # 💡 正名工程:將原本的 dynamic_bonding_groups 改為 basic_channel_sets
+ elif line.startswith("admin-state"): config["basic_channel_sets"]["admin-state"] = line.split(maxsplit=1)[1]
+ elif line.startswith("ds-primary-set"): config["basic_channel_sets"]["ds-primary-set"] = line.split(maxsplit=1)[1]
+ elif line.startswith("ds-non-primary-set"): config["basic_channel_sets"]["ds-non-primary-set"] = line.split(maxsplit=1)[1]
+ elif line.startswith("us-phy-channel-set"): config["basic_channel_sets"]["us-phy-channel-set"] = line.split(maxsplit=1)[1]
+ elif line.startswith("ds-ofdm-set"): config["basic_channel_sets"]["ds-ofdm-set"] = line.split(maxsplit=1)[1]
+ elif line.startswith("us-ofdma-set"): config["basic_channel_sets"]["us-ofdma-set"] = line.split(maxsplit=1)[1]
+
+ return {"status": "success", "data": config}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}")
+
+@router.get("/cmts-mac-domain-list")
+async def get_cmts_mac_domain_list(
+ host: str = Query(...),
+ username: str = Query(...),
+ password: str = Query(...)
+):
+ try:
+ device = CMTS_DEVICE.copy()
+ device.update({'host': host, 'username': username, 'password': password})
+ net_connect = ConnectHandler(**device)
+
+ raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
+ net_connect.disconnect()
+
+ import re
+ matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
+ mac_domains = list(dict.fromkeys(matches))
+
+ return {"status": "success", "data": mac_domains}
+ except Exception as e:
+ return {"status": "error", "message": str(e)}
+
+@router.get("/cmts-version")
+async def get_cmts_version(
+ host: str = Query(...),
+ username: str = Query(...),
+ password: str = Query(...)
+):
+ try:
+ device = CMTS_DEVICE.copy()
+ device.update({'host': host, 'username': username, 'password': password})
+ net_connect = ConnectHandler(**device)
+
+ raw_output = str(net_connect.send_command("show version", read_timeout=15))
+ net_connect.disconnect()
+
+ import re
+ # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
+ match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
+
+ if match:
+ return {"status": "success", "version": match.group(1)}
+ else:
+ return {"status": "error", "message": "無法解析版本號", "raw_output": raw_output}
+
+ except Exception as e:
+ return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
+
+
+================================================================================
+FILE: routers/config.py
+================================================================================
+# --- routers/config.py ---
+import re
+import json
+import os
+import database
+import asyncio
+import asyncssh
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import List
+from netmiko import ConnectHandler
+# 🌟 移除了舊的 load_settings, save_settings,改用專屬的雙軌機制
+from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB
+from collections import defaultdict
+from fastapi.responses import StreamingResponse
+
+router = APIRouter()
+
+# ==========================================
+# 🌟 雙軌過濾器檔案讀寫輔助函數 (加入高可用性 Fallback)
+# ==========================================
+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"
+
+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 快取讀取...")
+
+ 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 []
+
+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}")
+
+# ==========================================
+
+class ConfigRequest(BaseModel):
+ script: str
+ host: str
+ username: str
+ password: str
+
+# 定義前端傳來的 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]
+ interfaces_with_admin_state: List[str] = []
+
+@router.post("/cmts-config")
+async def execute_cmts_config(req: ConfigRequest):
+ # 過濾掉空行與註解 (! 開頭的行)
+ commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
+
+ async def config_streamer():
+ # 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行
+ async with cmts_config_lock:
+ 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:
+
+ # 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
+ async def read_until_quiet(timeout=0.5):
+ 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)
+ except asyncio.TimeoutError:
+ break
+ return out_data
+
+ # 1. 進入設定模式
+ process.stdin.write("config\n")
+ await process.stdin.drain()
+ await read_until_quiet(0.5) # 清空歡迎訊息
+
+ # 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
+ output = await read_until_quiet(wait_time)
+
+ # 🛡️ 防呆撤銷機制:偵測到錯誤立刻 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()
+ final_output = await read_until_quiet(1.0)
+
+ 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")
+
+@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]
+ interface_path = " ".join(parts[:-1])
+
+ # 全域清除路徑中間可能夾帶的虛擬資料夾索引
+ interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
+
+ if param_name == "admin-state":
+ interface_admin_states[interface_path] = diff.new_val
+ else:
+ interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
+
+ cli_lines = []
+ cli_lines.append("! --- Auto-Generated Configuration Script ---")
+
+ for interface, changes in interface_groups.items():
+ cli_lines.append(f"! Configuring: {interface}")
+
+ supports_admin_state = interface in req.interfaces_with_admin_state
+
+ if supports_admin_state:
+ cli_lines.append(f"{interface} admin-state down")
+
+ 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}")
+ else:
+ if new_val == "":
+ cli_lines.append(f"no {interface} {param}")
+ else:
+ cli_lines.append(f"{interface} {param} {new_val}")
+
+ if supports_admin_state:
+ final_state = interface_admin_states.get(interface, "up")
+ cli_lines.append(f"{interface} admin-state {final_state}")
+
+ cli_lines.append("commit")
+ cli_lines.append("!")
+
+ 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("!")
+
+ final_script = "\n".join(cli_lines)
+ return {"status": "success", "data": final_script}
+
+# ==========================================
+# DS RF Port 動態樹狀查詢 API
+# ==========================================
+
+@router.get("/cmts-ds-rf-port-list")
+async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
+ """獲取設備上所有的 DS RF Port 清單"""
+ 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"'
+ raw_output = str(net_connect.send_command(command, read_timeout=60))
+ net_connect.disconnect()
+
+ pattern = r"cable ds-rf-port\s+([0-9:/]+)"
+ ports = re.findall(pattern, raw_output)
+ 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)}
+
+
+@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"""
+ 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"
+ raw_output = str(net_connect.send_command(command, read_timeout=60))
+ net_connect.disconnect()
+
+ base_tree = parse_cli_to_tree(raw_output)
+ perfect_tree = deep_split_tree(base_tree)
+
+ return {
+ "status": "success",
+ "data": perfect_tree,
+ "raw_cli": raw_output.strip()
+ }
+ except Exception as e:
+ return {"status": "error", "message": str(e)}
+
+
+@router.get("/cmts-full-config")
+async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
+ """獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
+ try:
+ device = CMTS_DEVICE.copy()
+ device.update({'host': host, 'username': username, 'password': password})
+
+ net_connect = ConnectHandler(**device)
+
+ 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()
+
+ 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)
+
+ # ==========================================
+ # 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
+ # ==========================================
+ if not skip_filter:
+ 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)
+ # ==========================================
+
+ return {"status": "success", "data": perfect_tree}
+ except Exception as e:
+ return {"status": "error", "message": str(e)}
+
+@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():
+ 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)
+
+ await asyncio.to_thread(net_connect.disconnect)
+
+ # 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()
+
+ base_tree = parse_cli_to_tree(clean_output)
+ perfect_tree = deep_split_tree(base_tree)
+
+ # ==========================================
+ # 🌟 深度過濾攔截器 (維持原樣)
+ # ==========================================
+ 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"
+
+ # 回傳 NDJSON 串流格式
+ return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
+
+# ==========================================
+# 🌟 系統設定 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(config_type: str = "running"):
+ """獲取目前的樹狀圖隱藏名單"""
+ # 🌟 根據 config_type 讀取對應的 JSON
+ hidden_keys = await load_tree_filters(config_type)
+ return {"status": "success", "data": hidden_keys}
+
+@router.post("/settings/tree-filters")
+async def update_tree_filters(req: SettingsRequest):
+ """更新樹狀圖隱藏名單並存檔"""
+ # 🌟 根據 config_type 寫入對應的 JSON
+ await save_tree_filters(req.config_type, req.hidden_keys)
+ return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
+
+
+================================================================================
+FILE: routers/leaf_options.py
+================================================================================
+# --- routers/leaf_options.py ---
+from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from typing import List
+import json, os, asyncio, re
+import database
+from cmts_scraper import sync_cmts_leaves_async
+from shared import CMTS_DEVICE, USE_DB
+
+router = APIRouter(tags=["Options"])
+
+# 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
+def get_cache_file(host: str, config_type: str):
+ safe_host = host.replace(".", "_")
+ return f"{safe_host}_{config_type}_cache.json"
+
+# ==========================================
+# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
+# ==========================================
+# 將狀態改為空字典,動態根據 host_configType 建立
+SCAN_STATUS = {}
+active_clients = {}
+
+async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
+ """將訊息推播給特定頻道的連線前端"""
+ channel_key = f"{host}_{config_type}"
+ dead_clients = set()
+
+ # SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
+ message_str = f"data: {json.dumps(message_dict)}\n\n"
+
+ 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[channel_key].discard(dead)
+
+@router.get("/cmts-leaf-options/stream")
+async def sse_stream(request: Request, host: str, config_type: str = "running"):
+ """前端一載入就會連上這個路由,持續監聽特定頻道的廣播"""
+ channel_key = f"{host}_{config_type}"
+ client_queue = asyncio.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:
+ if await request.is_disconnected():
+ break
+ try:
+ message = await asyncio.wait_for(client_queue.get(), timeout=1.0)
+ yield message
+ except asyncio.TimeoutError:
+ yield ": keepalive\n\n"
+
+ except asyncio.CancelledError:
+ pass
+ finally:
+ 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(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(host: str, config_type: str = "running"):
+ if USE_DB:
+ try:
+ db_options = await database.get_all_leaf_options(host, config_type)
+ if db_options is not None:
+ # 取得 metadata
+ metadata = await database.get_device_status(host, config_type)
+ if metadata:
+ db_options["__metadata__"] = metadata
+ return db_options
+ else:
+ # 若回傳 None 表示 DB 連線異常,執行 Fallback
+ print("⚠️ [Fallback] 資料庫無法取得資料,自動切換至 JSON 快取讀取...")
+ except Exception as e:
+ print(f"⚠️ [Fallback] 資料庫讀取發生例外: {e},自動切換至 JSON 快取讀取...")
+
+ 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(host: str, username: str, password: str, paths: List[str], config_type: str):
+ """在背景執行的爬蟲任務,並將進度廣播出去"""
+ global SCAN_STATUS
+ channel_key = f"{host}_{config_type}"
+ try:
+ await broadcast_message({"event": "scan_start"}, host, config_type)
+
+ # 呼叫爬蟲 (帶入設備連線資訊與 config_type)
+ async for chunk in sync_cmts_leaves_async(
+ host=host,
+ username=username,
+ password=password,
+ leaf_paths=paths,
+ config_type=config_type
+ ):
+ if isinstance(chunk, str):
+ await broadcast_message(json.loads(chunk), host, config_type)
+ else:
+ await broadcast_message(chunk, host, config_type)
+
+ await broadcast_message({"event": "done"}, host, config_type)
+ except Exception as e:
+ await broadcast_message({"event": "error", "message": str(e)}, host, config_type)
+ finally:
+ # 解除特定頻道的鎖定
+ SCAN_STATUS[channel_key] = False
+
+@router.post("/cmts-leaf-options/sync")
+async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
+ global SCAN_STATUS
+ if not request.leaf_paths:
+ raise HTTPException(status_code=400)
+
+ 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("::", " ")
+ clean_p = re.sub(r"\s*\[\d+\]", "", clean_p)
+ cmts_query_paths.append(clean_p)
+
+ cmts_query_paths = list(dict.fromkeys(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"}
+
+# ==========================================
+# 🌟 4. 清除快取 API
+# ==========================================
+@router.post("/clear_cache")
+async def clear_specific_cache(host: str, config_type: str = "running", paths_to_clear: list = Body(...)):
+ cleared_count = 0
+
+ # 1. 嘗試清除 DB
+ if USE_DB:
+ try:
+ db_cleared = await database.delete_leaf_options(host, config_type, paths_to_clear)
+ if db_cleared >= 0:
+ cleared_count = db_cleared
+ else:
+ print("⚠️ [Fallback] 資料庫清除失敗,自動切換至 JSON 快取清除...")
+ except Exception as e:
+ print(f"⚠️ [Fallback] 資料庫清除發生例外: {e},自動切換至 JSON 快取清除...")
+
+ # 2. 清除 JSON (如果 DB 沒清掉,或 USE_DB=False,或者是連同舊檔一起清確保乾淨)
+ cache_file = get_cache_file(host, config_type)
+
+ if not os.path.exists(cache_file):
+ # 如果是走 DB,且有清掉,回傳 DB 的結果
+ if USE_DB and cleared_count > 0:
+ return {"status": "success", "cleared_count": cleared_count}
+ return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
+
+ try:
+ with open(cache_file, "r", encoding="utf-8") as f:
+ cache_data = json.load(f)
+
+ json_cleared_count = 0
+ for path in paths_to_clear:
+ if path in cache_data:
+ del cache_data[path]
+ json_cleared_count += 1
+
+ if json_cleared_count > 0:
+ with open(cache_file, "w", encoding="utf-8") as f:
+ json.dump(cache_data, f, ensure_ascii=False, indent=4)
+
+ # 回傳較大的那個數值
+ final_count = max(cleared_count, json_cleared_count)
+ return {"status": "success", "cleared_count": final_count}
+
+ except Exception as e:
+ # 確保回傳標準 JSON 格式
+ return {"status": "error", "message": f"資料庫連線與快取存取皆失敗: {str(e)}"}
+
diff --git a/merge_code.py b/merge_code.py
new file mode 100644
index 0000000..781d136
--- /dev/null
+++ b/merge_code.py
@@ -0,0 +1,95 @@
+import os
+
+# ==========================================
+# 設定區
+# ==========================================
+# 輸出的合併檔案名稱
+OUTPUT_FILE = "all_code.txt"
+
+# ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄)
+EXCLUDE_DIRS = {
+ "__pycache__",
+ ".git",
+ ".vscode", # VS Code 設定檔
+ ".continue", # Continue.dev 設定檔
+ "cmts_api_env", # 🚨 Python 虛擬環境 (極度重要!絕對要排除)
+ "venv", # 其他常見的虛擬環境名稱
+ ".venv",
+ "node_modules",
+ "dist",
+ "build"
+}
+
+# ⚠️ 排除的檔案副檔名 (不讀取這些格式)
+EXCLUDE_EXTENSIONS = {
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", # 圖片
+ ".pyc", ".pyo", ".pyd", # Python 編譯檔
+ ".exe", ".dll", ".so", ".dylib", # 執行檔/函式庫
+ ".zip", ".tar", ".gz", ".rar", # 壓縮檔
+ ".pdf", ".doc", ".docx", # 文件檔
+ ".sqlite3", ".db" # 資料庫
+}
+
+# ⚠️ 排除的特定檔案名稱 (例如腳本自己、隱藏檔等)
+EXCLUDE_FILES = {
+ "merge_code.py", # 排除這支腳本自己
+ "all_code.txt", # 排除輸出的檔案
+ ".DS_Store", # Mac 系統檔
+ ".clineignore", # AI 輔助工具的忽略檔
+ ".clinerules", # AI 輔助工具的規則檔
+ ".gitignore", # Git 忽略檔
+ "requirements.txt" # 依賴清單 (通常不需要給 AI 看,除非你要問套件問題)
+}
+
+def should_process_file(filename):
+ """判斷該檔案是否應該被處理"""
+ if filename in EXCLUDE_FILES:
+ return False
+
+ ext = os.path.splitext(filename)[1].lower()
+ if ext in EXCLUDE_EXTENSIONS:
+ return False
+
+ return True
+
+def merge_files():
+ # 取得當前腳本所在的目錄 (專案根目錄)
+ root_dir = os.path.dirname(os.path.abspath(__file__))
+
+ with open(OUTPUT_FILE, "w", encoding="utf-8") as outfile:
+ # 寫入一個總標題
+ outfile.write("=" * 80 + "\n")
+ outfile.write("PROJECT SOURCE CODE EXPORT\n")
+ outfile.write("=" * 80 + "\n\n")
+
+ # 走訪目錄
+ for dirpath, dirnames, filenames in os.walk(root_dir):
+ # 排除不需要的目錄 (原地修改 dirnames 列表,os.walk 就不會進去)
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
+
+ for filename in filenames:
+ if should_process_file(filename):
+ file_path = os.path.join(dirpath, filename)
+ # 計算相對路徑 (例如: routers/config.py)
+ rel_path = os.path.relpath(file_path, root_dir)
+
+ try:
+ with open(file_path, "r", encoding="utf-8") as infile:
+ content = infile.read()
+
+ # 寫入漂亮的檔名標籤
+ outfile.write("\n" + "=" * 80 + "\n")
+ outfile.write(f"FILE: {rel_path}\n")
+ outfile.write("=" * 80 + "\n")
+ # 寫入檔案內容
+ outfile.write(content)
+ outfile.write("\n")
+
+ print(f"✅ 已合併: {rel_path}")
+ except Exception as e:
+ print(f"❌ 讀取失敗 {rel_path}: {e}")
+
+ print(f"\n🎉 合併完成!所有程式碼已儲存至: {OUTPUT_FILE}")
+
+if __name__ == "__main__":
+ merge_files()