================================================================================
PROJECT SOURCE CODE EXPORT (FULL)
================================================================================


================================================================================
FILE: database.py
================================================================================
import asyncpg
import json
import uuid
from typing import Dict, List, Optional, Any
from logger import get_logger

# ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版)
# ==========================================

logger = get_logger("app.database")

# 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
        
    cmts_version = metadata.get("cmts_version")
    last_scanned = metadata.get("last_scanned", None)
    
    try:
        async with pool.acquire() as conn:
            # 🌟 關鍵修正：如果傳入的是 unknown 或空值，只更新時間，絕對不動版本號！
            if cmts_version and cmts_version != "unknown":
                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;
                """
                await conn.execute(query, host, config_type, cmts_version, last_scanned)
            else:
                query = """
                    INSERT INTO device_status (host, config_type, last_scanned)
                    VALUES ($1, $2, $3)
                    ON CONFLICT (host, config_type)
                    DO UPDATE SET 
                        last_scanned = EXCLUDED.last_scanned,
                        updated_at = CURRENT_TIMESTAMP;
                """
                await conn.execute(query, host, config_type, last_scanned)
        return True
    except Exception as e:
        logger.error(f"❌ DB 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)

# 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖
cmts_config_locks = defaultdict(asyncio.Lock)


================================================================================
FILE: index.html
================================================================================
<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Harmonic CMTS Manager</title>
    <link rel="icon" type="image/png" href="/static/my_icon.png">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
    <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    
    <!-- 引入獨立的 CSS 樣式表 -->
    <link rel="stylesheet" href="/static/style.css" />
</head>
<body>

    <!-- 1. 標題加上 id="app-title" -->
    <h1 id="app-title" style="cursor: default;">🎙️ Harmonic CMTS Admin</h1>

    <div class="global-settings">
        <label><strong>🌐 目標設備：</strong></label>
        <input type="text" id="cmtsHost" placeholder="IP 地址" value="10.14.110.4" style="width: 130px;">
        <input type="text" id="cmtsUser" placeholder="帳號" value="admin" style="width: 100px;">
        <input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;">
        
        <div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
            <button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
            <button onclick="disconnectWebSocket()" id="btnDisconnect" class="btn-modern btn-disconnect" style="display: none;">斷開連線</button>
            <span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態：未連線</span>
        </div>
    </div>

    <div class="tabs">
        <button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
        <button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
        <button class="tab-btn" onclick="switchTab('config-tab', this)">🛠️ CMTS 設備配置</button>
        <!-- 💡 新增：系統設定頁籤 -->
        <button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
        <!-- 💡 新增：備份與還原頁籤 -->
        <button class="tab-btn" onclick="switchTab('backup-tab', this); loadBackupHistory();">💾 設備備份與還原</button>
    </div>

    <!-- Tab 1: True SSH Terminal -->
    <div id="cli-tab" class="tab-content active">
        <div class="control-group">
            <label><strong>快捷指令：</strong></label>
            <select id="fixedCmd">
                <option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
                <option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
                <option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
            </select>
            <button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>

            <div style="margin-left: auto; display: flex; gap: 8px;">
                <button onclick="downloadTerminal()" class="btn-modern btn-slate">💾 下載紀錄</button>
            </div>
        </div>
        
        <div id="terminal-container"></div>
    </div>

    <!-- Tab 2: CMTS 狀態查詢 -->
    <div id="query-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
        <div class="manager-container">
            
            <div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
                <label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務：</label>
                <select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
                    <option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
                    <option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
                    <option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
                </select>
            </div>

            <!-- 表單 1：Cable 狀態綜合查詢 -->
            <div id="form-cm-query" class="task-form active">
                <h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
                <div class="form-grid">
                    <div class="form-row">
                        <label>目標設備 (Cable Modem MAC)</label>
                        <input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
                        <datalist id="cm-mac-list"></datalist>
                    </div>
                    <div class="form-row">
                        <label>查詢動作 (Action)</label>
                        <select id="queryTypeCm" onchange="toggleQueryInputs('Cm')">
                            <optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
                                <option value="base">基本狀態 (show cable modem)</option>
                                <option value="cpe">CPE 資訊 (cpe)</option>
                                <option value="cpe_dhcp">CPE DHCP (cpe dhcp)</option>
                                <option value="cpe_ipv6">CPE IPv6 (cpe ipv6)</option>
                                <option value="bonding">綁定摘要 (bonding)</option>
                                <option value="bonding_ds">下行綁定 (bonding downstream)</option>
                                <option value="bonding_us">上行綁定 (bonding upstream)</option>
                                <option value="phy">實體層狀態 (phy)</option>
                                <option value="verbose">詳細資訊 (verbose)</option>
                                <option value="ofdm_profile">OFDM Profile (ofdm-profile)</option>
                                <option value="ofdma_profile">OFDMA Profile (ofdma-profile)</option>
                                <option value="dhcp_verbose">DHCP 詳細資訊 (dhcp verbose)</option>
                                <option value="service_flow">Service Flow (service-flow)</option>
                                <option value="service_flow_verbose">Service Flow 詳細 (service-flow verbose)</option>
                                <option value="qos">QoS 資訊 (qos)</option>
                                <option value="uptime">運行時間 (uptime)</option>
                                <option value="ugs">UGS 資訊 (ugs)</option>
                                <option value="cm_status">CM 狀態 (cm-status)</option>
                            </optgroup>
                            <optgroup label="全域狀態查詢 (不分特定目標)">
                                <option value="partial_mode">Partial Mode (partial-mode)</option>
                                <option value="hop">Cable Hop (show cable hop)</option>
                                <option value="flap_list">Flap List (show cable flap-list)</option>
                                <option value="flap_sum">Flap Summary (show cable flap-sum)</option>
                            </optgroup>
                        </select>
                    </div>
                </div>
                <div class="form-actions">
                    <button onclick="executeQuery('Cm')" class="btn-modern btn-load">執行查詢</button>
                    <div style="display: flex; align-items: center;">
                        <input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;"> 
                        <label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
                    </div>
                </div>
            </div>

            <!-- 表單 2：RPD 狀態綜合查詢 -->
            <div id="form-rpd-query" class="task-form">
                <h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
                <div class="form-grid">
                    <div class="form-row">
                        <label>目標設備 (RPD VC:VS)</label>
                        <input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
                        <datalist id="rpd-vcvs-list"></datalist>
                    </div>
                    <div class="form-row">
                        <label>查詢動作 (Action)</label>
                        <select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')">
                            <option value="rpd_base">基本狀態 (show cable rpd)</option>
                            <option value="rpd_verbose">詳細資訊 (verbose)</option>
                            <option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
                            <option value="rpd_ptp_verbose">PTP 詳細資訊 (ptp verbose)</option>
                            <option value="rpd_counters_map">Counters Map (counters map)</option>
                            <option value="rpd_capabilities">能力資訊 (capabilities)</option>
                            <option value="rpd_video_counters">Video Counters (video-channel counters)</option>
                            <option value="rpd_env_temp">環境溫度 (environment temperature)</option>
                            <option value="rpd_env_volt">環境電壓 (environment voltage)</option>
                            <option value="rpd_session">Session (session)</option>
                            <option value="rpd_reset_history">重啟歷史 (reset-history)</option>
                            <option value="rpd_port_transceiver">光模組資訊 (port-transceiver)</option>
                        </select>
                    </div>
                </div>
                <div class="form-actions">
                    <button onclick="executeQuery('Rpd')" class="btn-modern btn-load">執行查詢</button>
                    <div style="display: flex; align-items: center;">
                        <input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;"> 
                        <label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
                    </div>
                </div>
            </div>

            <!-- 表單 3：CM 一鍵診斷中心 -->
            <div id="form-cm-diagnostics" class="task-form">
                <h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
    
                <!-- 搜尋區塊 -->
                <div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
                    <label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC：</label>
                    <input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
                    <datalist id="diag-cm-mac-list"></datalist>
                    <button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
                    <span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
                </div>

                <!-- 結果顯示區塊 (預設隱藏) -->
                <div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
                    
                    <!-- 上半部：基本資訊與 PHY 狀態 -->
                    <div style="display: flex; gap: 20px; flex-wrap: wrap;">
                        <!-- 基本資訊卡片 -->
                        <div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
                            <h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
                            <table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
                                <tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
                                <tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
                                <tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
                                <tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
                            </table>
                        </div>

                        <!-- PHY 狀態卡片 -->
                        <div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
                            <h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
                            <table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
                                <tr style="border-bottom: 1px solid #ecf0f1;">
                                    <th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
                                    <td id="diagResTx" style="font-weight: bold;">-</td>
                                </tr>
                                <tr>
                                    <th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
                                    <td id="diagResRx" style="font-weight: bold;">-</td>
                                </tr>
                            </table>
                            
                            <!-- 🌟 新增：上行通道標籤區塊 -->
                            <div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
                                <div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布：</div>
                                <div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
                                    <!-- 動態生成標籤 -->
                                </div>
                            </div>
                        </div>
                    </div>

                    <!-- 下半部：Chart.js MER 圖表 -->
                    <div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
                        <h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
                            <span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表，最低 MER ≥ 41dB 判定為優)</span></span>
                            <!-- 🌟 新增：動態 OFDM 頻道切換按鈕區塊 -->
                            <div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
                                <!-- 動態生成按鈕 -->
                            </div>
                        </h4>
                        <div style="position: relative; height: 280px; width: 100%;">
                            <canvas id="merChart"></canvas>
                        </div>
                    </div>
                </div>
            </div>
         </div>
    </div> 

    <!-- Tab 3: CMTS 設備配置 -->
    <div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
        <div class="manager-container">
            
            <div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
                <label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務：</label>
                <select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
                    <!-- 🌟 拆分為兩個獨立的選項 -->
                    <option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
                    <option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
                    <option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
                </select>
                <button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
            </div>

            <!-- 表單 3：MAC Domain 配置精靈 -->
            <div id="form-bonding-config" class="task-form active">
                <h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
                
                <div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
                    <label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain：</label>
                    <input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
                    <datalist id="mac-domain-list"></datalist>
                    <button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
                    <span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
                </div>

                <div id="macDomainConfigArea" style="display: none; margin-top: 20px;">
                    <!-- Common Settings -->
                    <h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
                    <div class="form-grid">
                        <div class="form-row"><label>IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
                        <div class="form-row"><label>Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
                        <div class="form-row"><label>CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
                        <div class="form-row"><label>CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
                        <div class="form-row"><label>DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
                        <div class="form-row"><label>DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
                        <div class="form-row"><label>US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
                    </div>

                    <!-- [Basic] DS/US Channel Sets -->
                    <h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
                    <div class="form-grid">
                        <div class="form-row"><label>Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
                        <div class="form-row"><label>DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
                        <div class="form-row"><label>DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
                        <div class="form-row"><label>US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
                        <div class="form-row"><label>DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
                        <div class="form-row"><label>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
                    </div>

                    <!-- [Static] Downstream Bonding Groups -->
                    <div id="section_group_ds" style="margin-top: 30px;">
                        <h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
                        <div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
                            <label>選擇要編輯的 DS Group：</label>
                            <select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
                            <input type="text" id="input_new_ds_group" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
                        </div>
                        <div class="form-grid">
                            <div class="form-row"><label>Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
                            <div class="form-row"><label>Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
                            <div class="form-row"><label>OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
                            <div class="form-row"><label>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
                        </div>
                    </div>

                    <!-- [Static] Upstream Bonding Groups -->
                    <div id="section_group_us" style="margin-top: 30px;">
                        <h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
                        <div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
                            <label>選擇要編輯的 US Group：</label>
                            <select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
                            <input type="text" id="input_new_us_group" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
                        </div>
                        <div class="form-grid">
                            <div class="form-row"><label>Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
                            <div class="form-row"><label>US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
                            <div class="form-row"><label>OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
                            <div class="form-row"><label>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
                        </div>
                    </div>

                    <!-- CLI 預覽區塊 -->
                    <div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>

                    <div class="form-actions">
                        <button id="btnRevert" onclick="revertFormChanges()" class="btn-modern btn-disconnect" style="margin-right: 15px;">🔄 復原重填</button>
                        <button onclick="generateMacDomainCLI()" class="btn-modern btn-scan">⚙️ 產生絕對路徑指令預覽</button>
                        <button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
                    </div>
                </div>
            </div>

            <!-- 表單 4：設備配置樹狀圖 (共用容器) -->
            <div id="form-tree-config" class="task-form" style="display: none;">
                <!-- 頂部：標題與按鈕區塊 (維持原樣) -->
                <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
                    <div style="display: flex; align-items: center; gap: 15px;">
                        <h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
                            <span id="tree-form-title">🌲 設備配置樹狀圖</span>
                        </h3>
                        <span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
                        <span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
                            ⏳ 正在從設備抓取完整配置，可能需要 30~60 秒，請耐心稍候...
                        </span>
                    </div>
                    <div style="display: flex; gap: 10px;">
                        <button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描局部缺失</button>
                        <button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">清除局部快取</button>
                        <button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
                        <button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
                    </div>
                </div>
    
                <!-- 🌟 左右分屏容器 (關鍵修改：align-items: stretch 讓左右強制等高) -->
                <div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
                    
                    <!-- 左側：樹狀圖 (保留 max-height: 600px，超過才出捲軸) -->
                    <div id="tree-container-running" class="tree-view-instance" style="flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
                        <span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
                    </div>

                    <div id="tree-container-full" class="tree-view-instance" style="display: none; flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
                        <span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
                    </div>

                    <!-- 🖱️ 拖曳分隔線 (拔除寫死的 height: 400px) -->
                    <div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; flex-shrink: 0; align-items: center; justify-content: center; z-index: 10;" title="左右拖曳調整寬度">
                        <div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
                    </div>

                    <!-- 右側：CLI 預覽與執行結果外框 -->
                    <div id="side-cli-preview" style="flex: 1; min-width: 300px; max-width: 50%; display: none; box-sizing: border-box;">
                        
                        <!-- 🌟 內部黑底容器 (使用 flex column 與 height: 100% 完美填滿外框) -->
                        <div style="display: flex; flex-direction: column; height: 100%; background: #1e1e1e; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #34495e; box-sizing: border-box;">
                            
                            <!-- 頂部標題列 -->
                            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
                                <h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
                                <div style="display: flex; align-items: center; gap: 20px;">
                                    <button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
                                    <button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
                                    <button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
                                </div>
                            </div>
                            
                            <!-- 模式一：指令預覽框 (🌟 拔除 height: 400px，改用 flex: 1 自動填滿) -->
                            <textarea id="side-cli-textarea" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; outline: none; box-sizing: border-box; white-space: pre; overflow-x: auto; overflow-y: auto; margin: 0; display: block; resize: none; min-height: 150px;"></textarea>
                            
                            <!-- 模式二：執行結果框 (🌟 拔除 height: 400px，改用 flex: 1 自動填滿) -->
                            <div id="side-execution-result" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; box-sizing: border-box; overflow-x: auto; overflow-y: auto; margin: 0; display: none; white-space: pre; min-height: 150px;"></div>
                        </div>
                    </div>
                </div>
            </div>             
        </div>
    </div>

    <!-- 💡 Tab 4: 系統設定 (System Settings) -->
    <div id="settings-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe; text-align: left;">
        <div class="manager-container" style="display: block; max-width: 100%;">
            <div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block;">
                
                <h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
                    ⚙️ 全域系統設定 (God Mode Filters)
                </h3>
                
                <div style="background: #f8f9fa; border-left: 4px solid #3498db; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
                    <p style="color: #2c3e50; font-size: 15px; margin: 0;">
                        請勾選您希望在「完整設備配置樹狀圖」中 <b>隱藏</b> 的設定項目。<br>
                        <span style="color: #7f8c8d; font-size: 14px;">💡 點擊下方按鈕載入設備實時配置，您可以深入展開並勾選任何層級的節點進行過濾。</span>
                    </p>
                </div>

                <!-- 🌟 新增：過濾器模式切換下拉選單 -->
                <div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
                    <label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器：</label>
                    <select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
                        <option value="running">Running 配置過濾器</option>
                        <option value="full">Full 配置過濾器</option>
                    </select>
                    
                    <button onclick="loadRealConfigForFilters()" class="btn-modern btn-load">
                        📥 載入設備配置以設定過濾
                    </button>
                    <span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置，請稍候...</span>
                </div>

                <!-- 樹狀 Checkbox 容器 -->
                <div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
                </div>

                <div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
                    <button onclick="saveSystemSettings()" class="btn-modern btn-connect" style="padding: 10px 20px; font-size: 15px;">
                        💾 儲存並套用設定
                    </button>
                    <span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存！</span>
                </div>
            </div>

            <!-- 🌟 新增：伺服器日誌管控面板 -->
            <div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
                <h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
                    🎛️ 伺服器日誌管控 (Server Log Management)
                </h3>
                
                <div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
                    <p style="color: #2c3e50; font-size: 15px; margin: 0;">
                        在此動態調整各個後端模組的日誌輸出等級。設定會立即生效，<b>無需重啟伺服器</b>。<br>
                        <span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b> 或 <b>ERROR</b>，僅在需要排查問題時開啟 <b>DEBUG</b>。</span>
                    </p>
                </div>

                <!-- 矩陣式下拉選單容器 -->
                <div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
                    <span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
                </div>
            </div>
        </div>
    </div>

    <!-- 💡 Tab 5: 設備備份與還原 -->
    <div id="backup-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
        <div class="manager-container">
            
            <!-- 上半部：建立新快照 -->
            <div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
                
                <!-- 💡 修正：移除負 margin，讓底線與內容邊界對齊 (如同系統設定頁籤) -->
                <div style="display: flex; justify-content: flex-start; align-items: center; gap: 15px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px;">
                    <h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px;">
                        📸 建立新快照
                    </h3>
                    
                    <button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save" style="padding: 8px 20px; font-size: 14px; background-color: #2980b9; border: none; box-shadow: 0 2px 4px rgba(41, 128, 185, 0.3);">
                        🚀 立即執行備份
                    </button>
                    
                    <span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none; font-weight: bold;">
                        ⏳ 正在連線設備並抓取設定，請稍候...
                    </span>
                </div>
                
                <!-- 表單網格區 -->
                <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
                    <div>
                        <label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
                            快照名稱 <span style="color: #e74c3c;">*</span>
                        </label>
                        <input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
                    </div>
                    <div>
                        <label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
                            配置類型
                        </label>
                        <select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
                            <option value="running">Running Config (運行中配置)</option>
                            <option value="full">Full Config (完整配置)</option>
                        </select>
                    </div>
                </div>

                <!-- 描述區 -->
                <div style="margin-bottom: 0;">
                    <label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
                        備份描述 <span style="color: #7f8c8d; font-weight: normal; font-size: 13px;">(選填)</span>
                    </label>
                    <input type="text" id="snapshotDescription" placeholder="請簡述此次備份的目的，例如：升級至 Unify FDD firmware，驗收通過" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px;">
                </div>
            </div>

            <!-- 下半部：歷史紀錄列表 -->
            <div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
                
                <!-- 💡 修正：移除負 margin，讓底線與內容邊界對齊 -->
                <div style="display: flex; align-items: center; gap: 20px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px; flex-wrap: wrap;">
                    
                    <h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px; white-space: nowrap;">
                        📁 歷史備份紀錄
                    </h3>
                    
                    <div style="display: flex; gap: 10px; align-items: center;">
                        <input type="text" id="filter-keyword" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
                        
                        <select id="filter-type" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
                            <option value="">所有類型</option>
                            <option value="running">running</option>
                            <option value="full">full</option>
                        </select>
                        
                        <!-- 💡 修正：回歸原生 type="date"，交由系統決定語系顯示 -->
                        <div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
                            <input type="date" id="filter-date-start" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
                            <span style="color: #7f8c8d; font-size: 13px;">至</span>
                            <input type="date" id="filter-date-end" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
                        </div>
                        
                        <button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
                            🔄 重新整理
                        </button>
                    </div>
                </div>
                
                <!-- 表格區塊 -->
                <table style="width: 100%; border-collapse: collapse; text-align: left;">
                    <thead>
                        <tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
                            <th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
                            <th style="padding: 10px; color: #34495e; width: 25%;">快照名稱</th>
                            <th style="padding: 10px; color: #34495e; width: 25%;">描述</th>
                            <th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
                            <th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
                        </tr>
                    </thead>
                    <tbody id="backup-history-tbody">
                        <tr>
                            <td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
                        </tr>
                    </tbody>
                </table>
            </div>

        </div>
    </div>

    <!-- 獨立的彈出式輸出視窗 (Modal) -->
    <div id="outputModal" class="modal-overlay">
        <div class="modal-container">
            <div class="modal-header">
                <div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
                    <span>📄 執行結果</span>
                    <span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
                </div>
                <div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
                    <button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
                </div>
            </div>
            <div class="modal-body">
                <pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
            </div>
        </div>
    </div>

    <!-- 🌟 新增：God Mode 授權視窗 (Modal) -->
    <div id="godModeModal" class="modal-overlay">
        <!-- 將對話框置中，限制最大寬度，改變一下配色風格 -->
        <div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
            <div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
                <div class="modal-title" style="justify-content: center; width: 100%;">
                    <span>🔐 系統進階授權</span>
                </div>
            </div>
            <div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
                <p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
                    請輸入維護者密碼以解鎖隱藏功能
                </p>
                <input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
                
                <span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
                
                <div style="display: flex; gap: 15px; justify-content: center;">
                    <button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
                    <button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
                </div>
            </div>
        </div>
    </div>

    <!-- 引入獨立的 JavaScript 邏輯 -->
    <script type="module" src="/static/app.js"></script>

</body>
</html>


================================================================================
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
from logger import get_logger

logger = get_logger("app.scraper")

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):
            logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
            
            try:
                # 🧹 [穩定性修復] 全面改用 async with 管理生命週期，確保連線與 process 絕對釋放
                async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
                    async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:

                        async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
                            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()
                                    # 🌟 精準 Prompt 偵測
                                    if prompt_pattern and re.search(prompt_pattern, output):
                                        break
                                except asyncio.TimeoutError:
                                    break
                            return output

                        # 🌟 新增：在第一批次連線時，先抓取設備版本
                        if cmts_version == "unknown":
                            # 🌟 補上 | nomore，並稍微延長等待時間，確保不會被分頁卡住
                            process.stdin.write("show version | nomore\n")
                            await process.stdin.drain()
                            version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
                            match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
                            if match:
                                cmts_version = match.group(1)

                        process.stdin.write("config\n")
                        await process.stdin.drain()
                        await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")

                        for path in batch:
                            # 🌟 優化 1：強迫讓出事件迴圈控制權，讓 FastAPI 去處理其他使用者的請求
                            await asyncio.sleep(0.01) 

                            # --- 步驟 1：發送 '?' 查詢 ---
                            command_to_send = f"{path} ?"
                            process.stdin.write(command_to_send)
                            await process.stdin.drain()
                            
                            output_question = await read_until_quiet(timeout=1.0)
                            q_data = parse_question_mark_output(output_question)
                            
                            backspaces = "\x08" * (len(command_to_send) + 5)
                            process.stdin.write(backspaces)
                            await process.stdin.drain()
                            await read_until_quiet(timeout=0.5)

                            parsed_data = {
                                "hint": q_data["hint"],
                                "options": q_data["options"],
                                "type": "list" if q_data["options"] else "unknown",
                                "current_value": q_data.get("current_value")
                            }

                            # --- 步驟 2：判斷是否需要發送 Enter ---
                            if q_data.get("is_format_only"):
                                parsed_data["type"] = "string_or_number"
                                    
                            elif not q_data["options"]:
                                process.stdin.write(f"{path}\n")
                                await process.stdin.drain()
                                
                                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:
                                    logger.debug(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 
            
            # --- 步驟 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
                            logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫！")
                        else:
                            logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆，退回寫入 JSON 快取...")
                    except Exception as e:
                        logger.warning(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)
                    logger.info(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔！(版本: {cmts_version}, 檔案: {cache_file})")

            except Exception as e:
                logger.error(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:
    """連線至設備並抓取完整的純文字設定檔"""
    try:
        # 🧹 [穩定性修復] 全面改用 async with 管理生命週期
        async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
            async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:

                async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
                    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()
                            # 🌟 精準 Prompt 偵測
                            if prompt_pattern and re.search(prompt_pattern, output):
                                break
                        except asyncio.TimeoutError:
                            break
                    return output

                # 🌟 新增這行：剛連線成功後，先清空終端機的登入歡迎詞 (MOTD) 與雜訊
                await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")

                # 關鍵修改：根據 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, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (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, prompt_pattern=r"\(config\)#")
                    
                    # 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, diagnostics, auth

@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")
app.include_router(diagnostics.router, prefix="/api/v1")
app.include_router(auth.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: logger.py
================================================================================
# --- logger.py ---
import logging
import sys

# 定義終端機輸出的 ANSI 顏色
COLORS = {
    "DEBUG": "\033[36m",     # 青色 (Cyan)
    "INFO": "\033[32m",      # 綠色 (Green)
    "WARNING": "\033[33m",   # 黃色 (Yellow)
    "ERROR": "\033[31m",     # 紅色 (Red)
    "CRITICAL": "\033[1;31m",# 粗體紅色 (Bold Red)
    "RESET": "\033[0m"       # 重置顏色
}

class ColoredFormatter(logging.Formatter):
    def format(self, record):
        log_color = COLORS.get(record.levelname, COLORS["RESET"])
        # 格式：時間 | 等級 | 模組名稱 | 訊息
        format_str = f"{log_color}%(asctime)s | %(levelname)-7s | %(name)-15s | %(message)s{COLORS['RESET']}"
        formatter = logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S")
        return formatter.format(record)

# 定義系統中受管控的模組清單
MANAGED_LOGGERS = [
    "app.database",    # 資料庫操作
    "app.scraper",     # 爬蟲與快取
    "app.diagnostics", # CM 診斷
    "app.backup",      # 備份與還原
    "app.ssh"          # SSH 連線引擎
]

def setup_logger(name: str, level=logging.INFO):
    logger = logging.getLogger(name)
    logger.setLevel(level)
    # 避免重複添加 Handler 導致日誌印兩次
    if not logger.handlers:
        handler = logging.StreamHandler(sys.stdout)
        handler.setFormatter(ColoredFormatter())
        logger.addHandler(handler)
    logger.propagate = False # 防止日誌往上傳遞給 root logger (避免被 FastAPI 預設格式覆蓋)
    return logger

# 系統啟動時，初始化所有模組，預設等級為 INFO
for name in MANAGED_LOGGERS:
    setup_logger(name, logging.INFO)

def get_logger(name: str):
    """供各個檔案引入 logger 使用"""
    if name not in MANAGED_LOGGERS:
        return setup_logger(name)
    return logging.getLogger(name)

def get_all_log_levels():
    """取得目前所有模組的日誌等級 (供前端 UI 顯示)"""
    return {name: logging.getLevelName(logging.getLogger(name).level) for name in MANAGED_LOGGERS}

def set_log_level(name: str, level_str: str):
    """動態設定特定模組的日誌等級"""
    if name in MANAGED_LOGGERS:
        level = getattr(logging, level_str.upper(), logging.INFO)
        logging.getLogger(name).setLevel(level)
        return True
    return 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`, `description`)。
- [x] **前端 UI 實作**：完成「設備備份與還原」頁籤，包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
- [x] **後端 API 實作**：完成手動建立快照與取得歷史快照列表的 API 路由。
- [x] **前端 UI 優化與防禦性編程**：完成滿版視覺重構、原生日期選擇器整合，並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。

### Phase 3: 智慧差異還原與歷史預覽 (Completed)
- [x] **歷史預覽 UI**：前端支援點擊歷史快照的「檢視」按鈕。
- [x] **前端安全還原防呆**：實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)，並加入跨設備還原阻斷機制。
- [x] **後端 Diff 引擎實作**：完成 `/api/v1/backups/{id}/diff`，成功生成絕對路徑指令陣列。
- [x] **前端串流接收器 (Streaming UI)**：升級 `executeSmartRestore`，使用 `ReadableStream` 即時渲染後端傳來的 SSH 逐行執行 Log，並具備自動捲動功能。
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**：
  - 實作 `/api/v1/backups/{id}/restore` API，採用 `StreamingResponse` (NDJSON 串流回應)。
  - 建立安全的逐行寫入機制 (Fail-safe)，遇錯立即停止、發送 `abort` 撤銷變更，並回傳錯誤 Chunk。
- [x] **效能與體驗優化**：完成 DOM 陣列渲染優化、Terminal 資源回收 (防殭屍連線)、精準 Prompt 偵測，以及 UI 視窗連動防呆機制。

---

## 🚧 目前開發階段 (Current Phase)

### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management)
- [ ] **自動備份攔截 (Auto-Backup Interceptor)**：在執行任何 `generate_cli` (寫入設備變更) 之前，實作自動觸發背景備份 `running config` 的防呆機制，確保每次變更前都有還原點。
- [ ] **備份保留策略 (Retention Policy)**：實作定期清理過期或過多歷史快照的機制，防止資料庫無限膨脹。

---

## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
- 目前系統運行穩定，各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。


================================================================================
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();
}

// 🌟 新增：動態抓取設備清單 API
export async function apiListCms(host, username, password) {
    const url = `/api/v1/list-cms?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
    const response = await fetch(url);
    return response.json();
}

export async function apiListRpds(host, username, password) {
    const url = `/api/v1/list-rpds?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);
            }
        }
    }
}

export async function apiGetCmDiagnostics(host, username, password, mac) {
    const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`;
    const response = await fetch(url);
    if (!response.ok) {
        const err = await response.json();
        throw new Error(err.detail || "伺服器發生錯誤");
    }
    return response.json();
}

export async function apiGetLogLevels() {
    const response = await fetch('/api/v1/settings/logs');
    return response.json();
}

export async function apiSetLogLevel(module, level) {
    const response = await fetch('/api/v1/settings/logs', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ module, level })
    });
    return response.json();
}

// ==========================================
// 7. 授權與驗證 API (Auth)
// ==========================================
export async function apiVerifyGodMode(password) {
    const response = await fetch('/api/v1/auth/god-mode', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ password })
    });
    
    if (!response.ok) {
        const err = await response.json();
        throw new Error(err.detail || "驗證失敗");
    }
    return response.json();
}


================================================================================
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,
    apiGetCmDiagnostics,
    apiListCms, apiListRpds, 
    apiGetLogLevels, apiSetLogLevel,
    apiVerifyGodMode 
} from './api.js';

// 3. 共用工具模組 (Utils)
import { getGlobalConnectionInfo } from './utils.js';

// 4. 樹狀圖 UI 模組 (Tree UI)
import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js';

// 5. 編輯模式與鎖定模組 (Edit Mode)
import { 
    releaseAllLocks, startEditFolder, startEditLeaf, 
    cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, 
    hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId  
} 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 = `⏳ 背景掃描進度：<b>${data.current} / ${data.total}</b> (正在處理: ${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;
    }
});

// 🌟 特務的記憶體：記錄「上一次輪詢時，處於鎖定狀態的路徑」(加入 Host 隔離)
let activeLockState = new Set();
let lockPollingTimer = null;
let currentPollingHost = null; // 記錄當前輪詢的設備 IP
window.globalActiveLocks = {}; // 🌟 升級為二維結構：{ "10.14.110.4": { "alias": {...} } }
window.recentlyReleasedLocks = {}; // 🌟 新增：防閃爍冷卻表 (Optimistic UI Cooldown)

function startLockStatusPolling() {
    if (lockPollingTimer) clearInterval(lockPollingTimer);
    
    lockPollingTimer = setInterval(async () => {
        const connInfo = getGlobalConnectionInfo();
        if (!connInfo || !connInfo.host) return;
        const host = connInfo.host;

        // 🛡️ 跨設備防呆：如果使用者切換了 IP，必須清空舊設備的鎖定記憶，防止幽靈解鎖
        if (currentPollingHost !== host) {
            activeLockState.clear();
            currentPollingHost = host;
        }

        try {
            const result = await apiGetLockStatus(host);
            if (result.status === 'success') {
                const currentLocks = result.data; 
                window.globalActiveLocks[host] = currentLocks; 
                const newLockState = new Set();
                const now = Date.now();

                // 🎯 任務 1：處理「新增鎖定」或「持續鎖定」的節點
                for (const [path, lockInfo] of Object.entries(currentLocks)) {
                    const lockKey = `${host}@@${path}`;

                    // 🛡️ 防閃爍機制：如果這個鎖是我們剛剛才主動釋放的，忽略後端傳來的舊狀態 (冷卻 8 秒)
                    if (window.recentlyReleasedLocks[lockKey]) {
                        if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
                            continue; // 跳過，不當作鎖定
                        } else {
                            delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間，清除記憶
                        }
                    }

                    newLockState.add(lockKey); // 記憶體加入 Host 標籤
                    
                    const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
                    // 🌟 擴大選擇器：同時選取完全匹配的節點，以及所有子節點
                    const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
                    
                    targetBtns.forEach(btn => {
                        // 略過目前正在編輯的那個實體按鈕
                        if (btn.id === `edit-btn-${currentEditElementId}`) return;

                        if (btn.innerText !== "🔒") {
                            btn.style.pointerEvents = 'none';
                            btn.style.opacity = '0.2';
                            
                            const btnPath = btn.getAttribute('data-path');
                            if (btnPath === path) {
                                // 判斷是別人鎖的，還是自己在另一個視圖鎖的
                                if (lockInfo.user_id !== SESSION_USER_ID) {
                                    btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
                                } else {
                                    btn.title = `🔒 您正在另一個視圖編輯此項目`;
                                }
                            } else {
                                // 子節點被父節點鎖定
                                btn.title = `🔒 父層級已被鎖定，無法編輯此項目`;
                            }
                            btn.innerText = "🔒";
                        }
                    });
                }

                // 🔓 任務 2：處理「解除鎖定」的節點
                activeLockState.forEach(oldKey => {
                    const [oldHost, oldPath] = oldKey.split('@@');
                    
                    // 🛡️ 嚴格限制：只解除「當前設備」且「已不在新鎖定清單中」的節點
                    if (oldHost === host && !newLockState.has(oldKey)) {
                        const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
                        // 🌟 擴大選擇器：同時選取完全匹配的節點，以及所有子節點
                        const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
                        
                        targetBtns.forEach(btn => {
                            if (btn.innerText === "🔒") {
                                const btnPath = btn.getAttribute('data-path');
                                let stillLockedByOther = false;
                                
                                // 檢查自己是否還在鎖定清單中
                                if (currentLocks[btnPath]) {
                                    stillLockedByOther = true;
                                } else {
                                    // 檢查是否有其他父節點鎖定它
                                    for (const lockedPath of Object.keys(currentLocks)) {
                                        if (btnPath.startsWith(lockedPath + "::")) {
                                            stillLockedByOther = true;
                                            break;
                                        }
                                    }
                                }
                                
                                if (!stillLockedByOther) {
                                    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);
}

// 切換「設備查詢」內的子任務表單
// ============================================================================
// 🌟 設備查詢動態清單快取 (新增)
// ============================================================================
let cachedDeviceLists = { cms: null, rpds: null, host: null };

// 切換「設備查詢」內的子任務表單
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');

    // 🌟 觸發背景動態載入 Datalist
    loadDynamicDatalist(selectedTaskId);
}

// 🌟 新增：背景載入 Datalist 邏輯
async function loadDynamicDatalist(taskType) {
    // 若尚未連線，則不觸發背景抓取 (不跳警告，默默 return)
    if (!isConnected) return; 
    
    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) return;

    // 若切換了設備 IP，清空舊快取
    if (cachedDeviceLists.host !== host) {
        cachedDeviceLists = { cms: null, rpds: null, host: host };
    }

    if (taskType === 'form-cm-query' || taskType === 'form-cm-diagnostics') {
        const inputId = taskType === 'form-cm-query' ? 'queryTargetCm' : 'diagMacInput';
        const listId = taskType === 'form-cm-query' ? 'cm-mac-list' : 'diag-cm-mac-list';
        const defaultPlaceholder = taskType === 'form-cm-query' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例如: 6467.7240.4076";
        
        await fetchAndBindList('cms', inputId, listId, defaultPlaceholder, host, user, pass);
    } else if (taskType === 'form-rpd-query') {
        await fetchAndBindList('rpds', 'queryTargetRpd', 'rpd-vcvs-list', "例: 13:0 (留白則查詢全體)", host, user, pass);
    }
}

// 🌟 新增：執行 API 呼叫與 UX 狀態切換
async function fetchAndBindList(type, inputId, listId, defaultPlaceholder, host, user, pass) {
    const inputEl = document.getElementById(inputId);
    const datalistEl = document.getElementById(listId);
    if (!inputEl || !datalistEl) return;

    // 如果已經有快取，直接綁定並結束
    if (cachedDeviceLists[type]) {
        populateDatalist(datalistEl, cachedDeviceLists[type]);
        return;
    }

    // 狀態 1：發出請求前 (鎖定輸入框，提示載入中)
    inputEl.disabled = true;
    inputEl.placeholder = "🔄 載入設備清單中...";
    inputEl.style.backgroundColor = "#fdfefe";

    try {
        let data;
        if (type === 'cms') {
            data = await apiListCms(host, user, pass);
            cachedDeviceLists.cms = data.cms || [];
        } else {
            data = await apiListRpds(host, user, pass);
            cachedDeviceLists.rpds = data.rpds || [];
        }

        // 狀態 2：請求成功 (塞入選項，恢復輸入框)
        populateDatalist(datalistEl, cachedDeviceLists[type]);
        inputEl.placeholder = defaultPlaceholder;
    } catch (error) {
        console.error(`[Background Task] Failed to load ${type}:`, error);
        // 狀態 3：請求失敗 (提示失敗，但仍解鎖讓使用者手動輸入)
        inputEl.placeholder = "⚠️ 清單載入失敗，請手動輸入";
    } finally {
        inputEl.disabled = false;
        inputEl.style.backgroundColor = ""; // 恢復預設背景色
    }
}

// 🌟 新增：將陣列轉換為 <option> 塞入 <datalist>
function populateDatalist(datalistEl, items) {
    datalistEl.innerHTML = '';
    items.forEach(item => {
        const option = document.createElement('option');
        option.value = item;
        datalistEl.appendChild(option);
    });
}

// 🌟 新增一個變數來記錄上一次的樹狀圖模式
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();

    // 🌟 新增：清空動態下拉選單的快取，確保切換設備時重新抓取
    if (typeof cachedDeviceLists !== 'undefined') {
        cachedDeviceLists = { cms: null, rpds: null, host: null };
    }
    
    // 🧹 [修復 Leak] 清空前端樹狀圖快取，避免切換設備時發生 OOM
    clearTreeCache();
    
    // 🛡️ [跨設備防護] 切換設備時，清空鎖定輪詢的記憶體
    if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
        activeLockState.clear();
    }

    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 mdInput = document.getElementById('cfgMacDomain');
    if (mdInput) {
        mdInput.value = '';
        mdInput.placeholder = '請先點擊上方載入任務...';
    }
    const mdDatalist = document.getElementById('mac-domain-list');
    if (mdDatalist) mdDatalist.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');
    
    // 🌟 模擬動態訊息流邏輯
    const states = [
        `🔌 正在建立 SSH 連線至 ${connInfo.host}...`,
        `📥 正在向設備發送查詢指令...`,
        `🧩 正在等待設備回傳資料...`
    ];
    let progressIdx = 0;
    outputEl.innerHTML = `<span id="query-progress-text" style="color: #f39c12; font-weight: bold;">${states[0]}</span>`;
    
    const progressInterval = setInterval(() => {
        progressIdx++;
        if (progressIdx < states.length) {
            const textEl = document.getElementById('query-progress-text');
            if (textEl) textEl.innerText = states[progressIdx];
        }
    }, 1500); // 每 1.5 秒切換一次狀態
    
    try {
        const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
        clearInterval(progressInterval); // 收到結果，停止輪播
        
        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) {
        clearInterval(progressInterval); // 發生錯誤，停止輪播
        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.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色
        loadingMsg.innerHTML = '⏳ 準備連線至設備...'; 
    }
    if (treeContainer) treeContainer.innerHTML = ''; 

    let needAutoScan = false;
    let progressInterval = null; 

    try {
        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;

                // 🌟 關鍵修正：當資料庫的版本為 unknown 時，不視為版本變更，直接放行，避免無意義的彈窗干擾
                if (cachedVersion && cachedVersion !== 'unknown' && 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);
        }

        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;
                                if (loadingMsg) {
                                    loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成！`;
                                    loadingMsg.style.color = "#27ae60"; 
                                }
                                await new Promise(resolve => setTimeout(resolve, 600));
                                if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色
                            }
                            if (loadingMsg) {
                                loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色
                                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;
        
        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 = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
    } finally {
        if (progressInterval) clearInterval(progressInterval);
        if (loadingMsg) {
            loadingMsg.style.display = 'none';
            loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色
        }
    }
}

// ============================================================================
// 🌟 CM 一鍵診斷中心邏輯 (多頻道切換與紅綠燈實作)
// ============================================================================
let merChartInstance = null; 
let globalMerData = {}; // 暫存所有頻道的 MER 資料供切換使用

window.runCmDiagnostics = async function() {
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo) return;

    const macInput = document.getElementById('diagMacInput').value.trim();
    if (!macInput) return alert("請輸入目標 CM 的 MAC Address！");

    const btn = document.getElementById('btnRunDiag');
    const msg = document.getElementById('diagStatusMsg');
    const resultArea = document.getElementById('diagResultArea');

    btn.disabled = true;
    btn.style.opacity = '0.7';
    msg.style.display = 'inline-block';
    resultArea.style.display = 'none';

    try {
        const result = await apiGetCmDiagnostics(connInfo.host, connInfo.user, connInfo.pass, macInput);
        
        if (result.status === 'success') {
            const data = result.data;

            // 1. 填入基本資訊
            document.getElementById('diagResMac').textContent = data.mac;
            document.getElementById('diagResIp').textContent = data.ip || 'N/A';
            document.getElementById('diagResCpe').textContent = data.cpe_count;
            
            const stateEl = document.getElementById('diagResState');
            stateEl.textContent = data.state || 'N/A';
            stateEl.style.color = (data.state && data.state.toLowerCase().includes('online')) ? '#27ae60' : '#e74c3c';

            document.getElementById('diagResTx').textContent = data.phy.tx_power !== null ? data.phy.tx_power : 'N/A';
            document.getElementById('diagResRx').textContent = data.phy.rx_power !== null ? data.phy.rx_power : 'N/A';

            // 2. 渲染上行 SNR (Upstreams) 標籤群
            const usContainer = document.getElementById('upstreamSnrContainer');
            usContainer.innerHTML = '';
            if (data.upstreams && data.upstreams.length > 0) {
                data.upstreams.forEach(us => {
                    let color = '#e74c3c'; // 預設紅 (差: < 30)
                    if (us.snr >= 35) color = '#27ae60'; // 綠 (優: >= 35)
                    else if (us.snr >= 30) color = '#f39c12'; // 橘 (尚可: 30~34.9)
                    
                    usContainer.innerHTML += `
                        <div style="background-color: ${color}; color: white; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; box-shadow: 0 1px 3px rgba(0,0,0,0.2);">
                            ${us.channel} : ${us.snr}
                        </div>
                    `;
                });
            } else {
                usContainer.innerHTML = '<span style="color: #bdc3c7; font-size: 13px;">無資料</span>';
            }

            // 3. 處理 OFDM MER 頻道按鈕與圖表
            globalMerData = data.mer_channels;
            const tabsContainer = document.getElementById('ofdmChannelTabs');
            tabsContainer.innerHTML = '';

            const channels = Object.keys(globalMerData);
            if (channels.length > 0) {
                channels.forEach((ch, index) => {
                    const chData = globalMerData[ch];
                    // 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅)
                    let bgCol = '#bdc3c7'; // 預設灰色 (無資料)
                    if (chData.health === 'good') bgCol = '#27ae60';
                    else if (chData.health === 'warning') bgCol = '#f39c12';
                    else if (chData.health === 'critical') bgCol = '#e74c3c';

                    const btn = document.createElement('button');
                    // 💡 拔除方正的 class，改用純手工膠囊樣式
                    btn.style.boxSizing = 'border-box';
                    btn.style.cursor = 'pointer';
                    btn.style.backgroundColor = bgCol;
                    btn.style.color = '#ffffff';
                    btn.style.padding = '6px 14px';
                    btn.style.borderRadius = '20px'; // 🌟 完美的膠囊圓角
                    btn.style.fontSize = '12px';
                    btn.style.fontWeight = 'bold';
                    btn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
                    btn.style.transition = 'all 0.2s ease';
                    
                    // 點擊與未點擊的狀態區分 (透明度與外框)
                    btn.style.opacity = index === 0 ? '1' : '0.5'; 
                    btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent';
                    
                    // 標籤文字加入最低 MER 數值提示
                    btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`;
                    
                    btn.onclick = () => {
                        // 重置所有按鈕外觀
                        Array.from(tabsContainer.children).forEach(b => {
                            b.style.opacity = '0.5';
                            b.style.border = '2px solid transparent';
                        });
                        // 突顯當前點擊的按鈕
                        btn.style.opacity = '1';
                        btn.style.border = '2px solid #2c3e50';
                        // 重新繪圖
                        renderMerChart(ch, chData.histogram);
                    };
                    tabsContainer.appendChild(btn);
                });
                
                // 預設渲染第一個頻道的圖表
                renderMerChart(channels[0], globalMerData[channels[0]].histogram);
            } else {
                tabsContainer.innerHTML = '<span style="color: #e74c3c; font-size: 14px; font-weight: bold;">⚠️ 設備無回傳任何 OFDM 頻道 MER 數據</span>';
                renderMerChart('無資料', {});
            }

            resultArea.style.display = 'flex';
        }
    } catch (error) {
        alert(`❌ 診斷失敗: ${error.message}`);
    } finally {
        btn.disabled = false;
        btn.style.opacity = '1';
        msg.style.display = 'none';
    }
};

function renderMerChart(channelName, histogramData) {
    if (merChartInstance) {
        merChartInstance.destroy();
    }
    const ctx = document.getElementById('merChart').getContext('2d');

    if (!histogramData || Object.keys(histogramData).length === 0) {
        merChartInstance = new Chart(ctx, {
            type: 'bar',
            data: { labels: ['無資料'], datasets: [{ label: 'Subcarriers', data: [0] }] },
            options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: '無法取得 MER 數據' } } }
        });
        return;
    }

    const labels = Object.keys(histogramData).sort((a, b) => parseInt(a) - parseInt(b));
    const dataPoints = labels.map(label => histogramData[label]);

    merChartInstance = new Chart(ctx, {
        type: 'bar',
        data: {
            labels: labels.map(l => `${l} dB`),
            datasets: [{
                label: 'Subcarriers 數量',
                data: dataPoints,
                backgroundColor: 'rgba(155, 89, 182, 0.6)', 
                borderColor: 'rgba(142, 68, 173, 1)',
                borderWidth: 1,
                borderRadius: 4,
                hoverBackgroundColor: 'rgba(155, 89, 182, 0.9)'
            }]
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            scales: {
                y: {
                    beginAtZero: true,
                    title: { display: true, text: 'Subcarriers 數量', font: { weight: 'bold' } },
                    grid: { color: '#ecf0f1' }
                },
                x: {
                    title: { display: true, text: 'MER (dB)', font: { weight: 'bold' } },
                    grid: { display: false }
                }
            },
            plugins: {
                title: {
                    display: true,
                    text: `顯示中頻道: ${channelName}`,
                    font: { size: 15, weight: 'bold' },
                    color: '#2c3e50'
                },
                legend: { display: false },
                tooltip: {
                    backgroundColor: 'rgba(44, 62, 80, 0.9)',
                    titleFont: { size: 14 },
                    bodyFont: { size: 14 },
                    padding: 10,
                    callbacks: {
                        label: function(context) {
                            return ` ${context.raw} 個 Subcarriers`;
                        }
                    }
                }
            }
        }
    });
}

// ============================================================================
// 🌟 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 => `<option value="${opt}">`).join('');
            }
        } else {
            // 🌟 3. POST 請求：把 host 塞進 body 裡面
            fetch('/api/v1/cmts-leaf-options/sync', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode })
            });
        }
    } catch (e) {
        console.warn("選項外掛載入失敗，維持純文字輸入", e);
    }
}

// ============================================================================
// 🌟 核心功能：執行掃描 (廣播升級版)，加上 mode 參數
// ============================================================================

// 🌟 新增：遞迴提取所有快取 Key 的輔助函數 (不依賴網頁 DOM)
function extractAllCacheKeys(node, path = '') {
    let keys = [];
    for (const [key, value] of Object.entries(node)) {
        const currentPath = path ? `${path}::${key}` : key;
        if (typeof value === 'object' && value !== null) {
            keys = keys.concat(extractAllCacheKeys(value, currentPath));
        } else {
            const origVal = (value === null ? '' : value).toString();
            let cacheKey = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
            if (currentPath.endsWith('::no')) {
                cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
            }
            keys.push(cacheKey);
        }
    }
    return keys;
}

async function performScan(isGlobal, mode = 'running') {
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo || !connInfo.host) return alert("請先連線至設備！");

    const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
    if (isSomeoneScanning) {
        alert("目前已有其他使用者正在執行掃描，請稍候共享更新結果！");
        return;
    }
    
    const treeContainer = document.getElementById(`tree-container-${mode}`);
    if (!treeContainer) return;

    const statusEl = document.getElementById('scan-status');
    
    try {
        const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
        const optData = await optRes.json();
        const pathsToSync = [];

        if (isGlobal) {
            // 🌟 全域掃描：直接從原始 JSON 資料遞迴提取，不依賴 DOM
            if (!window.currentTreeData) return alert("請先載入設備配置！");
            const allKeys = extractAllCacheKeys(window.currentTreeData);
            
            allKeys.forEach(cacheKey => {
                if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
                    pathsToSync.push(cacheKey);
                }
            });
        } else {
            // 🌟 局部掃描：維持原樣，只抓畫面上看得到的 (已展開的)
            const allContainers = treeContainer.querySelectorAll('.leaf-container');
            allContainers.forEach(container => {
                const isHiddenByFolder = container.closest('details:not([open])') !== null;
                if (!isHiddenByFolder) {
                    const path = container.getAttribute('data-path');
                    if (path) {
                        const origVal = container.getAttribute('data-original') || '';
                        let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
                        if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
                        
                        if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
                            pathsToSync.push(cacheKey);
                        }
                    }
                }
            });
        }

       if (pathsToSync.length === 0) {
            statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項，無需掃描！" : "✅ 局部展開的欄位都已有最新選項，無需掃描！";
            statusEl.style.color = "#27ae60";
            setTimeout(() => statusEl.textContent = "", 3000);
            return;
        }

        const response = await fetch('/api/v1/cmts-leaf-options/sync', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode })
        });
        
        const result = await response.json();
        if (result.status === 'busy') {
            alert(result.message);
        }

    } catch (error) {
        console.error("掃描請求發送失敗:", error);
        statusEl.textContent = "❌ 掃描請求發送失敗，請檢查網路連線。";
        statusEl.style.color = "#e74c3c";
    }
}

// ============================================================================
// 🌟 核心功能：清除快取 (廣播升級版)，加上 mode 參數
// ============================================================================
async function performClear(isGlobal, mode = 'running') {
    // 🌟 1. 取得當前連線的 IP
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo || !connInfo.host) return alert("請先連線至設備！");

    // 🌟 2. 檢查掃描狀態時，傳入 host
    const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
    if (isSomeoneScanning) {
        alert("⚠️ 系統正在進行全域選項掃描更新中！\n為避免資料衝突，請稍候掃描完成再執行清除動作。");
        return;
    }

    let pathsToClear = [];
    
    if (isGlobal) {
        try {
            // 🌟 3. GET 請求：網址加上 host 參數
            const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
            const optData = await optRes.json();
            pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__');
        } catch (e) {
            console.error(e);
            return alert("❌ 無法取得全域快取清單。");
        }
    } else {
        const treeContainer = document.getElementById(`tree-container-${mode}`);
        const elements = treeContainer ? treeContainer.querySelectorAll('.leaf-container') : []; 
        elements.forEach(el => {
            const isHiddenByFolder = el.closest('details:not([open])') !== null;
            if (!isHiddenByFolder) { 
                const path = el.getAttribute('data-path');
                if (path) {
                    const cacheKey = path.replace(/::/g, ' ');
                    if (!pathsToClear.includes(cacheKey)) pathsToClear.push(cacheKey);
                }
            }
        });
    }

    if (pathsToClear.length === 0) return alert(isGlobal ? "目前沒有任何快取可以清除！" : "局部沒有展開的選項可以清除！\n請先展開您想重抓的資料夾。");
    
    const msg = isGlobal 
        ? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎？\n(這將會清空所有已儲存的下拉選單資料)` 
        : `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎？\n(清除後將自動為您重新掃描)`;
    
    if (!confirm(msg)) return;

    try {
        // 🌟 4. 呼叫清除 API 時，傳入 host
        const result = await apiClearCache(connInfo.host, pathsToClear, mode); 
        const statusEl = document.getElementById('scan-status');
        if (statusEl) {
            statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取！準備重新掃描...`;
            statusEl.style.color = "#27ae60";
        }
        
        // 清除完畢後，自動呼叫簡化版的 performScan
        await performScan(isGlobal, mode);
        
    } catch (error) {
        console.error("清除快取失敗:", error);
        alert("❌ 清除快取失敗，請檢查網路連線或後端 API 是否正確啟動。");
    }
}

// 綁定給 HTML 呼叫的 4 個獨立函數
function scanVisibleMissingOptions() { 
    performScan(false); 
}
function scanGlobalMissingOptions() { 
    // 🌟 動態抓取當前選擇的模式，傳給 performScan
    const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
    performScan(true, mode); 
}
function clearVisibleCache() { 
    performClear(false); 
}
function clearGlobalCache() { 
    // 🌟 動態抓取當前選擇的模式，傳給 performClear
    const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
    performClear(true, mode); 
}

// ============================================================================
// 🌟 5. 系統設定與過濾器 (System Settings & Filters)
// ============================================================================

let currentHiddenKeys = [];

// 🌟 新增：切換過濾器模式時觸發
async function switchFilterMode() {
    const container = document.getElementById('filter-checkboxes');
    if (container) container.style.display = 'none'; // 先隱藏樹狀圖，強迫使用者重新載入
    await openSystemSettings();
}

// 開啟系統設定並讀取目前的過濾器清單
async function openSystemSettings() {
    // 🌟 動態抓取過濾器模式
    const modeSelect = document.getElementById('filter-mode-select');
    const mode = modeSelect ? modeSelect.value : 'running';

    try {
        const result = await apiGetTreeFilters(mode); 
        if (result.status === 'success') currentHiddenKeys = result.data;
    } catch (error) {
        console.error("讀取設定失敗:", error);
    }

    // 🌟 新增：同時載入日誌等級
    loadLogLevels();
}

async function loadLogLevels() {
    try {
        const res = await apiGetLogLevels();
        if (res.status === 'success') {
            const container = document.getElementById('log-settings-container');
            container.innerHTML = '';
            
            // 模組名稱對照表
            const moduleNames = {
                'app.database': '🗄️ 資料庫模組 (Database)',
                'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
                'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
                'app.backup': '💾 備份與還原模組 (Backup)',
                'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)'
            };

            for (const [module, level] of Object.entries(res.data)) {
                const displayName = moduleNames[module] || module;
                // 根據等級改變下拉選單顏色
                const bgColor = level === 'DEBUG' ? '#fcf3f2' : (level === 'ERROR' ? '#fdedec' : '#f8f9fa');
                const textColor = level === 'DEBUG' ? '#c0392b' : (level === 'ERROR' ? '#e74c3c' : '#2c3e50');

                container.innerHTML += `
                    <div style="display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 12px 15px; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
                        <span style="font-weight: bold; color: #34495e; font-size: 14px;">${displayName}</span>
                        <select onchange="changeLogLevel('${module}', this.value)" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 13px; cursor: pointer; background-color: ${bgColor}; color: ${textColor}; transition: all 0.2s;">
                            <option value="DEBUG" ${level === 'DEBUG' ? 'selected' : ''}>🐞 DEBUG (詳細)</option>
                            <option value="INFO" ${level === 'INFO' ? 'selected' : ''}>🟢 INFO (一般)</option>
                            <option value="WARNING" ${level === 'WARNING' ? 'selected' : ''}>🟠 WARNING (警告)</option>
                            <option value="ERROR" ${level === 'ERROR' ? 'selected' : ''}>🔴 ERROR (錯誤)</option>
                        </select>
                    </div>
                `;
            }
        }
    } catch (e) {
        console.error("載入日誌等級失敗", e);
    }
}

// 💡 改成一般的 function 定義
async function changeLogLevel(module, level) {
    try {
        const res = await apiSetLogLevel(module, level);
        if (res.status === 'success') {
            loadLogLevels(); // 重新渲染以更新顏色
        }
    } catch (e) {
        alert("設定日誌等級失敗！");
    }
}

// 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
async function loadRealConfigForFilters() {
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊！");

    const modeSelect = document.getElementById('filter-mode-select');
    const mode = modeSelect ? modeSelect.value : 'running';

    const loadingMsg = document.getElementById('filter-loading-msg');
    const container = document.getElementById('filter-checkboxes');
    
    loadingMsg.style.display = 'inline-block';
    loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色
    loadingMsg.innerHTML = '⏳ 準備連線至設備...';
    container.style.display = 'none';

    let progressInterval = null; // 🌟 新增：進度條計時器

    try {
        const url = `/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
        const response = await fetch(url);
        
        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 = `📥 正在下載 ${mode} 配置檔 (${Math.round(progress)}%)...`;
                                }
                            }, 500);
                        } else {
                            if (progressInterval) {
                                clearInterval(progressInterval);
                                progressInterval = null;
                                if (loadingMsg) {
                                    loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (100%) - 下載完成！`;
                                    loadingMsg.style.color = "#27ae60"; // 瞬間變綠色
                                }
                                await new Promise(resolve => setTimeout(resolve, 600));
                                if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 恢復橘色
                            }
                            if (loadingMsg) {
                                loadingMsg.style.color = '#f39c12'; 
                                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) {
            container.style.display = 'block';
            container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
        } else {
            throw new Error("未收到完整的配置資料");
        }

    } catch (error) {
        container.style.display = 'block';
        container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
    } finally {
        if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
        loadingMsg.style.display = 'none';
    }
}

// 當點擊「父節點」時：同步勾選/取消所有子節點
function toggleChildCheckboxes(parentCheckbox) {
    const details = parentCheckbox.closest('details');
    if (details) {
        const childCheckboxes = details.querySelectorAll('.filter-children-container .filter-checkbox');
        childCheckboxes.forEach(cb => cb.checked = parentCheckbox.checked);
    }
    updateParentCheckboxState(parentCheckbox);
}

// 當點擊「子節點」時：往上檢查父節點是否該被勾選
function updateParentCheckboxState(element) {
    const parentDetails = element.closest('.filter-children-container')?.closest('details');
    if (!parentDetails) return;

    const parentCheckbox = parentDetails.querySelector('summary .filter-checkbox');
    const childCheckboxes = parentDetails.querySelectorAll('.filter-children-container .filter-checkbox');
    
    if (parentCheckbox && childCheckboxes.length > 0) {
        const allChecked = Array.from(childCheckboxes).every(cb => cb.checked);
        parentCheckbox.checked = allChecked;
        updateParentCheckboxState(parentCheckbox);
    }
}

// 儲存系統設定 (過濾器)
async function saveSystemSettings() {
    // 🌟 動態抓取過濾器模式
    const modeSelect = document.getElementById('filter-mode-select');
    const mode = modeSelect ? modeSelect.value : 'running';

    const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
    const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
    
    try {
        const result = await apiSaveTreeFilters(selectedHiddenKeys, mode); 
        if (result.status === 'success') {
            const statusText = document.getElementById('settings-status');
            statusText.style.display = 'inline-block';
            currentHiddenKeys = selectedHiddenKeys; 
            setTimeout(() => { statusText.style.display = 'none'; }, 3000);
        }
    } catch (error) {
        console.error("儲存設定失敗:", error);
        alert("儲存設定時發生錯誤！");
    }
}

// ============================================================================
// 🌟 新增：設備備份與還原 (Backup & Restore)
// ============================================================================

// 🌟 新增：全域變數用來暫存所有快照資料，實現前端秒速過濾
let allBackupsData = [];

// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
async function createSnapshot() {
    if (!isConnected) {
        return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後，再執行備份任務！\n(確保您清楚目前正在操作哪一台設備)");
    }

    const connInfo = getGlobalConnectionInfo();
    if (!connInfo || !connInfo.host) return;

    const snapshotName = document.getElementById('snapshotName').value.trim();
    if (!snapshotName) return alert("請輸入快照名稱！");

    const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
    const configType = document.getElementById('backupConfigType').value;
    const btn = document.getElementById('btnCreateSnapshot');
    const msg = document.getElementById('backup-status-msg');

    btn.disabled = true;
    msg.style.display = 'inline-block';
    msg.style.color = '#f39c12'; // 🌟 確保橘色
    msg.innerHTML = '⏳ 準備連線至設備...';

    let progressInterval = null; // 🌟 新增：進度條計時器

    try {
        const response = await fetch('/api/v1/backups/snapshot', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                host: connInfo.host,
                username: connInfo.user,
                password: connInfo.pass,
                snapshot_name: snapshotName,
                description: snapshotDescription,
                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 isSuccess = false;
        let finalMessage = "";

        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 (msg) {
                                    msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
                                }
                            }, 500);
                        } else {
                            if (progressInterval) {
                                clearInterval(progressInterval);
                                progressInterval = null;
                                if (msg) {
                                    msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成！`;
                                    msg.style.color = "#27ae60"; // 瞬間變綠色
                                }
                                await new Promise(resolve => setTimeout(resolve, 600));
                                if (msg) msg.style.color = "#f39c12"; // 恢復橘色
                            }
                            if (msg) {
                                msg.style.color = '#f39c12'; 
                                msg.innerHTML = data.message;
                            }
                        }
                    } else if (data.status === 'success') {
                        isSuccess = true;
                        finalMessage = data.message;
                    } else if (data.status === 'error') {
                        throw new Error(data.message);
                    }
                } catch (e) {
                    if (e.message) throw e; 
                    console.warn("解析串流 JSON 失敗:", line);
                }
            }
        }

        if (isSuccess) {
            alert(`✅ ${finalMessage}`);
            document.getElementById('snapshotName').value = '';
            if (document.getElementById('snapshotDescription')) {
                document.getElementById('snapshotDescription').value = '';
            }
            loadBackupHistory();
        }
    } catch (error) {
        alert(`❌ 發生錯誤: ${error.message}`);
    } finally {
        if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
        btn.disabled = false;
        msg.style.display = 'none';
    }
}

// 2. 載入歷史紀錄列表 (維持 IP 綁定，避免後端報錯)
async function loadBackupHistory() {
    // 🌟 補回取得當前畫面上設定的 IP
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo || !connInfo.host) return;

    const tbody = document.getElementById('backup-history-tbody');
    if (!tbody) return;
    
    // 🌟 修正：將原本的灰色改為橘色並加粗
    tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';

    try {
        // 🌟 關鍵修改：將 config_type 改為 'all'，一次把該設備的所有快照撈回來
        const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=all`);
        const result = await response.json();

        if (result.status === 'success') {
            allBackupsData = result.data;
            applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染
        } else {
            // 加入防呆：如果後端回傳 422 (detail)，也能正確印出錯誤而不是 undefined
            const errMsg = result.message || (result.detail ? JSON.stringify(result.detail) : '未知錯誤');
            tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 載入失敗: ${errMsg}</td></tr>`;
        }
    } catch (error) {
        tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 連線失敗: ${error.message}</td></tr>`;
    }
}

// 💡 前端多維度過濾器邏輯
window.applyBackupFilters = function() {
    const tbody = document.getElementById('backup-history-tbody');
    if (!tbody) return;

    // 取得所有過濾條件 (加上 trim() 避免不小心多敲了空白鍵)
    const keyword = (document.getElementById('filter-keyword')?.value || '').trim().toLowerCase();
    const type = document.getElementById('filter-type')?.value || '';
    const dateStart = document.getElementById('filter-date-start')?.value;
    const dateEnd = document.getElementById('filter-date-end')?.value;

    // 進行陣列過濾
    const filteredData = allBackupsData.filter(item => {
        // 💡 終極防呆版：確保變數存在，並同時搜尋名稱與描述
        const safeName = (item.snapshot_name || '').toLowerCase();
        const safeDesc = (item.description || '').toLowerCase();
        const matchKeyword = safeName.includes(keyword) || safeDesc.includes(keyword);
        
        const matchType = type === '' || item.config_type === type;
        
        let matchDate = true;
        const itemDate = new Date(item.timestamp);
        if (dateStart) {
            const start = new Date(dateStart);
            start.setHours(0, 0, 0, 0);
            if (itemDate < start) matchDate = false;
        }
        if (dateEnd) {
            const end = new Date(dateEnd);
            end.setHours(23, 59, 59, 999);
            if (itemDate > end) matchDate = false;
        }

        return matchKeyword && matchType && matchDate;
    });

    // 渲染過濾後的結果
    if (filteredData.length > 0) {
        // 🌟 新增：判斷當前是否已解鎖上帝模式 (God Mode)
        const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
        const displayAdmin = isGodMode ? 'inline-block' : 'none';

        tbody.innerHTML = filteredData.map(item => `
            <tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
                <td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
                <td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
                <!-- 🟢 描述欄位 -->
                <td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
                    ${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
                </td>
                <td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
                <td style="padding: 12px 10px; text-align: right;">
                    <button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
                    <!-- 🌟 加上 admin-only-action class 與動態 display 樣式，只有解鎖後才會出現 -->
                    <button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
                    <button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
                </td>
            </tr>
        `).join('');
    } else {
        // 🟢 修正：沒有資料時，colspan 改為 5
        tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
    }
};

// 3. 檢視特定快照內容
async function viewBackup(backupId) {
    openModal("載入快照中...");
    const outputEl = document.getElementById('modalOutput');
    outputEl.innerHTML = `<span style="color: #f39c12;">⏳ 正在向資料庫撈取快照資料，請稍候...</span>`;

    try {
        const response = await fetch(`/api/v1/backups/${backupId}`);
        const result = await response.json();

        if (result.status === 'success') {
            // 確保這行真的有加進去，且檔案有按下 Ctrl+S 儲存！
            const data = result.data; 

            // 🟤 將描述加入彈出視窗的標頭中
            const descText = data.description ? data.description : '無';
            const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【備份描述】: ${descText}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`;
            
            // 如果後端有回傳 raw_cli 就優先顯示，否則將 parsed_tree 轉為字串顯示
            const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2);
            
            outputEl.style.color = "#e0e0e0";
            outputEl.textContent = infoHeader + content;
        } else {
            outputEl.style.color = "#e74c3c";
            outputEl.textContent = `❌ 讀取失敗: ${result.message}`;
        }
    } catch (error) {
        outputEl.style.color = "#e74c3c";
        outputEl.textContent = `❌ 連線失敗: ${error.message}`;
    }
}

// 4. 刪除快照
async function deleteBackup(backupId) {
    if (!confirm("⚠️ 確定要永久刪除這筆備份紀錄嗎？此動作無法復原！")) return;

    try {
        const response = await fetch(`/api/v1/backups/${backupId}`, { method: 'DELETE' });
        const result = await response.json();

        if (result.status === 'success') {
            loadBackupHistory(); // 刪除成功後重新整理列表
        } else {
            alert(`❌ 刪除失敗: ${result.message}`);
        }
    } catch (error) {
        alert(`❌ 發生錯誤: ${error.message}`);
    }
}

// 5. 請求設備差異分析 (安全還原第一步)
// 🌟 接收第三個參數 backupHost
async function restoreBackup(snapshotId, snapshotName, backupHost) {
    // 🛡️ 防線一：必須先連線
    if (!isConnected) {
        return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後，再執行還原任務！");
    }

    const connInfo = getGlobalConnectionInfo();
    if (!connInfo || !connInfo.host) return;

    // 🛡️ 防線二：嚴格阻斷跨設備還原
    if (backupHost && backupHost !== connInfo.host) {
        alert(
            `🚨 【跨設備還原阻斷】🚨\n\n` +
            `這份快照屬於設備：${backupHost}\n` +
            `您目前連線的設備：${connInfo.host}\n\n` +
            `⚠️ 系統目前禁止跨設備還原，以防止嚴重的網路衝突。請先連線至正確的設備再執行此操作！`
        );
        return; // 直接中斷，不進行後續 API 呼叫
    }

    const modal = document.getElementById('outputModal');
    const modalOutput = document.getElementById('modalOutput');
    const modalTargetInfo = document.getElementById('modalTargetInfo');
    
    // 1. 移除進度條，改為純文字百分比動態顯示
    modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
    // 🌟 修正：將原本的藍色改為橘色
    modalOutput.innerHTML = `
        <div id="diff-progress-text" style="color: #f39c12; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置，並與快照進行深度比對... (0%)</div>
    `;
    modal.classList.add('active');

    // 啟動漸進式模擬進度邏輯 (最高停在 95%)
    let progress = 0;
    const progressInterval = setInterval(() => {
        progress += (95 - progress) * 0.08; 
        const text = document.getElementById('diff-progress-text');
        if (text) {
            text.innerText = `🔍 正在抓取設備當前配置，並與快照進行深度比對... (${Math.round(progress)}%)`;
        }
    }, 400);

    try {
        const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass })
        });

        const result = await response.json();
        
        // API 回傳後，清除計時器並將進度填滿 100%
        clearInterval(progressInterval);
        const text = document.getElementById('diff-progress-text');
        if (text) {
            text.innerText = `🔍 正在抓取設備當前配置，並與快照進行深度比對... (100%) - 分析完成！`;
        }

        // 稍微延遲 0.5 秒讓使用者看清楚 100%，再渲染結果
        setTimeout(() => {
            if (result.status === 'success') {
                const commands = result.data.commands;
                let diffHtml = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單：</div>`;
                
                if (commands.length === 0) {
                    diffHtml += `<div style="color: #27ae60; font-weight: bold; padding: 10px; background: rgba(39, 174, 96, 0.1); border-radius: 5px;">✅ 設備當前狀態與快照完全一致，無需進行任何還原動作！</div>`;
                } else {
                    const safeCommands = JSON.stringify(commands).replace(/"/g, '&quot;');

                    // 2. 更改按鈕顏色：使用亮藍色 (#2980b9) 並加上陰影與邊框，凸顯立體感
                    modalTargetInfo.innerHTML = `
                        正在分析快照差異: ${snapshotName}
                        <span style="display: inline-flex; gap: 8px; margin-left: 35px; vertical-align: middle;">
                            <button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button>
                            <button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button>
                        </span>
                    `;

                    // 動態插入確認按鈕到 modal-action-container
                    const actionContainer = document.getElementById('modal-action-container');
                    if (actionContainer) {
                        const oldConfirmBtn = document.getElementById('btn-confirm-restore');
                        if (oldConfirmBtn) oldConfirmBtn.remove();
                        
                        const confirmBtn = document.createElement('button');
                        confirmBtn.id = 'btn-confirm-restore';
                        confirmBtn.className = 'btn-modern btn-save';
                        confirmBtn.style.backgroundColor = '#e74c3c';
                        confirmBtn.style.padding = '6px 12px';
                        confirmBtn.style.fontSize = '13px';
                        confirmBtn.innerHTML = '⚠️ 確認無誤，立即寫入設備並 Commit';
                        confirmBtn.onclick = () => executeSmartRestore(snapshotId, snapshotName, commands);
                        
                        actionContainer.insertBefore(confirmBtn, document.getElementById('btn-modal-close'));
                    }

                    diffHtml += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`;
                    commands.forEach(cmd => {
                        if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`;
                        else diffHtml += `<span style="color: #2ecc71;">+ ${cmd}</span>\n`;
                    });
                    diffHtml += `</pre>`;

                    diffHtml += `<pre id="view-cli" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: none; color: #ecf0f1;">`;
                    commands.forEach(cmd => { diffHtml += `${cmd}\n`; });
                    diffHtml += `<span style="color: #f39c12;">commit</span>\n`;
                    diffHtml += `</pre>`;
                }
                modalOutput.innerHTML = diffHtml;
            } else {
                modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗：\n${result.message}</span>`;
            }
        }, 500);

    } catch (error) {
        clearInterval(progressInterval);
        modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤：無法連線至後端伺服器。\n${error.message}</span>`;
    }
}

// 6. 真正執行差異還原 (串接後端 Streaming API)
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
    if (!confirm(`⚠️ 警告：即將將 ${commands.length} 條指令寫入設備並 Commit！\n確定要繼續嗎？`)) return;

    // 1. 優雅地將按鈕反灰 (Disabled)，包含關閉按鈕
    const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
    btnIds.forEach(id => {
        const btn = document.getElementById(id);
        if (btn) {
            btn.disabled = true;
            btn.style.opacity = '0.5';
            btn.style.cursor = 'not-allowed';
            btn.style.pointerEvents = 'none';
            if (id === 'btn-confirm-restore') {
                btn.innerText = '⏳ 正在寫入設備中...';
                btn.style.backgroundColor = '#7f8c8d';
            }
        }
    });

    const connInfo = getGlobalConnectionInfo();
    const modalOutput = document.getElementById('modalOutput');
    
    // 2. 建立即時 Log 視窗 UI (極致滿版，交由外層 modal-body 控制捲軸)
    modalOutput.innerHTML = `
        <div id="restore-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; box-sizing: border-box;">
            <div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入還原指令，請勿關閉視窗...</div>
            <div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
        </div>
    `;
    const logContainer = document.getElementById('restore-log-container');

    try {
        const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
        });

        const reader = response.body.getReader();
        const decoder = new TextDecoder("utf-8");
        let buffer = "";

        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);
                    const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
                    
                    if (data.status === 'progress') {
                        logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
                    } else if (data.status === 'success') {
                        let cleanOutput = data.output || '無';
                        cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();

                        logContainer.innerHTML += `
                            <div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 15px;">[${timeStr}] ${data.message}</div>
                            <div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳：<br><span style="color: #bdc3c7;">${cleanOutput}</span></div>
                        `;
                        document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
                        
                        // 成功後，隱藏確認按鈕，並恢復關閉按鈕
                        const confirmBtn = document.getElementById('btn-confirm-restore');
                        if (confirmBtn) confirmBtn.style.display = 'none';
                        
                        const closeBtn = document.getElementById('btn-modal-close');
                        if (closeBtn) {
                            closeBtn.disabled = false;
                            closeBtn.style.opacity = '1';
                            closeBtn.style.cursor = 'pointer';
                            closeBtn.style.pointerEvents = 'auto';
                        }
                    } else if (data.status === 'error') {
                        logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
                        if (data.output) {
                            logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
                        }
                        restoreButtonsState(btnIds);
                    }
                    
                    logContainer.scrollTop = logContainer.scrollHeight;
                } catch (e) {
                    console.warn("解析串流 JSON 失敗:", line);
                }
            }
        }
    } catch (error) {
        logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">❌ 系統錯誤：無法連線至後端伺服器。<br>${error.message}</div>`;
        restoreButtonsState(btnIds);
    }
};

// 輔助函數：恢復按鈕狀態 (請放在 executeSmartRestore 下方)
function restoreButtonsState(btnIds) {
    btnIds.forEach(id => {
        const btn = document.getElementById(id);
        if (btn) {
            btn.disabled = false;
            btn.style.opacity = '1';
            btn.style.cursor = 'pointer';
            btn.style.pointerEvents = 'auto';
            if (id === 'btn-confirm-restore') {
                btn.innerText = '⚠️ 確認無誤，重新嘗試寫入';
                btn.style.backgroundColor = '#e74c3c';
            }
        }
    });
}

// ============================================================================
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
// ============================================================================

// 開啟共用輸出彈出視窗
function openModal(targetInfo) {
    document.getElementById('outputModal').classList.add('active');
    document.getElementById('modalTargetInfo').textContent = targetInfo ? `(${targetInfo})` : '';
    document.body.style.overflow = 'hidden';
}

// 關閉共用輸出彈出視窗
function closeModal() {
    document.getElementById('outputModal').classList.remove('active');
    document.body.style.overflow = '';
    
    // 清除動態加入的確認按鈕，避免污染下次開啟
    const confirmBtn = document.getElementById('btn-confirm-restore');
    if (confirmBtn) confirmBtn.remove();
}

// 網頁載入時，檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
document.addEventListener("DOMContentLoaded", checkScanButtonState);

// ==========================================
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
// ==========================================
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];

// 獨立拉出功能解鎖函式，供初始化與驗證成功後呼叫
function unlockAdminFeatures() {
    // 1. 解鎖全域快取掃描按鈕
    ADMIN_BTN_IDS.forEach(id => {
        const btn = document.getElementById(id);
        if (btn) btn.style.display = 'inline-block';
    });
    
    // 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
    document.querySelectorAll('.debug-preview-btn').forEach(btn => {
        btn.style.display = 'inline-block';
    });
    
    // 🌟 3. 新增：解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
    document.querySelectorAll('.admin-only-action').forEach(btn => {
        btn.style.display = 'inline-block';
    });
}

document.addEventListener('DOMContentLoaded', () => {
    const appTitle = document.getElementById('app-title');

    // 🌟 1. 頁面初始化檢查 (依賴 sessionStorage，關閉網頁即失效)
    if (sessionStorage.getItem('godModeUnlocked') === 'true') {
        unlockAdminFeatures();
    }

    // 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門

    // 🌟 3. 連點標題觸發機制
    let clickCount = 0;
    let clickTimer = null;
    if (appTitle) {
        appTitle.addEventListener('click', () => {
            // 已解鎖則忽略
            if (sessionStorage.getItem('godModeUnlocked') === 'true') return;

            clickCount++;
            if (clickCount === 5) {
                openGodModeModal(); // 喚出密碼對話框
                clickCount = 0; 
            }
            clearTimeout(clickTimer);
            clickTimer = setTimeout(() => { clickCount = 0; }, 1000); 
        });
    }

    // 🌟 4. 綁定密碼框的 Enter 快捷鍵
    const pwInput = document.getElementById('godModePassword');
    if (pwInput) {
        pwInput.addEventListener('keypress', function(e) {
            if (e.key === 'Enter') window.verifyGodMode();
        });
    }
});

// 🌟 Modal 操作與 API 驗證邏輯
window.openGodModeModal = function() {
    const modal = document.getElementById('godModeModal');
    const input = document.getElementById('godModePassword');
    const errorSpan = document.getElementById('god-mode-error');
    
    errorSpan.textContent = '';
    input.value = '';
    input.classList.remove('shake-animation');
    
    modal.classList.add('active');
    setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦
};

window.closeGodModeModal = function() {
    document.getElementById('godModeModal').classList.remove('active');
};

window.verifyGodMode = async function() {
    const input = document.getElementById('godModePassword');
    const errorSpan = document.getElementById('god-mode-error');
    const btn = document.getElementById('btn-unlock-godmode');
    const pwd = input.value.trim();

    if (!pwd) {
        errorSpan.textContent = '密碼不可為空！';
        triggerShake(input);
        return;
    }

    btn.disabled = true;
    btn.innerHTML = '⏳ 驗證中...';
    errorSpan.textContent = '';
    input.classList.remove('shake-animation');

    try {
        await apiVerifyGodMode(pwd);
        
        // 驗證成功
        sessionStorage.setItem('godModeUnlocked', 'true');
        closeGodModeModal();
        alert('🔓 密碼正確！已解鎖系統維護者模式。');
        unlockAdminFeatures();
        
    } catch (err) {
        // 驗證失敗
        errorSpan.textContent = '❌ 密碼錯誤，請重新輸入';
        triggerShake(input);
    } finally {
        btn.disabled = false;
        btn.innerHTML = '🚀 解鎖';
    }
};

function triggerShake(element) {
    element.classList.remove('shake-animation');
    void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發
    element.classList.add('shake-animation');
    element.focus();
}

// 統一控制 4 顆按鈕的狀態
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
    ADMIN_BTN_IDS.forEach(id => {
        const btn = document.getElementById(id);
        if (btn) {
            btn.disabled = isBusy;
            if (isBusy) {
                btn.classList.add('is-busy');
                if (id.includes('scan')) btn.innerText = text;
            } else {
                btn.classList.remove('is-busy');
                // 恢復原本的文字
                if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失";
                if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失";
            }
        }
    });
}

function checkScanButtonState() {
    const btn = document.getElementById('btn-scan-visible');
    if (!btn) return;
    const scanLockUntil = localStorage.getItem('scanLockUntil');
    if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
        lockScanButton(parseInt(scanLockUntil) - Date.now());
    }
}

function lockScanButton(durationMs) {
    setAdminButtonsState(true, "⏳ 背景掃描執行中...");
    setTimeout(() => {
        const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
        const treeContainer = document.getElementById(`tree-container-${currentMode}`);
        if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) {
            enableGlobalScanButton();
        } else {
            setAdminButtonsState(true, "⏳ 請先載入樹狀圖");
        }
        localStorage.removeItem('scanLockUntil');
    }, durationMs);
}

function enableGlobalScanButton() {
    const scanLockUntil = localStorage.getItem('scanLockUntil');
    if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) {
        setAdminButtonsState(false);
    }
}


// ============================================================================
// 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫)
// ============================================================================

// --- 終端機綁定 ---
window.connectWebSocket = () => connectWebSocket(resetAllManagerData);
window.disconnectWebSocket = disconnectWebSocket;
window.injectCommand = injectCommand;
window.downloadTerminal = downloadTerminal;

// --- 頁籤與任務切換 ---
window.switchTab = switchTab;
window.switchQueryTask = switchQueryTask;
window.switchConfigTask = switchConfigTask;
window.startConfigTask = startConfigTask;
window.toggleQueryInputs = toggleQueryInputs;

// --- 查詢與 MAC Domain 精靈 ---
window.executeQuery = executeQuery;
window.fetchMacDomainConfig = fetchMacDomainConfig;
window.revertFormChanges = revertFormChanges;
window.toggleGroupVisibility = toggleGroupVisibility;
window.loadDsGroupData = loadDsGroupData;
window.loadUsGroupData = loadUsGroupData;
window.generateMacDomainCLI = generateMacDomainCLI;
window.executeBondingConfig = executeBondingConfig;
window.loadMacDomainList = loadMacDomainList;

// --- 系統設定與快取掃描 ---
window.openSystemSettings = openSystemSettings;
window.loadRealConfigForFilters = loadRealConfigForFilters;
window.saveSystemSettings = saveSystemSettings;
window.scanVisibleMissingOptions = scanVisibleMissingOptions;
window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; 

// --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll;
window.collapseAll = collapseAll;
window.startEditFolder = startEditFolder;
window.startEditLeaf = startEditLeaf;
window.cancelEditFolder = cancelEditFolder;
window.cancelEditLeaf = cancelEditLeaf;
window.previewFolderCLI = previewFolderCLI;
window.previewLeafCLI = previewLeafCLI;
window.hideSideCLI = hideSideCLI;
window.executeSideCLI = executeSideCLI;

// --- 其他 UI 輔助 ---
window.toggleChildCheckboxes = toggleChildCheckboxes;
window.updateParentCheckboxState = updateParentCheckboxState;
window.closeModal = closeModal;

// --- 備份與還原 ---
window.createSnapshot = createSnapshot;
window.loadBackupHistory = loadBackupHistory;
window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup; 
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
window.changeLogLevel = changeLogLevel; // 🌟 新增這行，統一在這裡綁定！

================================================================================
FILE: static/terminal.js
================================================================================
// --- static/terminal.js ---

// 1. 從剛剛建立的 utils.js 引入工具
import { getGlobalConnectionInfo } from './utils.js';

// 2. 宣告終端機專用的變數
let term, fitAddon, ws;
export let isConnected = false; 

// 3. 終端機文字上色 (內部使用，不需要 export)
function colorizeTerminalStream(text) {
    let res = text;
    
    // 1. IP 地址：改為「柔和冰藍色」 (移除 1; 粗體)
    res = res.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g, '\x1b[38;5;111m$1\x1b[0m');
    
    // 2. MAC 地址：改為「柔和薰衣草紫」 (移除 1; 粗體)
    res = res.replace(/\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b/g, '\x1b[38;5;140m$1\x1b[0m');
    
    // 3. 正常狀態：維持「柔和薄荷綠」
    res = res.replace(/\b([a-z]-online|online|init\([^)]+\)|up|active|success|yes)\b/gi, '\x1b[38;5;114m$1\x1b[0m');
    
    // 4. 異常狀態：維持「柔和珊瑚紅」
    res = res.replace(/\b(offline|down|admin-down|error|fail|failed|shutdown|reject|invalid|no)\b/gi, '\x1b[38;5;204m$1\x1b[0m');
    
    // 5. 介面名稱 (Cable/RPD等)：改為「柔和奶油黃」 (原本是 \x1b[33m)
    res = res.replace(/\b(Cable|GigabitEthernet|TenGigabitEthernet|Upstream|Downstream|Mac-domain|bonding-group|rpd)\b/gi, '\x1b[38;5;179m$1\x1b[0m');
    
    return res;
}

// 4. 匯出 (export) 所有需要被外部呼叫的函數
export function initTerminal() {
    term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 });
    fitAddon = new FitAddon.FitAddon();
    term.loadAddon(fitAddon);
    term.open(document.getElementById('terminal-container'));
    fitAddon.fit();
    term.writeln('Welcome to Harmonic cOS Web Terminal.');
    term.writeln('Please confirm the target device above and click "連線至 CMTS"... \r\n');
    
    term.onData((data) => { 
        if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); 
    });
}

export function connectWebSocket(resetCallback) {
    if (ws && ws.readyState === WebSocket.OPEN) return;
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo) return;

    if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數

    term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
    
    ws = new WebSocket(wsUrl);

    ws.onopen = () => {
        isConnected = true; 
        const btnLoadTask = document.getElementById('btn-load-task');
        if (btnLoadTask) {
            btnLoadTask.disabled = false;
            btnLoadTask.style.backgroundColor = '#c0392b';
            btnLoadTask.style.cursor = 'pointer';
        }
        document.getElementById('wsStatus').textContent = `狀態：已連線`;
        document.getElementById('wsStatus').style.color = '#27ae60';
        document.getElementById('btnConnect').style.display = 'none';
        document.getElementById('btnDisconnect').style.display = 'inline-block';
        document.getElementById('cmtsHost').disabled = true;
        document.getElementById('cmtsUser').disabled = true;
        document.getElementById('cmtsPass').disabled = true;
        term.focus();

        // 🌟 新增：連線成功後，自動觸發一次查詢任務的 change 事件，讓背景預載 Datalist
        const queryTaskSelect = document.getElementById('queryTask');
        if (queryTaskSelect) {
            queryTaskSelect.dispatchEvent(new Event('change'));
        }
    };

    ws.onmessage = (event) => {
        term.write(colorizeTerminalStream(event.data));
    };

    ws.onclose = () => {
        isConnected = false; 
        const btnLoadTask = document.getElementById('btn-load-task');
        if (btnLoadTask) {
            btnLoadTask.disabled = true;
            btnLoadTask.style.backgroundColor = '#95a5a6';
            btnLoadTask.style.cursor = 'not-allowed';
        }
        document.getElementById('wsStatus').textContent = '狀態：已斷線';
        document.getElementById('wsStatus').style.color = '#c0392b';
        document.getElementById('btnConnect').style.display = 'inline-block';
        document.getElementById('btnDisconnect').style.display = 'none';
        document.getElementById('cmtsHost').disabled = false;
        document.getElementById('cmtsUser').disabled = false;
        document.getElementById('cmtsPass').disabled = false;
        term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');

        if (resetCallback) resetCallback();
    };
}

export function disconnectWebSocket() { if (ws) ws.close(); }

export function injectCommand() {
    if (!ws || ws.readyState !== WebSocket.OPEN) return alert("請先點擊「連線至 CMTS」！");
    const cmd = document.getElementById('fixedCmd').value;
    ws.send(cmd + '\r');
    term.focus();
}

export function getTerminalText() {
    if (!term) return '';
    try {
        if (term.hasSelection()) return term.getSelection();
        const buffer = term.buffer.active;
        let text = '';
        for (let i = 0; i < buffer.length; i++) {
            const line = buffer.getLine(i);
            if (line) text += line.translateToString(true) + '\n';
        }
        return text;
    } catch (err) {
        console.error("讀取終端機文字失敗:", err);
        return '';
    }
}

export function downloadTerminal() {
    const text = getTerminalText();
    if (!text.trim()) return alert("沒有內容可下載！");
    try {
        const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
        a.download = `cmts_terminal_log_${timestamp}.txt`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    } catch (err) {
        console.error("下載失敗:", err);
    }
}


================================================================================
FILE: static/style.css
================================================================================
/* --- static/style.css --- */

/* =========================================
   全域按鈕色彩系統 (Modern Enterprise Vibe)
   ========================================= */
:root {
    /* 基礎設定 */
    --btn-radius: 6px;
    --btn-transition: all 0.2s ease-in-out;

    /* 色彩定義 */
    --color-emerald: #059669;    /* 連線 */
    --color-emerald-hover: #047857;
    
    --color-slate: #64748B;      /* 斷開/中性/取消 */
    --color-slate-hover: #475569;
    
    --color-blue: #2563EB;       /* 載入/查詢 */
    --color-blue-hover: #1D4ED8;
    
    --color-indigo: #4F46E5;     /* 儲存/套用 */
    --color-indigo-hover: #4338CA;
    
    --color-violet: #7C3AED;     /* 掃描/自動化 */
    --color-violet-hover: #6D28D9;
    
    --color-rose: #DC2626;       /* 清除/刪除 */
    --color-rose-hover: #B91C1C;
    
    --color-disabled-bg: #E2E8F0;
    --color-disabled-text: #94A3B8;
    --color-busy-bg: #F59E0B;    /* 忙碌中(橘黃) */
}

/* 基礎按鈕共用樣式 */
.btn-modern {
    padding: 8px 16px;
    font-size: 14px;
    font-weight: 500;
    border: none;
    border-radius: var(--btn-radius);
    color: #FFFFFF;
    cursor: pointer;
    transition: var(--btn-transition);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

/* 禁用狀態 (所有按鈕共用) */
.btn-modern:disabled {
    background-color: var(--color-disabled-bg) !important;
    color: var(--color-disabled-text) !important;
    cursor: not-allowed !important;
    box-shadow: none;
}

/* 各別語意 Class */
.btn-connect { background-color: var(--color-emerald); }
.btn-connect:hover:not(:disabled) { background-color: var(--color-emerald-hover); }

.btn-disconnect { background-color: var(--color-slate); }
.btn-disconnect:hover:not(:disabled) { background-color: var(--color-slate-hover); }

.btn-load { background-color: var(--color-blue); }
.btn-load:hover:not(:disabled) { background-color: var(--color-blue-hover); }

.btn-save { background-color: var(--color-indigo); }
.btn-save:hover:not(:disabled) { background-color: var(--color-indigo-hover); }

.btn-scan { background-color: var(--color-violet); }
.btn-scan:hover:not(:disabled) { background-color: var(--color-violet-hover); }

.btn-clear { background-color: var(--color-rose); }
.btn-clear:hover:not(:disabled) { background-color: var(--color-rose-hover); }

/* 特殊忙碌狀態 (給掃描或載入時使用) */
.btn-modern.is-busy:disabled {
    background-color: var(--color-busy-bg) !important;
    color: #FFFFFF !important;
}

/* 1. 基礎滿版佈局 (全域 80% 縮放感) */
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 15px 20px; box-sizing: border-box; background-color: #f5f7fa; height: 100vh; display: flex; flex-direction: column; font-size: 14px; }
h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-size: 24px; }

/* 2. 全域設備設定區塊 */
.global-settings { background-color: #e8ecef; padding: 8px 15px; border-radius: 6px; border: 1px solid #d1d8dd; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-shrink: 0; flex-wrap: wrap; font-size: 13px; }

/* 3. 頁籤樣式 (🌟 修復 Hover 狀態，確保字體清晰) */
.tabs { display: flex; margin-bottom: 12px; border-bottom: 2px solid #ddd; flex-shrink: 0; }
.tab-btn { padding: 8px 18px; cursor: pointer; background: transparent; border: none; font-size: 15px; font-weight: bold; color: #7f8c8d; transition: all 0.2s ease; border-radius: 6px 6px 0 0; }
.tab-btn:hover { color: #2c3e50; background-color: #e2e8f0; } /* 滑鼠移入時：深色字+淺灰底 */
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; background-color: transparent; }

/* 4. 內容區塊 */
.tab-content { display: none; background: white; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; box-sizing: border-box; max-width: 100%; overflow: hidden; }
.tab-content.active { display: flex; flex-direction: column; }

/* 5. 控制列樣式 (微縮元件) */
.control-group { margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
select, input[type="text"], input[type="password"] { padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
button { padding: 6px 14px; background-color: #2980b9; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 13px; transition: background-color 0.2s; }
button:hover { background-color: #3498db; }

/* 6. 終端機與輸出區塊 */
#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; min-height: 0; overflow: hidden; position: relative; }
.xterm { padding: 10px; height: 100%; box-sizing: border-box; }

/* 7. 專業級表單排版 (🌟 縮小間距，解決空白過多) */
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 12px; }
.task-form { display: none; background: #ffffff; padding: 18px 20px; border-radius: 8px; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px rgba(0,0,0,0.02); }
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }

/* 網格系統：高空間利用率 */
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
.form-row { display: flex; flex-direction: column; gap: 4px; }
.form-row label { font-size: 12px; font-weight: bold; color: #34495e; }
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
.form-row select { padding-right: 30px; text-overflow: ellipsis; } /* 🌟 確保下拉箭頭有足夠空間，不會遮擋文字 */
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }

/* 表單底部操作區 */
.form-actions { display: flex; justify-content: flex-start; align-items: center; gap: 15px; padding-top: 12px; border-top: 1px solid #ecf0f1; }
.form-actions button { padding: 8px 20px; font-size: 14px; min-width: 120px; }

/* 8. 彈出式輸出視窗 (Modal) 樣式 */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; backdrop-filter: blur(3px); }
.modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; }
.modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; }
.modal-overlay.active .modal-container { transform: translateY(0); }
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
/* 1. 移除 Modal 身體的多餘內距，讓內容可以貼齊邊緣 */
.modal-body { 
    flex-grow: 1; 
    padding: 0 !important; /* 🌟 關鍵：移除原本 20px 的留白 */
    background-color: #1e1e1e; 
    display: flex;
    flex-direction: column;
    overflow: hidden; /* 將捲軸交給內層的 terminal 處理 */
}

/* 2. 徹底移除終端機的邊框，並接管滿版空間 */
.readonly-terminal { 
    margin: 0 !important; 
    padding: 15px 20px !important; /* 保留適當的文字呼吸空間，不讓字完全貼死邊緣 */
    color: #e0e0e0; 
    font-family: 'Consolas', 'Monaco', 'Courier New', monospace; 
    font-size: 14px; 
    line-height: 1.5;
    background-color: transparent !important; /* 🌟 關鍵：背景透明，融入 Modal */
    border: none !important;                  /* 🌟 關鍵：拔除所有細邊框 */
    box-shadow: none !important;
    border-radius: 0 !important;
    flex-grow: 1;
    overflow-y: auto; /* 垂直捲動 */
    overflow-x: auto; /* 水平捲動 */
    white-space: pre; /* 強制不折行以支援水平滑動，保護版面 */
    box-sizing: border-box;
}

/* 樹狀圖節點的容器樣式 */
.tree-node-header {
    display: flex;
    align-items: center;
    cursor: pointer;
    padding: 6px 8px;
    user-select: none;
    color: #2c3e50; /* 保持原本的深色文字 */
    transition: background-color 0.2s ease;
    border-radius: 4px;
}

/* 單一設定項目的容器樣式與懸停高光 */
.tree-leaf-node {
    transition: background-color 0.2s ease;
    border-radius: 4px;
}

/* 💡 輕量化懸停效果：改用極淺的灰藍色 */
.tree-node-header:hover {
    background-color: #eef2f5; /* 非常柔和的背景色 */
    /* 這裡不需要再改變文字顏色了，深色字在淺色背景上非常清晰 */
}

.tree-leaf-node:hover {
    background-color: #eef2f5; /* 與目錄相同的淺灰藍色背景 */
}

/* 1. 旋轉小箭頭 (Chevron) */
.tree-chevron {
    width: 16px;
    height: 16px;
    margin-right: 6px;
    /* 內建灰色箭頭 SVG */
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2395a5a6'><path d='M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z'/></svg>");
    background-size: contain;
    background-repeat: no-repeat;
    transition: transform 0.2s ease-in-out; /* 💡 旋轉動畫核心 */
}

/* 展開時：箭頭向下轉 90 度 */
.tree-node-header.is-open .tree-chevron {
    transform: rotate(90deg);
}

/* 2. 資料夾圖示 (預設為關閉狀態) */
.tree-folder-icon {
    width: 18px;
    height: 18px;
    margin-right: 8px;
    /* 內建金色關閉資料夾 SVG */
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f1c40f'><path d='M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z'/></svg>");
    background-size: contain;
    background-repeat: no-repeat;
}

/* 展開時：切換為開啟的資料夾 */
.tree-node-header.is-open .tree-folder-icon {
    /* 內建金色開啟資料夾 SVG */
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f1c40f'><path d='M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.56 8.59 6.51 8 7.46 8H20V6c0-1.1-.9-2-2-2h-8l-2-2H4z'/></svg>");
}

/* 隱藏原生 Checkbox，改為自訂的過濾器樣式 */
.filter-checkbox {
    appearance: none;
    -webkit-appearance: none;
    width: 16px;
    height: 16px;
    border: 2px solid #bdc3c7;
    border-radius: 4px;
    background-color: #fff;
    cursor: pointer;
    position: relative;
    display: inline-block;
    vertical-align: middle;
    margin-right: 8px;
    transition: all 0.2s ease;
}

/* 懸停時提示顏色 */
.filter-checkbox:hover {
    border-color: #e74c3c;
}

/* 勾選時背景變紅，並顯示 ✖ */
.filter-checkbox:checked {
    background-color: #e74c3c;
    border-color: #e74c3c;
}

.filter-checkbox:checked::after {
    content: '✖';
    color: white;
    font-size: 11px;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    font-weight: bold;
}

/* 針對 SweetAlert2 彈窗內的終端機強制去框 */
.swal2-html-container pre {
    border: none !important;
    background: transparent !important;
    padding: 15px !important;
    margin: 0 !important;
}

@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

/* =========================================
   🚨 密碼輸入錯誤震動動畫 (Shake Effect)
   ========================================= */
@keyframes shake {
    0%, 100% { transform: translateX(0); }
    10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
    20%, 40%, 60%, 80% { transform: translateX(6px); }
}

.shake-animation {
    animation: shake 0.4s ease-in-out;
    border-color: #e74c3c !important; /* 震動時外框變紅 */
    background-color: #fdedec !important;
}


================================================================================
FILE: static/edit-mode.js
================================================================================
// --- static/edit-mode.js ---

import { 
    apiAcquireLock, apiReleaseLock, apiSendHeartbeat, 
    apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
    apiSyncLeafOptionsStream 
} from './api.js';
import { getGlobalConnectionInfo } from './utils.js';

// --- 安全跳脫 HTML 特殊字元，防止破版 ---
function escapeHTML(str) {
    if (str === null || str === undefined) return "";
    return String(str)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#39;");
}

// ==========================================
// 1. 全域狀態與鎖定管理 (Lock Management)
// ==========================================
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {}; 

// 🌟 新增：用來防禦「秒按取消」導致的非同步渲染競爭危害
const activeEditSessions = new Set();

export let currentEditPath = null;
export let currentEditElementId = null;
export let currentEditIsSuccess = false;
export let currentEditType = null; 
export let currentEditDiffs = []; 

export async function releaseAllLocks() {
    const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
    
    // 🌟 1. 檢查當前是否處於 God Mode 狀態
    const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
    
    // 🌟 2. 只要觸發閒置，立刻無條件清除 God Mode 權限
    sessionStorage.removeItem('godModeUnlocked');

    if (keysToRelease.length === 0) {
        // 如果沒有正在編輯的節點，但剛剛是 God Mode，依然要登出並重整
        if (wasGodModeUnlocked) {
            alert("您已閒置超過 5 分鐘，系統已自動為您登出進階維護者模式。");
            location.reload();
        }
        return;
    }

    const releasePromises = keysToRelease.map(key => {
        const [host, path] = key.split('@@');
        clearInterval(ACTIVE_HEARTBEATS[key]);
        delete ACTIVE_HEARTBEATS[key];
        return apiReleaseLock(path, SESSION_USER_ID, host)
            .catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
    });

    await Promise.all(releasePromises);
    alert("您已閒置超過 5 分鐘，系統已自動釋放編輯鎖定並登出進階模式。");
    location.reload(); // 重整網頁，確保所有 UI 恢復未解鎖狀態
}

async function sendHeartbeat(path, host, intervalId, lockKey) {
    try {
        const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
        if (!response.ok) {
            console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
            if (response.status === 404) {
                clearInterval(intervalId);
                if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
                    delete ACTIVE_HEARTBEATS[lockKey];
                }
            }
        }
    } catch (error) {
        console.error(`心跳請求發生錯誤 (${path}):`, error);
    }
}

// ==========================================
// 2. 編輯模式啟動與取消 (Folder & Leaf)
// ==========================================

export async function startEditFolder(path, elementId) {
    // 🚨 防護 1：防止連點或重複執行
    if (activeEditSessions.has(elementId)) return;
    activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖

    const connInfo = getGlobalConnectionInfo();
    const username = connInfo ? connInfo.user : 'admin';
    const host = connInfo ? connInfo.host : ''; 
    const lockKey = `${host}@@${path}`; 

    const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';

    // 立即讓按鈕呈現讀取中，防止使用者焦慮連點
    const editBtn = document.getElementById(`edit-btn-${elementId}`);
    if (editBtn) {
        editBtn.style.pointerEvents = 'none';
        editBtn.innerText = "⏳";
    }

    try {
        const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
        
        // 🚨 防護 2：如果在等待後端上鎖的期間，使用者已經按了取消
        if (!activeEditSessions.has(elementId)) {
            if (result.status === 200 || (result.data && result.data.status === 'success')) {
                // 🌟 補上防閃爍冷卻，防止輪詢抓到時間差
                if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
                window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
                apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去
            }
            if (editBtn) {
                editBtn.style.pointerEvents = 'auto';
                editBtn.style.opacity = '0.3';
                editBtn.title = "鎖定並編輯此項目";
                editBtn.innerText = "✏️";
            }
            return; // 立即中斷，絕不觸碰 UI
        }

        if (result.status === 409) {
            activeEditSessions.delete(elementId);
            if (editBtn) {
                editBtn.style.pointerEvents = 'auto';
                editBtn.innerText = "✏️";
            }
            return alert(`⚠️ ${result.data.detail}`); 
        }
        
        if (result.data.status === 'success') {
            if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
            let intervalId;
            intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
            ACTIVE_HEARTBEATS[lockKey] = intervalId;
            
            // 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
            if (!window.globalActiveLocks) window.globalActiveLocks = {};
            if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
            window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
            
            // 🌟 確保不在冷卻名單中 (防止手速過快)
            if (window.recentlyReleasedLocks) {
                delete window.recentlyReleasedLocks[`${host}@@${path}`];
            }
            
            const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
            // 🌟 擴大選擇器：連同所有子節點一起秒級鎖定
            document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
                if (btn.id !== `edit-btn-${elementId}`) {
                    btn.style.pointerEvents = 'none';
                    btn.style.opacity = '0.2';
                    
                    const btnPath = btn.getAttribute('data-path');
                    if (btnPath === path) {
                        btn.title = `🔒 您正在另一個視圖編輯此項目`;
                    } else {
                        btn.title = `🔒 父層級已被鎖定，無法編輯此項目`;
                    }
                    btn.innerText = "🔒";
                }
            });
            
            if (editBtn) editBtn.style.display = 'none';
            
            const actionBtn = document.getElementById(`actions-${elementId}`);
            if (actionBtn) actionBtn.style.display = 'inline-block';
            
            const detailsEl = document.getElementById(`details-${elementId}`);
            if (detailsEl) detailsEl.open = true; 

            const contentDiv = document.getElementById(`content-${elementId}`);
            if (!contentDiv) {
                console.warn(`[防呆警告] 找不到 content-${elementId} 的容器，請略過此資料夾的編輯。`);
                return; 
            }
            const leafContainers = contentDiv.querySelectorAll('.leaf-container');
            
            let optData = {};
            try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}

            // 🚨 防護 3：如果等待 API 期間使用者按了取消，立刻中斷後續渲染
            if (!activeEditSessions.has(elementId)) return;

            const pathsToSync = [];
            const containersArray = Array.from(leafContainers);
            const CHUNK_SIZE = 50; 

            for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
                // 🚨 防護 4：如果分批渲染期間使用者按了取消，立刻中斷
                if (!activeEditSessions.has(elementId)) return;

                const chunk = containersArray.slice(i, i + CHUNK_SIZE);
                
                chunk.forEach(container => {
                    const origVal = container.getAttribute('data-original');
                    const rawPath = container.getAttribute('data-path');
                    const safeOrigVal = escapeHTML(origVal);

                    let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
                    if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
                    let leafCache = optData[cacheKey];

                    if (!leafCache) pathsToSync.push(cacheKey);

                    let inputHtml = "";
                    let hintAttr = "";
                    let hintIcon = "";

                    if (leafCache && leafCache.hint) {
                        const safeHint = leafCache.hint.replace(/"/g, '&quot;');
                        hintAttr = `title="${safeHint}"`;
                        hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
                    }

                    if (leafCache && leafCache.options && leafCache.options.length > 0) {
                        let options = leafCache.options;
                        if (origVal && !options.includes(origVal)) options = [origVal, ...options];

                        inputHtml = `<select class="edit-input" data-path="${rawPath}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
                        options.forEach(opt => {
                            const safeOpt = escapeHTML(opt);
                            const isSelected = (opt === origVal) ? 'selected' : '';
                            inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
                        });
                        inputHtml += `</select>${hintIcon}`;
                    } else {
                        inputHtml = `<input type="text" class="edit-input" data-path="${rawPath}" value="${safeOrigVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
                    }
                    container.innerHTML = inputHtml;
                });
                
                await new Promise(resolve => setTimeout(resolve, 0)); 
            }

            if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
        }
    } catch (error) {
        activeEditSessions.delete(elementId);
        if (editBtn) {
            editBtn.style.pointerEvents = 'auto';
            editBtn.innerText = "✏️";
        }
        alert("❌ 無法連線到鎖定伺服器：" + error.message);
    }
}

export async function startEditLeaf(path, elementId) {
    // 🚨 防護 1：防止連點或重複執行
    if (activeEditSessions.has(elementId)) return;
    activeEditSessions.add(elementId);

    const connInfo = getGlobalConnectionInfo();
    const username = connInfo ? connInfo.user : 'admin';
    const host = connInfo ? connInfo.host : '';
    const lockKey = `${host}@@${path}`;

    const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';

    const editBtn = document.getElementById(`edit-btn-${elementId}`);
    if (editBtn) {
        editBtn.style.pointerEvents = 'none';
        editBtn.innerText = "⏳";
    }

    try {
        const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
        
        // 🚨 防護 2：如果在等待後端上鎖的期間，使用者已經按了取消
        if (!activeEditSessions.has(elementId)) {
            if (result.status === 200 || (result.data && result.data.status === 'success')) {
                // 🌟 補上防閃爍冷卻，防止輪詢抓到時間差
                if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
                window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
                apiReleaseLock(path, SESSION_USER_ID, host);
            }
            if (editBtn) {
                editBtn.style.pointerEvents = 'auto';
                editBtn.style.opacity = '0.3';
                editBtn.title = "鎖定並編輯此項目";
                editBtn.innerText = "✏️";
            }
            return;
        }

        if (result.status === 409) {
            activeEditSessions.delete(elementId);
            if (editBtn) {
                editBtn.style.pointerEvents = 'auto';
                editBtn.innerText = "✏️";
            }
            return alert(`⚠️ ${result.data.detail}`); 
        }
        
        if (result.data.status === 'success') {
            if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
            let intervalId;
            intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
            ACTIVE_HEARTBEATS[lockKey] = intervalId;
            
            if (!window.globalActiveLocks) window.globalActiveLocks = {};
            if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
            window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
            
            if (window.recentlyReleasedLocks) {
                delete window.recentlyReleasedLocks[`${host}@@${path}`];
            }
            
            const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
            document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
                if (btn.id !== `edit-btn-${elementId}`) {
                    btn.style.pointerEvents = 'none';
                    btn.style.opacity = '0.2';
                    btn.title = `🔒 您正在另一個視圖編輯此項目`;
                    btn.innerText = "🔒";
                }
            });
            
            if (editBtn) editBtn.style.display = 'none';
            document.getElementById(`actions-${elementId}`).style.display = 'inline-block';

            const container = document.getElementById(`container-${elementId}`);
            const origVal = container.getAttribute('data-original');
            const safeOrigVal = escapeHTML(origVal);
            
            // 🌟 修正：統一為標準橘色並加粗 (原為 #e67e22)
            container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
            
            try {
                let optData = await apiGetLeafOptions(host, currentMode);
                let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
                if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
                let leafCache = optData[cacheKey];

                if (!leafCache) {
                    apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
                    let found = false;
                    for (let i = 0; i < 10; i++) {
                        if (!activeEditSessions.has(elementId)) return; // 🚨 防護
                        await new Promise(resolve => setTimeout(resolve, 1000));
                        optData = await apiGetLeafOptions(host, currentMode);
                        leafCache = optData[cacheKey];
                        
                        // 🌟 修正：只要取得 hint(說明) 或選項，就立即終止等待，讓 ❓ 提早顯示出來
                        if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
                            found = true; break;
                        }
                    }
                }

                if (!activeEditSessions.has(elementId)) return; // 🚨 防護

                let inputHtml = "";
                let hintAttr = "";
                let hintIcon = "";

                if (leafCache && leafCache.hint) {
                    const safeHint = leafCache.hint.replace(/"/g, '&quot;');
                    hintAttr = `title="${safeHint}"`;
                    hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
                }

                if (leafCache && leafCache.options && leafCache.options.length > 0) {
                    let options = leafCache.options;
                    if (origVal && !options.includes(origVal)) options = [origVal, ...options];

                    inputHtml = `<select class="edit-input" data-path="${path}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
                    options.forEach(opt => {
                        const safeOpt = escapeHTML(opt);
                        const isSelected = (opt === origVal) ? 'selected' : '';
                        inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
                    });
                    inputHtml += `</select>${hintIcon}`;
                } else {
                    inputHtml = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
                }
                container.innerHTML = inputHtml;
            } catch (e) {
                if (!activeEditSessions.has(elementId)) return; // 🚨 防護
                container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
            }
        }
    } catch (error) {
        activeEditSessions.delete(elementId);
        if (editBtn) {
            editBtn.style.pointerEvents = 'auto';
            editBtn.innerText = "✏️";
        }
        alert("❌ 無法連線到鎖定伺服器：" + error.message);
    }
}

export async function cancelEditFolder(path, elementId) {
    activeEditSessions.delete(elementId); // 🚨 防護：註銷編輯狀態，強制中斷背景渲染迴圈

    const connInfo = getGlobalConnectionInfo();
    const host = connInfo ? connInfo.host : '';
    const lockKey = `${host}@@${path}`;

    if (ACTIVE_HEARTBEATS[lockKey]) {
        clearInterval(ACTIVE_HEARTBEATS[lockKey]);
        delete ACTIVE_HEARTBEATS[lockKey];
    }
    
    // 🌟 關鍵修復：寫入防閃爍冷卻表必須在 await 之前！防止 API 延遲期間被輪詢重新上鎖
    if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
    window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();

    try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}

    // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
    if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
        delete window.globalActiveLocks[host][path];
    }
    const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    // 🌟 擴大選擇器：連同所有子節點一起秒級解除鎖定
    document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
        if (btn.id !== `edit-btn-${elementId}`) {
            const btnPath = btn.getAttribute('data-path');
            let stillLockedByOther = false;
            
            // 確保解除鎖定時，不會誤解開被其他父節點鎖定的子節點
            if (window.globalActiveLocks && window.globalActiveLocks[host]) {
                const hostLocks = window.globalActiveLocks[host];
                if (hostLocks[btnPath]) {
                    stillLockedByOther = true;
                } else {
                    for (const lockedPath of Object.keys(hostLocks)) {
                        if (btnPath.startsWith(lockedPath + "::")) {
                            stillLockedByOther = true;
                            break;
                        }
                    }
                }
            }
            
            if (!stillLockedByOther) {
                btn.style.pointerEvents = 'auto';
                btn.style.opacity = '0.3';
                btn.title = "鎖定並編輯此項目";
                btn.innerText = "✏️";
            }
        }
    });
    
    const editBtn = document.getElementById(`edit-btn-${elementId}`);
    if (editBtn) {
        editBtn.style.display = 'inline-block';
        editBtn.style.pointerEvents = 'auto';
        editBtn.style.opacity = '0.3';
        editBtn.title = "鎖定並編輯此項目";
        editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
    }
    
    const actionBtns = document.getElementById(`actions-${elementId}`);
    if (actionBtns) actionBtns.style.display = 'none';

    const contentDiv = document.getElementById(`content-${elementId}`);
    const leafContainers = contentDiv.querySelectorAll('.leaf-container');
    leafContainers.forEach(container => {
        const origVal = container.getAttribute('data-original');
        const safeOrigVal = escapeHTML(origVal);
        container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
    });

    // 🌟 聯動防呆機制：如果右側預覽視窗正在顯示這個節點的指令，連帶關閉它，防止無鎖寫入
    if (currentEditElementId === elementId) {
        document.querySelectorAll('.tree-view-instance').forEach(pane => {
            pane.style.flex = '1';
            pane.style.width = '100%';
            pane.style.maxWidth = 'none';
        });
        const rightPane = document.getElementById('side-cli-preview');
        if (rightPane) {
            rightPane.style.flex = '1';
            rightPane.style.maxWidth = '50%';
            rightPane.style.width = '';
            rightPane.style.display = 'none';
        }
        document.getElementById('drag-resizer').style.display = 'none';

        currentEditPath = null;
        currentEditElementId = null;
        currentEditIsSuccess = false;
        currentEditType = null;
    }
}

export async function cancelEditLeaf(path, elementId) {
    activeEditSessions.delete(elementId); // 🚨 防護：註銷編輯狀態，強制中斷背景渲染迴圈

    const connInfo = getGlobalConnectionInfo();
    const host = connInfo ? connInfo.host : '';
    const lockKey = `${host}@@${path}`;

    if (ACTIVE_HEARTBEATS[lockKey]) {
        clearInterval(ACTIVE_HEARTBEATS[lockKey]);
        delete ACTIVE_HEARTBEATS[lockKey];
    }
    
    // 🌟 關鍵修復：寫入防閃爍冷卻表必須在 await 之前！防止 API 延遲期間被輪詢重新上鎖
    if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
    window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();

    try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}

    // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
    if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
        delete window.globalActiveLocks[host][path];
    }
    const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
        if (btn.id !== `edit-btn-${elementId}`) {
            btn.style.pointerEvents = 'auto';
            btn.style.opacity = '0.3';
            btn.title = "鎖定並編輯此項目";
            btn.innerText = "✏️";
        }
    });
    
    const editBtn = document.getElementById(`edit-btn-${elementId}`);
    if (editBtn) {
        editBtn.style.display = 'inline-block';
        editBtn.style.pointerEvents = 'auto';
        editBtn.style.opacity = '0.3';
        editBtn.title = "鎖定並編輯此項目";
        editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
    }
    
    const actionBtns = document.getElementById(`actions-${elementId}`);
    if (actionBtns) actionBtns.style.display = 'none';

    const container = document.getElementById(`container-${elementId}`);
    const origVal = container.getAttribute('data-original');
    const safeOrigVal = escapeHTML(origVal);
    container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;

    // 🌟 聯動防呆機制：如果右側預覽視窗正在顯示這個節點的指令，連帶關閉它，防止無鎖寫入
    if (currentEditElementId === elementId) {
        document.querySelectorAll('.tree-view-instance').forEach(pane => {
            pane.style.flex = '1';
            pane.style.width = '100%';
            pane.style.maxWidth = 'none';
        });
        const rightPane = document.getElementById('side-cli-preview');
        if (rightPane) {
            rightPane.style.flex = '1';
            rightPane.style.maxWidth = '50%';
            rightPane.style.width = '';
            rightPane.style.display = 'none';
        }
        document.getElementById('drag-resizer').style.display = 'none';

        currentEditPath = null;
        currentEditElementId = null;
        currentEditIsSuccess = false;
        currentEditType = null;
    }
}

// ==========================================
// 3. CLI 生成與預覽 (CLI Generation)
// ==========================================
function getInterfacesWithAdminState() {
    const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
    const activeContainer = document.getElementById(`tree-container-${currentMode}`);
    if (!activeContainer) return [];
    
    const nodes = activeContainer.querySelectorAll('.leaf-container');
    const interfaces = new Set();
    nodes.forEach(node => {
        const path = node.getAttribute('data-path');
        if (path && path.endsWith('::admin-state')) {
            const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
            interfaces.add(interfacePath);
        }
    });
    return Array.from(interfaces);
}

export async function previewFolderCLI(path, elementId) {
    currentEditPath = path;
    currentEditElementId = elementId;
    currentEditIsSuccess = false;
    currentEditType = 'folder'; 

    const contentDiv = document.getElementById(`content-${elementId}`);
    if (!contentDiv) return;

    const inputs = contentDiv.querySelectorAll('.edit-input');
    const diffs = [];

    inputs.forEach(input => {
        const container = input.closest('.leaf-container');
        if (!container) return;
        const originalVal = container.getAttribute('data-original') || "";
        const newVal = input.value.trim();
        const nodePath = container.getAttribute('data-path');
        
        if (originalVal !== newVal && nodePath) {
            diffs.push({ path: nodePath, old_val: originalVal, new_val: newVal });
        }
    });

    if (diffs.length === 0) return alert("沒有偵測到任何修改，無需生成指令！");
    currentEditDiffs = diffs; 

    try {
        const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
        if (result.status === 'success') {
            showCliPreviewModal(result.data);
        } else {
            alert("CLI 生成失敗: " + result.message);
        }
    } catch (error) {
        console.error("Error generating CLI:", error);
        alert("網路連線錯誤，無法生成 CLI");
    }
}

export async function previewLeafCLI(path, elementId) {
    currentEditPath = path;
    currentEditElementId = elementId;
    currentEditIsSuccess = false;
    currentEditType = 'leaf'; 

    const container = document.getElementById(`container-${elementId}`);
    if (!container) return;

    const input = container.querySelector('.edit-input');
    if (!input) return;

    const originalVal = container.getAttribute('data-original') || "";
    const newVal = input.value.trim();
    if (originalVal === newVal) return alert("沒有偵測到任何修改，無需生成指令！");

    const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
    currentEditDiffs = diffs; 

    try {
        const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
        if (result.status === 'success') {
            showCliPreviewModal(result.data);
        } else {
            alert("CLI 生成失敗: " + result.message);
        }
    } catch (error) {
        alert("網路連線錯誤，無法生成 CLI");
    }
}

function showCliPreviewModal(cliScript) {
    const leftPanes = document.querySelectorAll('.tree-view-instance');
    const resizer = document.getElementById('drag-resizer');
    const rightPane = document.getElementById('side-cli-preview');
    
    document.getElementById('side-pane-title').innerHTML = '⚠️ 即將寫入的指令';
    document.getElementById('side-pane-title').style.color = '#f1c40f';
    document.getElementById('btn-side-cancel').style.display = 'inline-block';
    document.getElementById('btn-side-confirm').style.display = 'inline-block';
    document.getElementById('btn-side-close').style.display = 'none';
    document.getElementById('btn-side-close').textContent = '關閉';
    document.getElementById('side-cli-textarea').style.display = 'block';
    document.getElementById('side-execution-result').style.display = 'none';
    
    const textarea = document.getElementById('side-cli-textarea');
    textarea.value = cliScript;
    
    textarea.readOnly = false; 
    textarea.style.backgroundColor = '#1e1e1e'; 
    textarea.style.color = '#ecf0f1';           
    
    // 🛡️ 安全修改：解除 Flexbox 均分限制，讓 JS 拖拉的 width 生效
    leftPanes.forEach(pane => {
        pane.style.flex = 'none';
        pane.style.maxWidth = 'none';
        pane.style.width = 'calc(50% - 11px)';
    });
    rightPane.style.flex = 'none';
    rightPane.style.maxWidth = 'none';
    rightPane.style.width = 'calc(50% - 11px)';
    
    resizer.style.display = 'flex';
    rightPane.style.display = 'block';
    
    if (!window.isResizerInitialized) {
        initResizer();
        window.isResizerInitialized = true;
    }
}

// ==========================================
// 4. 側邊欄與執行邏輯
// ==========================================
export async function hideSideCLI() {
    // 🌟 完美修復：明確恢復 Flexbox 伸展能力與 100% 寬度
    document.querySelectorAll('.tree-view-instance').forEach(pane => {
        pane.style.flex = '1';       // 恢復 HTML 原本賦予的彈性伸展能力
        pane.style.width = '100%';   // 強制撐滿整個父容器
        pane.style.maxWidth = 'none';
    });
    
    const rightPane = document.getElementById('side-cli-preview');
    if (rightPane) {
        rightPane.style.flex = '1';
        rightPane.style.maxWidth = '50%'; // 恢復 HTML 原本的 50% 限制
        rightPane.style.width = '';
        rightPane.style.display = 'none';
    }
    
    document.getElementById('drag-resizer').style.display = 'none';

    // 以下為原本的取消編輯與還原邏輯，完全保持不變，確保功能安全
    if (currentEditIsSuccess && currentEditElementId && currentEditPath) {
        const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`);
        if (wrapper) {
            const container = document.getElementById(`container-${currentEditElementId}`);
            const input = container ? container.querySelector('.edit-input') : null;
            const newCommand = input ? input.value.trim() : (container ? container.getAttribute('data-original') : '');
            
            if (currentEditType === 'folder') await cancelEditFolder(currentEditPath, currentEditElementId);
            else await cancelEditLeaf(currentEditPath, currentEditElementId);
            
            transformNoToActive(currentEditElementId, newCommand);
        } else {
            if (currentEditType === 'folder') await applyEditFolder(currentEditPath, currentEditElementId);
            else if (currentEditType === 'leaf') await applyEditLeaf(currentEditPath, currentEditElementId);
        }
    }

    currentEditPath = null;
    currentEditElementId = null;
    currentEditIsSuccess = false;
    currentEditType = null;
}

async function applyEditFolder(path, elementId) {
    const contentDiv = document.getElementById(`content-${elementId}`);
    if (contentDiv) {
        const inputs = contentDiv.querySelectorAll('.edit-input');
        inputs.forEach(input => {
            const container = input.closest('.leaf-container');
            if (container) container.setAttribute('data-original', input.value.trim()); 
        });
    }
    await cancelEditFolder(path, elementId);
}

async function applyEditLeaf(path, elementId) {
    const container = document.getElementById(`container-${elementId}`);
    if (container) {
        const input = container.querySelector('.edit-input');
        if (input) container.setAttribute('data-original', input.value.trim()); 
    }
    await cancelEditLeaf(path, elementId);
}

export function executeSideCLI() {
    const finalScript = document.getElementById('side-cli-textarea').value;
    document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
    document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
    document.getElementById('btn-side-cancel').style.display = 'none';
    document.getElementById('btn-side-confirm').style.display = 'none';
    document.getElementById('btn-side-close').style.display = 'none'; 
    document.getElementById('side-cli-textarea').style.display = 'none';
    
    const resultBox = document.getElementById('side-execution-result');
    resultBox.style.display = 'block';
    resultBox.innerHTML = `<span style="color: #ecf0f1;">${finalScript}</span>`;
    
    executeGeneratedCLI(finalScript);
}

async function executeGeneratedCLI(script) {
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo) return;

    const outputEl = document.getElementById('side-execution-result');
    
    // 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格)
    outputEl.innerHTML = `
        <div id="edit-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; margin: 0; box-sizing: border-box;">
            <div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入指令，請勿關閉視窗...</div>
            <div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
        </div>
    `;
    const logContainer = document.getElementById('edit-log-container');
    
    // 🌟 修正 1: 捲動目標必須是真正有捲軸的容器
    const scrollTarget = document.getElementById('side-execution-result'); 

    try {
        const response = await fetch('/api/v1/cmts-config', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ 
                script: script, 
                host: connInfo.host, 
                username: connInfo.user, 
                password: connInfo.pass 
            })
        });

        const reader = response.body.getReader();
        const decoder = new TextDecoder("utf-8");
        let buffer = "";
        let isSuccess = false;

        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);
                    const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
                    
                    if (data.status === 'progress') {
                        logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
                    } else if (data.status === 'success') {
                        isSuccess = true;
                        logContainer.innerHTML += `
                            <div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 14px;">[${timeStr}] ${data.message}</div>
                        `;
                        document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功！正在同步快取...';
                        document.getElementById('side-pane-title').style.color = '#f39c12';
                    } else if (data.status === 'error') {
                        logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
                        if (data.output) {
                            logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
                        }
                        document.getElementById('btn-side-close').style.display = 'inline-block';
                        document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
                        document.getElementById('side-pane-title').style.color = '#ff6b6b';
                    }
                    
                    // 自動捲動到最底部
                    if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
                } catch (e) {
                    console.warn("解析串流 JSON 失敗:", line);
                }
            }
        }

        // 4. 串流結束後，執行快取同步
        if (isSuccess) {
            currentEditIsSuccess = true; 
            
            const pathsToSync = currentEditDiffs.map(d => {
                let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
                if (d.path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + d.old_val;
                return cacheKey;
            });
            const uniquePaths = [...new Set(pathsToSync)];

            try {
                const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
                let isSyncDone = false;

                // 🌟 修正 2: 先啟動 SSE 監聽 (避免錯過 done 事件)
                const streamPromise = apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
                    const titleEl = document.getElementById('side-pane-title');
                    
                    if (data.event === 'done' || data.status === 'done') {
                        isSyncDone = true;
                        if (titleEl) {
                            titleEl.innerHTML = '✅ 寫入成功！快取同步完成';
                            titleEl.style.color = '#2ecc71';
                        }
                        document.getElementById('btn-side-close').style.display = 'inline-block'; 
                        logContainer.innerHTML += `<br><span style="color: #2ecc71; font-weight: bold;">[系統] 快取同步完成！您可以關閉此面板。</span>`;
                        if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
                        
                    } else if (data.event === 'error' || data.status === 'error') {
                        isSyncDone = true;
                        if (titleEl) {
                            titleEl.innerHTML = '⚠️ 寫入成功 (但同步快取失敗)';
                            titleEl.style.color = '#e74c3c';
                        }
                        document.getElementById('btn-side-close').style.display = 'inline-block'; 
                        logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
                        if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
                    }
                }, currentMode);

                // 🌟 修正 3: 再觸發後端同步任務
                apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);

                // 🌟 修正 4: 加入 8 秒防呆機制，如果後端默默做完沒通知，自動放行
                setTimeout(() => {
                    if (!isSyncDone) {
                        const titleEl = document.getElementById('side-pane-title');
                        if (titleEl) {
                            titleEl.innerHTML = '✅ 寫入成功！(快取同步已於背景完成)';
                            titleEl.style.color = '#2ecc71';
                        }
                        document.getElementById('btn-side-close').style.display = 'inline-block'; 
                        logContainer.innerHTML += `<br><span style="color: #f39c12; font-weight: bold;">[系統] 快取同步已於背景執行，您可以關閉此面板。</span>`;
                        if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
                    }
                }, 8000);

                await streamPromise;

            } catch (syncErr) {
                console.error("同步設定失敗:", syncErr);
                document.getElementById('btn-side-close').style.display = 'inline-block';
            }
        } else {
            currentEditIsSuccess = false; 
        }

    } catch (error) {
        document.getElementById('btn-side-close').style.display = 'inline-block';
        document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤';
        document.getElementById('side-pane-title').style.color = '#ff6b6b';
        logContainer.innerHTML += `<div style="color: #ff6b6b; font-weight: bold; margin-top: 10px;">連線或伺服器錯誤: ${error}</div>`;
    }
}

// ==========================================
// 5. 輔助 UI 函數
// ==========================================
function initResizer() {
    const resizer = document.getElementById('drag-resizer');
    const leftPanes = document.querySelectorAll('.tree-view-instance');
    const rightPane = document.getElementById('side-cli-preview');
    const container = document.getElementById('split-container');
    let isResizing = false;

    resizer.addEventListener('mousedown', (e) => {
        isResizing = true;
        document.body.style.cursor = 'col-resize';
        document.body.style.userSelect = 'none'; 
    });

    document.addEventListener('mousemove', (e) => {
        if (!isResizing) return;
        const containerRect = container.getBoundingClientRect();
        let newLeftWidth = e.clientX - containerRect.left;
        const minWidth = containerRect.width * 0.2;
        const maxWidth = containerRect.width * 0.8;
        if (newLeftWidth < minWidth) newLeftWidth = minWidth;
        if (newLeftWidth > maxWidth) newLeftWidth = maxWidth;
        
        const leftPercentage = (newLeftWidth / containerRect.width) * 100;
        const rightPercentage = 100 - leftPercentage;
        leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
        rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
    });

    document.addEventListener('mouseup', () => {
        if (isResizing) {
            isResizing = false;
            document.body.style.cursor = 'default';
            document.body.style.userSelect = 'auto'; 
        }
    });
}

function highlightTerminalOutput(text) {
    if (!text) return '';
    const lines = text.split('\n');
    const formattedLines = lines.map(line => {
        if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) {
            return `<span style="color: #ff6b6b; font-weight: bold;">${line}</span>`;
        }
        return line; 
    });
    return formattedLines.join('\n');
}

function transformNoToActive(elementId, newCommand) {
    const wrapper = document.getElementById(`display-wrapper-${elementId}`);
    if (!wrapper) return;

    const parts = newCommand.trim().split(' ');
    const firstWord = parts[0]; 
    const restCommand = parts.slice(1).join(' '); 

    const safeRestCommand = escapeHTML(restCommand);

    const svgEnabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#27ae60"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zM8.29 11.59L7 12.88l4 4 8-8-1.29-1.29L11 14.18z"/></svg>`;
    const iconHtml = `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${svgEnabled}</span>`;

    wrapper.innerHTML = `
        ${iconHtml}
        <b style="color: #2c3e50;">${firstWord}</b> 
        <span class="leaf-container" style="display: inline-flex; align-items: center; min-height: 24px;">
            <span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeRestCommand}</span>
            <span style="font-size: 0.8em; color: #f39c12; margin-left: 10px; border: 1px solid #f39c12; padding: 2px 6px; border-radius: 4px; background-color: #fffdf7;">(已啟用，重整後歸檔)</span>
        </span>
    `;
    
    const actionBtns = document.getElementById(`actions-${elementId}`);
    const editBtn = document.getElementById(`edit-btn-${elementId}`);
    if (actionBtns) actionBtns.style.display = 'none';
    if (editBtn) editBtn.style.display = 'none';
}

// ============================================================================
// 💻 開發者工具：預覽節點完整指令 (唯讀 Debug 模式)
// ============================================================================
export async function previewNodeCLI(btnElement, rootPath) {
    let container = btnElement.closest('details');
    if (container) {
        const elementId = container.id.replace('details-', '');
        if (container.dataset.loaded !== 'true') {
            await window.lazyLoadFolder(elementId, false);
        }
    }
    const targetNode = container || btnElement.closest('.tree-leaf-node');
    if (!targetNode) return;

    const leafNodes = targetNode.querySelectorAll('.leaf-container');
    const interfaceGroups = {};

    leafNodes.forEach(node => {
        const path = node.getAttribute('data-path') || "";
        const val = node.getAttribute('data-original');
        if (!path) return;
        
        const parts = path.split('::');
        const paramName = parts.pop();
        let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');

        if (!interfaceGroups[interfacePath]) {
            interfaceGroups[interfacePath] = [];
        }
        interfaceGroups[interfacePath].push({ param: paramName, val: val });
    });

    if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
        const singleNode = targetNode.querySelector('.leaf-container');
        if (singleNode) {
            const path = singleNode.getAttribute('data-path');
            const val = singleNode.getAttribute('data-original');
            const parts = path.split('::');
            const paramName = parts.pop();
            let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
            interfaceGroups[interfacePath] = [{ param: paramName, val: val }];
        }
    }

    if (Object.keys(interfaceGroups).length === 0) {
        return alert("此節點下沒有任何數值可供預覽！");
    }

    let cliLines = [];
    cliLines.push("! --- Read-Only Configuration Preview ---");
    for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
        cliLines.push(`! Node: ${interfacePath || 'Global'}`);
        params.forEach(({param, val}) => {
            if (/^\[\d+\]$/.test(param)) {
                if (val) {
                    cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
                }
            } else if (param === 'no') {
                cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
            } else {
                let line = interfacePath ? `${interfacePath} ${param}` : param;
                if (val) line += ` ${val}`;
                cliLines.push(line);
            }
        });
        cliLines.push("!");
    }
    const finalScript = cliLines.join('\n');
    
    showReadOnlyCliPreviewModal(finalScript, rootPath);
}

function showReadOnlyCliPreviewModal(cliScript, rootPath) {
    const leftPanes = document.querySelectorAll('.tree-view-instance');
    const resizer = document.getElementById('drag-resizer');
    const rightPane = document.getElementById('side-cli-preview');

    document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
    document.getElementById('side-pane-title').style.color = '#3498db';

    document.getElementById('btn-side-confirm').style.display = 'none';
    document.getElementById('btn-side-cancel').style.display = 'none';
    const closeBtn = document.getElementById('btn-side-close');
    closeBtn.style.display = 'inline-block';
    closeBtn.textContent = '關閉預覽';

    const textarea = document.getElementById('side-cli-textarea');
    textarea.style.display = 'block';
    textarea.value = cliScript;
    textarea.readOnly = true; 
    textarea.style.backgroundColor = '#2c3e50'; 

    document.getElementById('side-execution-result').style.display = 'none';

    // 🛡️ 安全修改：解除 Flexbox 均分限制，讓 JS 拖拉的 width 生效
    leftPanes.forEach(pane => {
        pane.style.flex = 'none';
        pane.style.maxWidth = 'none';
        pane.style.width = 'calc(50% - 11px)';
    });
    if (rightPane) {
        rightPane.style.flex = 'none';
        rightPane.style.maxWidth = 'none';
        rightPane.style.width = 'calc(50% - 11px)';
        rightPane.style.display = 'block';
    }
    
    if (resizer) resizer.style.display = 'flex';
    
    if (!window.isResizerInitialized && typeof initResizer === 'function') {
        initResizer();
        window.isResizerInitialized = true;
    }
}

// --- EOF (檔案結束) ---


================================================================================
FILE: static/mac-domain.js
================================================================================
// --- static/mac-domain.js ---

import { apiGetMacDomainList, apiGetMacDomainConfig, apiExecuteConfig } from './api.js';
import { getGlobalConnectionInfo } from './utils.js';

// 🌟 將原本在 app.js 的全域變數移到這裡封裝
let currentMacDomainData = null;

export function hasMacDomainData() {
    return currentMacDomainData !== null;
}

export function resetMacDomainData() {
    currentMacDomainData = null;
}

export async function loadMacDomainList() {
    const connInfo = getGlobalConnectionInfo();
    if (!connInfo) return;

    const mdInput = document.getElementById('cfgMacDomain');
    const mdDatalist = document.getElementById('mac-domain-list');
    const statusSpan = document.getElementById('fetchStatus');

    // 載入前：鎖定輸入框並更改提示
    mdInput.disabled = true;
    mdInput.value = '';
    mdInput.placeholder = '⏳ 查詢設備中...';
    if (mdDatalist) mdDatalist.innerHTML = '';

    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) {
            // 載入成功：將資料塞入 datalist
            if (mdDatalist) {
                result.data.forEach(md => {
                    const option = document.createElement('option');
                    option.value = md;
                    mdDatalist.appendChild(option);
                });
            }
            mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
            statusSpan.textContent = "✅ 清單抓取成功！請選擇目標並點擊讀取配置。";
            statusSpan.style.color = "#27ae60";
        } else {
            mdInput.placeholder = '❌ 找不到資料';
            statusSpan.textContent = "無法解析清單，請確認設備狀態。";
            statusSpan.style.color = "#e74c3c";
        }
    } catch (error) {
        mdInput.placeholder = '❌ 連線錯誤';
        statusSpan.textContent = "連線失敗：" + error;
        statusSpan.style.color = "#e74c3c";
    } finally {
        // 載入結束：解鎖輸入框
        mdInput.disabled = false;
    }
}

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 = '<option value="_new_">[➕ 新增 DS Group]</option>';
    Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
    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 = '<option value="_new_">[➕ 新增 US Group]</option>';
    Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
    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 = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本，這可能需要幾秒鐘，請勿關閉視窗...</span>\n\n${cliScript}`;
    
    // 鎖定關閉按鈕 (Execution Lock)
    const closeBtn = document.getElementById('btn-modal-close');
    if (closeBtn) {
        closeBtn.disabled = true;
        closeBtn.style.opacity = '0.5';
        closeBtn.style.cursor = 'not-allowed';
        closeBtn.style.pointerEvents = 'none';
    }
    
    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}`;
    } finally {
        // 恢復關閉按鈕
        if (closeBtn) {
            closeBtn.disabled = false;
            closeBtn.style.opacity = '1';
            closeBtn.style.cursor = 'pointer';
            closeBtn.style.pointerEvents = 'auto';
        }
    }
}


================================================================================
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 ---

import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';

// ==========================================
// 🌟 效能終極優化：全域快取與延遲渲染 (Lazy Rendering)
// ==========================================
export const folderDataCache = {};
export const filterFolderCache = {};

// 🧹 [修復 Leak] 徹底清空樹狀圖快取，釋放記憶體
export function clearTreeCache() {
    for (let key in folderDataCache) delete folderDataCache[key];
    for (let key in filterFolderCache) delete filterFolderCache[key];
}

// 魔法函數：當資料夾被點擊展開時，才將 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') {
    const htmlParts = [];
    if (!node || typeof node !== 'object') return htmlParts.join('');

    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 ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';

            // 🌟 將資料存入快取，供延遲渲染使用
            folderDataCache[elementId] = value;

            const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
            const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
            const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
            const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;

            const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
            const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;

            const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
            const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;

            const expandCollapseBtns = `
                <span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
                    <span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')" 
                          style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;" 
                          onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
                        ${expandAllIcon}
                    </span>
                    <span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')" 
                          style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;" 
                          onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
                        ${collapseAllIcon}
                    </span>
                </span>
            `;

            // 🌟 延遲渲染防護：取得當前設備 IP，並檢查此節點是否已被鎖定 (跨設備隔離)
            const currentHost = document.getElementById('cmtsHost')?.value.trim();
            let isLocked = false;
            let lockTitle = "鎖定並編輯此群組";
            let lockText = "✏️";
            let lockOpacity = "0.3";
            let lockPointer = "auto";

            if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
                const hostLocks = window.globalActiveLocks[currentHost];
                
                // 1. 檢查自己是否被鎖定
                if (hostLocks[currentPath]) {
                    const lockInfo = hostLocks[currentPath];
                    if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
                        isLocked = true;
                        lockText = "🔒";
                        lockOpacity = "0.2";
                        lockPointer = "none";
                        lockTitle = lockInfo.user_id !== SESSION_USER_ID 
                            ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` 
                            : `🔒 您正在另一個視圖編輯此項目`;
                    }
                } else {
                    // 2. 檢查父層級是否被鎖定 (Cascading Lock)
                    for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
                        if (currentPath.startsWith(lockedPath + "::")) {
                            isLocked = true;
                            lockText = "🔒";
                            lockOpacity = "0.2";
                            lockPointer = "none";
                            lockTitle = `🔒 父層級已被鎖定，無法編輯此項目`;
                            break;
                        }
                    }
                }
            }

            htmlParts.push(`
                <details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" 
                         data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
                         ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
                    <summary class="tree-folder-header" 
                             title="${cliCommand}"
                             onmouseover="this.style.backgroundColor='#f1f2f6'" 
                             onmouseout="this.style.backgroundColor='transparent'" 
                             style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
                        
                        <span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
                            <span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
                            <span class="icon-open" style="display: none;">${iconOpen}</span>
                        </span> 
                        <b style="color: #2c3e50;">${key}</b>${folderSuffix}
                        ${expandCollapseBtns}
                        
                        <div style="margin-left: auto; display: flex; align-items: center;">
                            <span class="debug-preview-btn admin-only" 
                                  onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')" 
                                  style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;" 
                                  title="[開發者] 預覽此節點下所有指令"
                                  onmouseover="this.style.transform='scale(1.2)'" 
                                  onmouseout="this.style.transform='scale(1)'">
                                  🔍
                            </span>
                            <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')" 
                                  style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;" 
                                  onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
                                ${lockText}
                            </span>
                            <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
                                <button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
                                <button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
                            </span>
                        </div>
                    </summary>
                    <div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
                        <!-- 🌟 延遲渲染：這裡一開始是空的，展開時才會填入 -->
                    </div>
                </details>
            `);
        } else {
            const isVirtualIndex = /^\[\d+\]$/.test(key);
            const isNoCommand = (key === 'no'); 
            const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
            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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
            const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;

            const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
            const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;

            // 🌟 延遲渲染防護：取得當前設備 IP，並檢查此節點是否已被鎖定 (跨設備隔離)
            const currentHost = document.getElementById('cmtsHost')?.value.trim();
            let isLocked = false;
            let lockTitle = "鎖定並編輯此項目";
            let lockText = "✏️";
            let lockOpacity = "0.3";
            let lockPointer = "auto";

            if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
                const hostLocks = window.globalActiveLocks[currentHost];
                
                // 1. 檢查自己是否被鎖定
                if (hostLocks[currentPath]) {
                    const lockInfo = hostLocks[currentPath];
                    if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
                        isLocked = true;
                        lockText = "🔒";
                        lockOpacity = "0.2";
                        lockPointer = "none";
                        lockTitle = lockInfo.user_id !== SESSION_USER_ID 
                            ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` 
                            : `🔒 您正在另一個視圖編輯此項目`;
                    }
                } else {
                    // 2. 檢查父層級是否被鎖定 (Cascading Lock)
                    for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
                        if (currentPath.startsWith(lockedPath + "::")) {
                            isLocked = true;
                            lockText = "🔒";
                            lockOpacity = "0.2";
                            lockPointer = "none";
                            lockTitle = `🔒 父層級已被鎖定，無法編輯此項目`;
                            break;
                        }
                    }
                }
            }
            
            let displayContent = '';

            if (isVirtualIndex) {
                displayContent = `
                    ${iconHtml(svgLeaf)}
                    <span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
                        <span class="leaf-value" style="${valueStyle}">${safeValue}</span>
                    </span>
                `;
           } else if (isNoCommand) {
                displayContent = `
                    <span id="display-wrapper-${elementId}" style="display: inline-flex; align-items: center;">
                        ${iconHtml(svgDisabled)} 
                        <b style="color: #e74c3c;">no:</b> 
                        <span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
                            <span class="leaf-value" style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>
                        </span>
                    </span>
                `;
            } else {
                displayContent = `
                    ${iconHtml(svgLeaf)} <b>${key}</b>: 
                    <span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
                        <span class="leaf-value" style="${valueStyle}">${safeValue}</span>
                    </span>
                `;
            }

            htmlParts.push(`
                <div class="tree-leaf-node" 
                     title="${cliCommand}"
                     onmouseover="this.style.backgroundColor='#f1f2f6'" 
                     onmouseout="this.style.backgroundColor='transparent'" 
                     style="padding: 4px 8px; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; display: flex; align-items: center; border-radius: 4px; transition: background-color 0.2s;">
                    ${displayContent}
                    
                    <div style="margin-left: auto; display: flex; align-items: center;">
                        <span class="debug-preview-btn admin-only" 
                              onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')" 
                              style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;" 
                              title="[開發者] 預覽此節點下所有指令"
                              onmouseover="this.style.transform='scale(1.2)'" 
                              onmouseout="this.style.transform='scale(1)'">
                              🔍
                        </span>
                        <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')" 
                              style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;" 
                              onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
                            ${lockText}
                        </span>
                        <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
                            <button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
                            <button onclick="event.stopPropagation(); event.preventDefault(); cancelEditLeaf('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
                        </span>
                    </div>
                </div>
            `);
        }
    }
    return htmlParts.join('');
}

// ==========================================
// 🌟 核心函數：生成系統設定的過濾樹狀圖 (Lazy 版)
// ==========================================
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
    if (typeof data !== 'object' || data === null) return '';

    const htmlParts = [];
    htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
    
    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 = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
        const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
        const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
        const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
        const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;

        const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
        const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
        const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;

        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 ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';

            const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
            const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;

            const expandCollapseBtns = `
                <span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
                    <span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')" 
                          style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;" 
                          onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
                        ${expandAllIcon}
                    </span>
                    <span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')" 
                          style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;" 
                          onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
                        ${collapseAllIcon}
                    </span>
                </span>
            `;

            // 🌟 加入 lazyLoadFolder 觸發
            htmlParts.push(`
                <details id="details-${elementId}" class="tree-folder" 
                         data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
                         ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
                    <summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
                        <style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
                        ${checkboxHtml}
                        <span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
                            <span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
                            <span class="icon-open" style="display: none;">${iconOpen}</span>
                        </span>
                        <span>${key}${folderSuffix}</span>
                        ${expandCollapseBtns}
                    </summary>
                    <div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
                        <!-- 🌟 延遲渲染 -->
                    </div>
                </details>
            `);
        } else {
            const isVirtualIndex = /^\[\d+\]$/.test(key);
            let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
            const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
            
            const isNoCommand = (key === 'no');

            let valueHtml = `<span style="color: #16a085; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
            let colonHtml = `:`;
            let currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;

            if (isNoCommand) {
                let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
                cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
                
                displayName = `<b style="color: #e74c3c;">no</b>`;
                colonHtml = `:`; 
                valueHtml = `<span style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
                currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
            }

            htmlParts.push(`
                <div class="tree-leaf-node"
                     onmouseover="this.style.backgroundColor='#f1f2f6'" 
                     onmouseout="this.style.backgroundColor='transparent'" 
                     style="padding: 2px 0; color: #34495e; display: flex; align-items: center; margin-top: 2px; border-radius: 4px; transition: background-color 0.2s;"
                     title="${cliCommand}">
                    ${checkboxHtml}
                    <span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0;">
                        ${currentIcon}
                    </span>
                    <span style="margin-right: 5px;">${displayName}${colonHtml}</span>
                    ${valueHtml}
                </div>
            `);
        }
    }
    htmlParts.push('</div>');
    return htmlParts.join('');
}

================================================================================
FILE: routers/backup.py
================================================================================
# --- routers/backup.py ---
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
from logger import get_logger

# 引入 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
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks

logger = get_logger("app.backup")

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):
    async def backup_streamer():
        try:
            # 1. 廣播：正在連線
            yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
            await asyncio.sleep(0.1)

            # 2. 廣播：正在下載
            yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
            raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)

            # 3. 廣播：正在解析
            yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
            parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)

            # 4. 廣播：寫入資料庫
            yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
            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:
                yield json.dumps({"status": "error", "message": "資料庫寫入失敗，請檢查系統日誌"}) + "\n"
                return

            # 5. 廣播：成功
            yield json.dumps({
                "status": "success", 
                "message": f"快照 '{req.snapshot_name}' 建立成功！",
                "backup_id": backup_id,
                "host": req.host
            }) + "\n"

        except Exception as e:
            logger.error(f"❌ 建立快照失敗: {e}")
            yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n"

    return StreamingResponse(backup_streamer(), media_type="application/x-ndjson")

@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():
        # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
        host_lock = cmts_config_locks[req.host]
        
        # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
        if host_lock.locked():
            yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置，請稍後再試。"}) + "\n"
            return
            
        async with host_lock:
            try:
                yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
                
                # 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
                async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
                    async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:

                        async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
                            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()
                                    # 🌟 精準 Prompt 偵測
                                    if prompt_pattern and re.search(prompt_pattern, output):
                                        break
                                except asyncio.TimeoutError:
                                    break
                            return output

                        # 2. 進入設定模式
                        process.stdin.write("config\n")
                        await process.stdin.drain()
                        await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
                        yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"

                        # 3. 逐行寫入並進行 Fail-safe 檢查
                        for cmd in commands:
                            process.stdin.write(f"{cmd}\n")
                            await process.stdin.drain()
                            out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
                            
                            clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()

                            # 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
                            if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
                                # 1. 先通知前端正在撤銷
                                yield json.dumps({
                                    "status": "progress", 
                                    "message": "⚠️ 偵測到無效指令，正在執行 abort 放棄所有變更..."
                                }) + "\n"
                                
                                # 2. 對設備下達 abort 指令
                                process.stdin.write("abort\n")
                                await process.stdin.drain()
                                await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
                                
                                # 3. 回報最終錯誤並關閉連線
                                yield json.dumps({
                                    "status": "error", 
                                    "message": f"❌ 寫入中斷且已安全撤銷！失敗指令: {cmd}", 
                                    "output": clean_out
                                }) + "\n"
                                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, prompt_pattern=r"\(config\)#")

                        # 清理 Commit 回傳的雜訊
                        clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
                        
                        # 5. 退出並關閉連線
                        process.stdin.write("exit\n")
                        await process.stdin.drain()
                        # 🌟 移除手動 conn.close()，交由 async with 處理

                        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
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from logger import get_logger

logger = get_logger("app.ssh")
router = APIRouter()

@router.websocket("/ws/terminal")
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
    await websocket.accept()
    conn = None
    process = None
    ws_task = None
    ssh_task = 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: 
                        logger.debug("設備端主動關閉了 stdout 通道")
                        break
                    
                    safe_text = data_bytes.decode('utf-8', errors='replace')
                    await websocket.send_text(safe_text)
            except asyncio.CancelledError:
                pass
            except Exception as e:
                logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True)

        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:
                # 主動拋出，讓外層捕捉以進行資源回收
                raise
            except asyncio.CancelledError:
                pass
            except Exception as e:
                logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True)

        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 WebSocketDisconnect:
        logger.debug("WebSocket 正常斷線，正在終止背景任務...")
        if ws_task and not ws_task.done():
            ws_task.cancel()
        if ssh_task and not ssh_task.done():
            ssh_task.cancel()
    except Exception as e:
        error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
        try:
            await websocket.send_text(error_msg)
        except:
            pass
        logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
    finally:
        # 🌟 確保 process 與 conn 絕對被關閉，防止 Memory Leak
        if process:
            process.close()
        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": ...} }
# ⚠️ 嚴格規則：Lock Key 絕對不包含 config_type，確保 running 與 full 視圖共用同一把鎖！
# ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120  

# 🌟 請求模型嚴格排除 config_type
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)
        
    # ⚠️ 嚴格綁定 host 與 path，跨視圖共用
    lock_key = f"{req.host}@@{req.path}"
    ACTIVE_LOCKS[lock_key] = {
        "user_id": req.user_id,
        "username": req.username,
        "expires_at": time.time() + LOCK_TIMEOUT
    }
    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/diagnostics.py
================================================================================
# --- routers/diagnostics.py ---
import re
import asyncssh
from logger import get_logger
from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any

logger = get_logger("app.diagnostics")
router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"])

@router.get("/")
async def get_cm_diagnostics(
    host: str = Query(...),
    username: str = Query(...),
    password: str = Query(...),
    mac: str = Query(..., description="Cable Modem MAC Address")
) -> Dict[str, Any]:
    
    mac = mac.strip().lower()
    if not mac:
        raise HTTPException(status_code=400, detail="MAC Address is required")

    result_data = {
        "mac": mac,
        "ip": "N/A",
        "state": "N/A",
        "cpe_count": 0,
        "phy": {
            "tx_power": None,
            "rx_power": None
        },
        "upstreams": [],    
        "mer_channels": {}  
    }

    try:
        async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
            logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")

            # ==========================================
            # 1. 查詢基本狀態
            # ==========================================
            cmd_base = "show cable modem " + mac + " | nomore"
            res_base = await conn.run(cmd_base, check=False)
            if res_base.exit_status == 0 and res_base.stdout:
                for line in res_base.stdout.splitlines():
                    if mac in line.lower():
                        tokens = line.split()
                        for t in tokens:
                            if re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$", t): 
                                result_data["ip"] = t
                            elif re.match(r"^[a-z]-online|online|offline|init.*|reject", t, re.IGNORECASE): 
                                result_data["state"] = t
                        nums = [t for t in tokens if t.isdigit()]
                        if nums:
                            result_data["cpe_count"] = int(nums[-1])
                        break

            # ==========================================
            # 2. 查詢 PHY 狀態 (💡 修復：移除寫死的 us/oad 判斷，改用通用特徵)
            # ==========================================
            cmd_phy = "show cable modem " + mac + " phy | nomore"
            res_phy = await conn.run(cmd_phy, check=False)
            if res_phy.exit_status == 0 and res_phy.stdout:
                tx_list = []
                rx_list = []
                for line in res_phy.stdout.splitlines():
                    line_lower = line.lower()
                    if mac in line_lower:
                        tokens = line.split()
                        if len(tokens) >= 6:
                            ch_name = tokens[1]
                            # 💡 只要名稱包含 ":" 和 "/"，就認定是合法的通道 (例如 Oa32, Oad32, Us32)
                            if ":" in ch_name and "/" in ch_name:
                                try:
                                    snr_val = float(tokens[4])
                                    result_data["upstreams"].append({"channel": ch_name, "snr": snr_val})
                                except:
                                    pass
                            try:
                                tx_val = float(tokens[3].split('/')[0])
                                rx_list_val = float(tokens[5].split('/')[0])
                                tx_list.append(tx_val)
                                rx_list.append(rx_list_val)
                            except:
                                pass
                                
                if tx_list: result_data["phy"]["tx_power"] = round(sum(tx_list)/len(tx_list), 2)
                if rx_list: result_data["phy"]["rx_power"] = round(sum(rx_list)/len(rx_list), 2)

            # ==========================================
            # 3. 尋找所有 OFDM 通道
            # ==========================================
            cmd_help = "show cable modem " + mac + " ofdm-channel ?"
            res_help = await conn.run(cmd_help, check=False)
            text_to_search = (res_help.stdout or "") + "\n" + (res_help.stderr or "")
            
            cmd_verb = "show cable modem " + mac + " verbose | nomore"
            res_verb = await conn.run(cmd_verb, check=False)
            text_to_search += "\n" + (res_verb.stdout or "")

            matches = re.findall(r"\b(Of(?:dm)?\d*[\d/:]+)\b", text_to_search, re.IGNORECASE)
            ofdm_channels = []
            for m in matches:
                clean_m = m.lower()
                if clean_m.startswith("of"):
                    clean_m = "Of" + clean_m[2:]
                if clean_m not in ofdm_channels:
                    ofdm_channels.append(clean_m)
            ofdm_channels.sort()

            # ==========================================
            # 4. 對每一個 OFDM 通道查詢 MER
            # ==========================================
            for ch in ofdm_channels:
                cmd_mer = "show cable modem " + mac + " ofdm-channel " + ch + " mer | nomore"
                res_mer = await conn.run(cmd_mer, check=False)
                
                histogram = {}
                min_mer = 999
                
                if res_mer.exit_status == 0 and res_mer.stdout:
                    mer_lines = re.finditer(r"(\d+)\s*(?:db|dB)?\s*\|[^|]*\|?\s*(\**)", res_mer.stdout, re.IGNORECASE)
                    for match in mer_lines:
                        db_val = match.group(1)
                        stars = match.group(2)
                        if stars:
                            histogram[db_val] = len(stars) * 100
                            db_int = int(db_val)
                            if db_int < min_mer:
                                min_mer = db_int
                
                health = "unknown"
                if histogram and min_mer != 999:
                    if min_mer >= 41: health = "good"
                    elif min_mer >= 38: health = "warning"
                    else: health = "critical"
                    
                result_data["mer_channels"][ch] = {
                    "histogram": histogram,
                    "health": health,
                    "min_mer": min_mer if min_mer != 999 else "N/A"
                }

        logger.debug(f"✅ [Diagnostics] 解析結果: {result_data}")
        return {"status": "success", "data": result_data}

    except Exception as e:
        logger.error("CM Diagnostics Error: " + str(e))
        raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e))

================================================================================
FILE: routers/query.py
================================================================================
# --- routers/query.py ---
import re
import asyncssh
import asyncio
from fastapi import APIRouter, HTTPException, Query
from shared import CMTS_DEVICE

router = APIRouter()

# ==========================================
# 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko)
# ==========================================

async def execute_single_command(host, username, password, command, timeout=15.0):
    """
    執行單一查詢指令 (適用於 90% 的標準 show 指令)
    自動防呆：確保指令帶有取消分頁的後綴，防止 Event Loop 卡死
    """
    try:
        async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
            # 確保指令包含取消分頁的參數
            if "| nomore" not in command.lower():
                command += " | nomore"
                
            result = await conn.run(command, check=False, timeout=timeout)
            return result.stdout or ""
            
    except asyncssh.Error as e:
        raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
    except asyncio.TimeoutError:
        raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
    except Exception as e:
        raise Exception(f"SSH Unexpected Error: {str(e)}")

async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
    """
    執行互動式指令 (適用於需要進入 config 模式，或使用 '?' 觸發補全的指令)
    動態處理終端機的 --More-- 提示
    """
    try:
        async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
            async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
                
                async def read_until_quiet(wait_time):
                    output = ""
                    while True:
                        try:
                            chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=wait_time)
                            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

                # 1. 清除登入 MOTD
                await read_until_quiet(1.5)
                
                # 2. 執行取消分頁指令 (雙重保險)
                process.stdin.write("terminal length 0\n")
                await process.stdin.drain()
                await read_until_quiet(0.5)

                # 3. 執行目標指令
                final_output = ""
                for cmd in commands:
                    process.stdin.write(cmd)
                    await process.stdin.drain()
                    final_output += await read_until_quiet(timeout)
                    
                # 4. 退出 session
                process.stdin.write("exit\n")
                await process.stdin.drain()
                
                # 清理 ANSI 控制碼與退格鍵雜訊
                clean_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', final_output)
                return clean_output
                
    except asyncssh.Error as e:
        raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
    except asyncio.TimeoutError:
        raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
    except Exception as e:
        raise Exception(f"SSH Unexpected Error: {str(e)}")

# ==========================================
# 🚀 API 路由
# ==========================================

@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:
        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"
        raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
        
        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:
        cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
        raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)

        # 💡 正名工程：更新字典的 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:
        # 這裡使用 '?' 觸發補全，必須使用互動式引擎
        commands = ["show running-config cable mac-domain ?"]
        raw_output = await execute_interactive_command(host, username, password, commands, timeout=5.0)

        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:
        raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0)
        
        # 🌟 使用 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)}"}

@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
async def list_cms(host: str, username: str, password: str):
    try:
        raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
        
        if not raw_output:
            return {"cms": []}
        
        # 嚴謹的 Regex：匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
        mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
        macs = []
        
        for line in raw_output.splitlines():
            match = mac_pattern.search(line)
            if match:
                macs.append(match.group(1).lower())
        
        # 去重複並排序
        return {"cms": sorted(list(set(macs)))}
        
    except Exception as e:
        print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
        return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列

@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
async def list_rpds(host: str, username: str, password: str):
    try:
        raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
        
        if not raw_output:
            return {"rpds": []}
        
        # 嚴謹的 Regex：匹配 數字:數字 (例如 13:0)，並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
        vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
        rpds = []
        
        for line in raw_output.splitlines():
            match = vcvs_pattern.search(line)
            if match:
                rpds.append(match.group(1))
        
        return {"rpds": sorted(list(set(rpds)))}
        
    except Exception as e:
        print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
        return {"rpds": []}


================================================================================
FILE: routers/auth.py
================================================================================
import os
import secrets
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from logger import get_logger

logger = get_logger("app.auth")
router = APIRouter(tags=["Auth"])

# 定義請求本體格式
class GodModeRequest(BaseModel):
    password: str

@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
async def verify_god_mode(req: GodModeRequest):
    # 從環境變數讀取正確密碼，預設為 "admin999"
    expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
    
    # 使用 compare_digest 防禦計時攻擊
    if secrets.compare_digest(req.password, expected_password):
        logger.info("🔓 上帝模式解鎖成功！")
        return {"status": "success"}
    
    logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
    raise HTTPException(status_code=401, detail="Invalid authorization code")


================================================================================
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
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict
from fastapi.responses import StreamingResponse
from logger import get_all_log_levels, set_log_level

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():
        # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
        host_lock = cmts_config_locks[req.host]
        async with host_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, prompt_pattern: str = None):
                            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)
                                    # 🌟 精準 Prompt 偵測
                                    if prompt_pattern and re.search(prompt_pattern, out_data):
                                        break
                                except asyncio.TimeoutError:
                                    break
                            return out_data

                        # 1. 進入設定模式
                        process.stdin.write("config\n")
                        await process.stdin.drain()
                        await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
                        
                        # 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, prompt_pattern=r"\(config.*\)#")
                            
                            # 🛡️ 防呆撤銷機制：偵測到錯誤立刻 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, prompt_pattern=r"(?:#|>)")
                        
                        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 清單"""
    net_connect = None
    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))
        
        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)}
    finally:
        if net_connect:
            net_connect.disconnect()


@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"""
    net_connect = None
    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))
        
        # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
        base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
        perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
        
        return {
            "status": "success", 
            "data": perfect_tree,         
            "raw_cli": raw_output.strip() 
        }
    except Exception as e:
        return {"status": "error", "message": str(e)}
    finally:
        if net_connect:
            net_connect.disconnect()
    

@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"""
    net_connect = None
    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))
            
        clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
        clean_output = clean_output.lstrip()

        # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
        base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
        perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)

        # ==========================================
        # 🌟 深度過濾攔截器：改用雙軌過濾器讀取邏輯
        # ==========================================
        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)}
    finally:
        if net_connect:
            net_connect.disconnect()

@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():
        net_connect = None
        try:
            # 1. 廣播：正在連線
            yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
            await asyncio.sleep(0.1) # 讓前端有時間渲染
            
            device = CMTS_DEVICE.copy()
            device.update({'host': host, 'username': username, 'password': password})
            
            # 使用 asyncio.to_thread 將阻塞的 netmiko 連線放到背景執行，避免卡住其他非同步任務
            net_connect = await asyncio.to_thread(ConnectHandler, **device)
            
            # 2. 廣播：正在下載
            yield json.dumps({"status": "progress", "message": f"📥 正在下載 {config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
            
            if config_type == "full":
                await asyncio.to_thread(net_connect.send_command_timing, "config") 
                command = "show full-configuration | nomore"
                raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
                await asyncio.to_thread(net_connect.send_command_timing, "exit")   
            else:
                command = "show running-config | nomore"
                raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
                
            # 3. 廣播：正在解析
            yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
            await asyncio.sleep(0.1)
            
            clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
            clean_output = clean_output.lstrip()

            # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
            base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
            perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)

            # ==========================================
            # 🌟 深度過濾攔截器 (維持原樣)
            # ==========================================
            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"
        finally:
            # 🌟 [Priority 1 修復] 確保背景連線被正確釋放
            if net_connect:
                await asyncio.to_thread(net_connect.disconnect)

    # 回傳 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}) 已更新"}

# ==========================================
# 🌟 伺服器日誌管控 API (Server Log Management)
# ==========================================
class LogLevelRequest(BaseModel):
    module: str
    level: str

@router.get("/settings/logs")
async def api_get_log_levels():
    """獲取目前所有模組的日誌等級"""
    return {"status": "success", "data": get_all_log_levels()}

@router.post("/settings/logs")
async def api_set_log_level(req: LogLevelRequest):
    """動態修改特定模組的日誌等級"""
    success = set_log_level(req.module, req.level)
    if success:
        return {"status": "success", "message": f"已將 {req.module} 的日誌等級設為 {req.level}"}
    raise HTTPException(status_code=400, detail="未知的模組名稱")

================================================================================
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:
                    # [修復 Leak] 延長 timeout 降低輪詢負擔
                    message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
                    yield message
                except asyncio.TimeoutError:
                    yield ": keepalive\n\n"
                    
        except asyncio.CancelledError:
            pass
        finally:
            # [修復 Leak] 確保斷線時 Queue 絕對會被移出 active_clients 釋放記憶體
            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)}"}

