scm-harmonic-cmts-admin/all_code.txt

6957 lines
328 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

================================================================================
PROJECT SOURCE CODE EXPORT
================================================================================
================================================================================
FILE: database.py
================================================================================
import asyncpg
import json
import logging
import uuid
from typing import Dict, List, Optional, Any
# ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版)
# ==========================================
logger = logging.getLogger(__name__)
# DB 設定 (從您的 init_db.py 中提取)
DB_CONFIG = {
"database": "cmts_nms",
"user": "swpa",
"password": "swpa4920",
"host": "127.0.0.1",
"port": "5432"
}
_pool: Optional[asyncpg.Pool] = None
async def init_db_pool():
"""初始化非同步連線池"""
global _pool
try:
if _pool is None:
_pool = await asyncpg.create_pool(
database=DB_CONFIG["database"],
user=DB_CONFIG["user"],
password=DB_CONFIG["password"],
host=DB_CONFIG["host"],
port=int(DB_CONFIG["port"]),
min_size=1,
max_size=10
)
logger.info("✅ PostgreSQL Connection Pool Initialized.")
except Exception as e:
logger.error(f"❌ Failed to initialize DB Pool: {e}")
async def close_db_pool():
"""關閉非同步連線池"""
global _pool
if _pool:
await _pool.close()
_pool = None
logger.info("🔌 PostgreSQL Connection Pool Closed.")
async def get_pool() -> Optional[asyncpg.Pool]:
if _pool is None:
await init_db_pool()
return _pool
# ------------------------------------------
# CRUD Functions for cmts_options
# ------------------------------------------
async def upsert_leaf_option(host: str, config_type: str, path: str, data: dict) -> bool:
"""將選項寫入或更新至資料庫"""
pool = await get_pool()
if not pool:
return False
query = """
INSERT INTO cmts_options (host, config_type, path, data)
VALUES ($1, $2, $3, $4)
ON CONFLICT (host, config_type, path)
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, config_type, path, json.dumps(data))
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_leaf_option): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_leaf_option): {e}")
return False
async def get_all_leaf_options(host: str, config_type: str) -> Optional[Dict[str, Any]]:
"""取得特定 host 與 config_type 的所有選項"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT path, data FROM cmts_options
WHERE host = $1 AND config_type = $2;
"""
try:
async with pool.acquire() as conn:
records = await conn.fetch(query, host, config_type)
result = {}
for record in records:
# asyncpg returns strings for JSON if not explicitly configured with type mapping,
# but usually it's fine to just json.loads it.
data_val = record['data']
if isinstance(data_val, str):
result[record['path']] = json.loads(data_val)
else:
result[record['path']] = data_val
return result
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_all_leaf_options): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_all_leaf_options): {e}")
return None
async def delete_leaf_options(host: str, config_type: str, paths: List[str]) -> int:
"""刪除特定路徑的選項快取"""
pool = await get_pool()
if not pool or not paths:
return -1
query = """
DELETE FROM cmts_options
WHERE host = $1 AND config_type = $2 AND path = ANY($3);
"""
try:
async with pool.acquire() as conn:
# Execute returns the command tag, e.g., 'DELETE 5'
status = await conn.execute(query, host, config_type, paths)
deleted_count = int(status.split()[-1])
return deleted_count
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (delete_leaf_options): {e}")
return -1
except Exception as e:
logger.error(f"❌ Unknown Error (delete_leaf_options): {e}")
return -1
# ------------------------------------------
# CRUD Functions for device_status
# ------------------------------------------
async def upsert_device_status(host: str, config_type: str, metadata: dict) -> bool:
"""更新設備的 metadata"""
pool = await get_pool()
if not pool:
return False
# Extract known fields
cmts_version = metadata.get("cmts_version", "unknown")
last_scanned = metadata.get("last_scanned", None)
query = """
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
VALUES ($1, $2, $3, $4)
ON CONFLICT (host, config_type)
DO UPDATE SET
cmts_version = EXCLUDED.cmts_version,
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, config_type, cmts_version, last_scanned)
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
return False
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
"""獲取設備 metadata"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT cmts_version, last_scanned FROM device_status
WHERE host = $1 AND config_type = $2;
"""
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, host, config_type)
if record:
return {
"cmts_version": record["cmts_version"],
"last_scanned": record["last_scanned"]
}
return None
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_device_status): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_device_status): {e}")
return None
# ------------------------------------------
# CRUD Functions for system_filters (Tree Filters)
# ------------------------------------------
async def upsert_tree_filters(config_type: str, hidden_keys: List[str]) -> bool:
"""寫入全域的樹狀圖隱藏節點名單"""
pool = await get_pool()
if not pool:
return False
# Use 'global' as a dummy host to keep schema simple if needed,
# but a dedicated system_filters table is better.
query = """
INSERT INTO system_filters (config_type, hidden_keys)
VALUES ($1, $2)
ON CONFLICT (config_type)
DO UPDATE SET hidden_keys = EXCLUDED.hidden_keys, updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, config_type, hidden_keys)
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_tree_filters): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_tree_filters): {e}")
return False
async def get_tree_filters(config_type: str) -> Optional[List[str]]:
"""讀取全域的樹狀圖隱藏節點名單"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT hidden_keys FROM system_filters
WHERE config_type = $1;
"""
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, config_type)
if record:
return record["hidden_keys"]
return []
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_tree_filters): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_tree_filters): {e}")
return None
# ==========================================
# CRUD Functions for config_backups (Phase 1 & 2)
# ==========================================
async def insert_config_backup(
host: str,
config_type: str,
raw_cli: str,
parsed_tree: dict,
snapshot_name: Optional[str] = None,
description: str = "", # 🟢 新增描述參數 (預設為空字串)
is_auto: bool = False
) -> Optional[str]:
"""新增一筆設備配置備份,回傳產生的 Backup ID"""
pool = await get_pool()
if not pool:
return None
backup_id = str(uuid.uuid4())
# 🟢 SQL 語句加入 description 與對應的 $5
query = """
INSERT INTO config_backups
(id, host, config_type, snapshot_name, description, is_auto, raw_cli, parsed_tree)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
"""
try:
async with pool.acquire() as conn:
await conn.execute(
query,
backup_id,
host,
config_type,
snapshot_name,
description, # 🟢 傳入描述參數
is_auto,
raw_cli,
json.dumps(parsed_tree)
)
return backup_id
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (insert_config_backup): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (insert_config_backup): {e}")
return None
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
pool = await get_pool()
if not pool:
return None
try:
async with pool.acquire() as conn:
if config_type == "all":
# 🟢 SELECT 加入 description
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
FROM config_backups
WHERE host = $1
ORDER BY timestamp DESC;
"""
records = await conn.fetch(query, host)
else:
# 🟢 SELECT 加入 description
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
FROM config_backups
WHERE host = $1 AND config_type = $2
ORDER BY timestamp DESC;
"""
records = await conn.fetch(query, host, config_type)
return [
{
"id": str(r["id"]),
"host": r["host"],
"config_type": r["config_type"],
"timestamp": r["timestamp"].isoformat(),
"snapshot_name": r["snapshot_name"],
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
"is_auto": r["is_auto"]
}
for r in records
]
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_config_backup_list): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_config_backup_list): {e}")
return None
async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
"""取得特定快照的完整內容 (包含 parsed_tree供 Phase 3 還原使用)"""
pool = await get_pool()
if not pool:
return None
# 🟢 SELECT 加入 description
query = """
SELECT id, host, timestamp, snapshot_name, description, is_auto, parsed_tree
FROM config_backups
WHERE id = $1;
"""
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, backup_id)
if record:
data_val = record['parsed_tree']
parsed_tree = json.loads(data_val) if isinstance(data_val, str) else data_val
return {
"id": str(record["id"]),
"host": record["host"],
"timestamp": record["timestamp"].isoformat(),
"snapshot_name": record["snapshot_name"],
"description": record["description"], # 🟢 將資料庫的描述放入回傳字典
"is_auto": record["is_auto"],
"parsed_tree": parsed_tree
}
return None
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_config_backup_detail): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_config_backup_detail): {e}")
return None
async def delete_config_backup(backup_id: str) -> bool:
"""刪除指定的快照"""
pool = await get_pool()
if not pool:
return False
query = "DELETE FROM config_backups WHERE id = $1;"
try:
async with pool.acquire() as conn:
status = await conn.execute(query, backup_id)
# status 會是 'DELETE 1' 或 'DELETE 0'
return int(status.split()[-1]) > 0
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (delete_config_backup): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
return False
================================================================================
FILE: shared.py
================================================================================
# --- shared.py ---
import asyncio
import json
import os
import re
from collections import defaultdict
DEBUG_MODE = False
USE_DB = True # PostgreSQL Feature Toggle
def debug_print(msg: str):
if DEBUG_MODE:
print(msg)
# 預設的 CMTS 連線樣板
CMTS_DEVICE = {
'device_type': 'cisco_ios',
'host': '10.14.110.4',
'username': 'admin',
'password': 'nsgadmin',
'port': 22,
'fast_cli': False,
'global_delay_factor': 2
}
def parse_cli_to_tree(cli_text: str) -> dict:
"""
將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
"""
# ==========================================
# 🌟 階段 0預處理 (清理雜訊,移除危險的自動黏合)
# ==========================================
cli_text = cli_text.replace('\r\n', '\n').replace('\r', '\n')
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
raw_lines = cli_text.split('\n')
fixed_lines = []
for line in raw_lines:
line = ansi_escape.sub('', line)
line = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]', '', line)
if not line.strip():
continue
# 💡 乾淨俐落:直接加入陣列,不再做危險的字串長度黏合判斷
fixed_lines.append(line)
lines = [line.rstrip() for line in fixed_lines if line.strip()]
# ==========================================
# 🌟 階段 1建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
# ==========================================
def build_raw_tree(start_idx, current_indent):
nodes = []
i = start_idx
while i < len(lines):
line = lines[i]
stripped = line.strip()
indent = len(line) - len(line.lstrip())
if indent < current_indent and stripped != '!':
return nodes, i
if stripped == '!':
i += 1
continue
has_children = False
next_i = i + 1
while next_i < len(lines) and lines[next_i].strip() == '!':
next_i += 1
if next_i < len(lines):
next_line = lines[next_i]
next_indent = len(next_line) - len(next_line.lstrip())
if next_indent > indent:
has_children = True
if has_children:
children, next_i = build_raw_tree(i + 1, next_indent)
nodes.append({"type": "folder", "content": stripped, "children": children})
i = next_i
else:
nodes.append({"type": "leaf", "content": stripped})
i += 1
return nodes, i
raw_tree, _ = build_raw_tree(0, 0)
# ==========================================
# 🌟 階段 2邏輯群組化與字典合併 (保留嚴格型別防護)
# ==========================================
def deep_merge(dict1, dict2):
for k, v in dict2.items():
if k in dict1:
if isinstance(dict1[k], dict) and isinstance(v, dict):
deep_merge(dict1[k], v)
else:
idx = 1
while f"{k} [{idx}]" in dict1:
idx += 1
dict1[f"{k} [{idx}]"] = v
else:
dict1[k] = v
def nest_folder_key(key_string, value_dict, target_dict):
parts = key_string.split()
current = target_dict
for i, part in enumerate(parts):
if i == len(parts) - 1:
if part not in current:
current[part] = value_dict
else:
if isinstance(current[part], dict) and isinstance(value_dict, dict):
deep_merge(current[part], value_dict)
else:
idx = 1
while f"{part} (衝突 {idx})" in current:
idx += 1
current[f"{part} (衝突 {idx})"] = value_dict
else:
if part not in current or not isinstance(current[part], dict):
if part in current and not isinstance(current[part], dict):
old_val = current[part]
current[part] = {"[0]": old_val}
else:
current[part] = {}
current = current[part]
def group_leaves(leaf_strings):
grouped = {}
first_word_map = defaultdict(list)
for s in leaf_strings:
parts = s.split(maxsplit=1)
first_word = parts[0]
remainder = parts[1] if len(parts) > 1 else ""
first_word_map[first_word].append(remainder)
for prefix, remainders in first_word_map.items():
if len(remainders) == 1:
grouped[prefix] = remainders[0]
else:
valid_remainders = [r for r in remainders if r]
empty_count = len(remainders) - len(valid_remainders)
group_dict = {}
if valid_remainders:
sub_grouped = group_leaves(valid_remainders)
is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
if is_all_empty_vals:
for i, k in enumerate(sub_grouped.keys()):
group_dict[f"[{i}]"] = k
else:
idx = 0
for k, v in sub_grouped.items():
if isinstance(v, dict):
group_dict[k] = v
elif v == "":
group_dict[f"[{idx}]"] = k
idx += 1
else:
group_dict[k] = v
for _ in range(empty_count):
idx = 0
while f"[{idx}]" in group_dict:
idx += 1
group_dict[f"[{idx}]"] = ""
grouped[prefix] = group_dict
return grouped
def fold_nodes(nodes):
result = {}
for node in [n for n in nodes if n["type"] == "folder"]:
folded_children = fold_nodes(node["children"])
nest_folder_key(node["content"], folded_children, result)
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
if leaves:
leaf_dict = group_leaves(leaves)
deep_merge(result, leaf_dict)
return result
return fold_nodes(raw_tree)
def deep_split_tree(data):
"""將平坦的 CLI 字典,依照空白字元拆分成深層巢狀結構 (具備終極型態衝突保護)"""
if not isinstance(data, dict):
return data
nested_tree = {}
for key, value in data.items():
processed_value = deep_split_tree(value)
parts = str(key).strip().split()
if not parts:
continue
current = nested_tree
for i, part in enumerate(parts):
if i == len(parts) - 1:
if part not in current:
current[part] = processed_value
else:
# 💡 終極衝突保護:安全轉型,絕不遺失資料
if isinstance(current[part], dict) and isinstance(processed_value, dict):
current[part].update(processed_value)
elif isinstance(current[part], dict) and not isinstance(processed_value, dict):
# 原本是資料夾,新來的是字串 -> 塞入虛擬 key [0], [1]
idx = 0
while f"[{idx}]" in current[part]: idx += 1
current[part][f"[{idx}]"] = processed_value
elif not isinstance(current[part], dict) and isinstance(processed_value, dict):
# 原本是字串,新來的是資料夾 -> 升級成資料夾,並保留原字串至 [0]
old_val = current[part]
current[part] = processed_value
idx = 0
while f"[{idx}]" in current[part]: idx += 1
current[part][f"[{idx}]"] = old_val
else:
# 兩者都是字串 -> 升級成資料夾,包含兩個字串
old_val = current[part]
current[part] = {"[0]": old_val, "[1]": processed_value}
else:
if part not in current:
current[part] = {}
elif not isinstance(current[part], dict):
# 中間節點原本是字串,必須升級為資料夾,並保留原字串
old_val = current[part]
current[part] = {"[0]": old_val}
current = current[part]
return nested_tree
# ==========================================
# 💡 系統設定管理 (System Settings Manager)
# ==========================================
SETTINGS_FILE = "system_settings.json"
def load_settings():
"""讀取系統設定,若檔案不存在則回傳預設的黑名單"""
if os.path.exists(SETTINGS_FILE):
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
# 預設隱藏這些比較雜亂且不常修改的系統層級設定
return {"hidden_keys": ["logging", "privilege", "aaa", "certificate", "line"]}
def save_settings(settings_data):
"""將系統設定寫入 JSON 檔案"""
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings_data, f, indent=4, ensure_ascii=False)
# 🌟 [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>
<!-- 引入獨立的 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>
</select>
</div>
<!-- 表單 1Cable 狀態綜合查詢 -->
<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" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
</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>
<!-- 表單 2RPD 狀態綜合查詢 -->
<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" placeholder="例: 13:0 (留白則查詢全體)">
</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>
</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>
<!-- 表單 3MAC 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>
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<option value="">請先點擊上方載入任務...</option>
</select>
<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: 10px;">
<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 style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">&times;</span>
</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>
</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" onclick="closeModal(event)">
<div class="modal-container" onclick="event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span>📄 執行結果</span>
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal;"></span>
</div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
</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
def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
hint_lines = []
options = []
current_value = None
format_desc = None
is_format_only = False
state = "INIT"
has_description = False
has_bracket_value = False
# 🌟 Case-5 特殊處理dhcp-relay 混合型欄位 (選項 + IP輸入)
# 直接在開頭掃描完整輸出,若包含 dhcp-relay強制轉為純文字輸入框
if "dhcp-relay" in output.lower():
is_format_only = True
format_desc = "IP address or options"
for line in output.splitlines():
line = line.strip()
# 過濾雜訊與終端機提示字元
if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
continue
if line.startswith('Description:'):
state = "DESC"
has_description = True
hint_lines.append(line)
continue
if line.startswith('Possible completions:'):
state = "COMPLETIONS"
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
continue
if state == "DESC":
hint_lines.append(line)
elif state == "COMPLETIONS":
# Case 1~4: 無 Description 時,將後續選項說明也納入提示
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
# 規則 1: <格式說明>[當前值]
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
if match_format:
is_format_only = True
format_desc = match_format.group(1).strip()
if match_format.group(2):
current_value = match_format.group(2).strip()
continue
# 規則 1.5: 型態, 最小值 .. 最大值
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
if match_range:
is_format_only = True
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
continue
# 規則 1.6: IP address 等純文字關鍵字
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
if match_keyword:
is_format_only = True
format_desc = match_keyword.group(1)
continue
# 規則 2: 選項列表處理
match_current = re.search(r"^\[([^\]]+)\]", line)
if match_current:
has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單
if not current_value:
current_value = match_current.group(1).strip()
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
else:
clean_line = line.strip()
# 🌟 Case-6 防呆:過濾垂直列表的說明文字
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線
if re.search(r"\s{3,}", clean_line):
clean_line = re.split(r"\s{3,}", clean_line)[0]
parts = clean_line.split()
for p in parts:
if p and p not in ["|", ".."]:
options.append(p)
# 🌟 Case 2, 3, 4: 子命令防呆機制
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
is_format_only = True
options = []
# 確保現值一定包含在選項中(如果它是一般的下拉選單)
if current_value and options and current_value not in options:
options.append(current_value)
hint_text = "\n".join(hint_lines).strip()
return {
"hint": hint_text,
# 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
"options": list(dict.fromkeys(options)) if not is_format_only else [],
"current_value": current_value,
"format_desc": format_desc,
"is_format_only": is_format_only
}
def parse_device_response(output: str) -> dict:
"""支援多種編輯狀態格式解析"""
match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
if match:
enum_content = match.group(1)
desc_content = match.group(2)
current_value = match.group(3).strip()
if enum_content:
if enum_content.strip().lower() == "list":
return {"type": "list", "options": [], "current_value": current_value}
else:
return {
"type": "enum",
"options": [opt.strip() for opt in enum_content.split(",") if opt.strip()],
"current_value": current_value
}
elif desc_content:
return {
"type": "string_or_number",
"options": [],
"current_value": current_value,
"format_desc": desc_content.strip()
}
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
# 🌟 1. 函數簽名加上 config_type 參數,預設為 "running"
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
try:
total_paths = len(leaf_paths)
processed_count = 0
result_data = {}
BATCH_SIZE = 30
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
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":
process.stdin.write("show version\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match:
cmts_version = match.group(1)
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
parsed_data = {
"hint": q_data["hint"],
"options": q_data["options"],
"type": "list" if q_data["options"] else "unknown",
"current_value": q_data.get("current_value")
}
# --- 步驟 2判斷是否需要發送 Enter ---
if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number"
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain()
output_enter = await read_until_quiet(timeout=1.0)
enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"]
parsed_data["options"] = enter_data["options"]
parsed_data["current_value"] = enter_data["current_value"]
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
processed_count += 1
event_data = {
"event": "progress",
"current": processed_count,
"total": total_paths,
"current_path": path
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0)
except Exception as e:
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue
# --- 步驟 3寫入快取 (DB or JSON) ---
try:
db_success = False
current_ts = int(time.time())
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
# 1. 嘗試寫入資料庫
if USE_DB:
try:
# 寫入 metadata
if cmts_version != "unknown":
await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
else:
await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time})
# 寫入 options
db_write_count = 0
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
if success:
db_write_count += 1
if db_write_count > 0:
db_success = True
print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
else:
print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
# 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON
if not db_success:
# 🌟 動態決定快取檔名 (加入 IP 隔離)
safe_host = host.replace(".", "_")
cache_file = f"{safe_host}_{config_type}_cache.json"
def read_json_from_file(filepath):
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
return {}
cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
# 🌟 新增:寫入 Metadata (版本與掃描時間)
if "__metadata__" not in cache_data:
cache_data["__metadata__"] = {}
if cmts_version != "unknown":
cache_data["__metadata__"]["cmts_version"] = cmts_version
cache_data["__metadata__"]["last_scanned"] = formatted_time
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p]
def write_json_to_file(filepath, data):
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
print(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
except Exception as e:
print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
if batch_idx < len(batches) - 1:
await asyncio.sleep(2)
yield json.dumps({"event": "done", "message": "快取更新完成"}) + "\n"
except Exception as e:
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
"""連線至設備並抓取完整的純文字設定檔"""
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
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await database.init_db_pool()
yield
# Shutdown
await database.close_db_pool()
app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan)
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
app.mount("/static", StaticFiles(directory="static"), name="static")
# 🌟 統一標準:所有 REST API 都在這裡掛載 /api/v1 前綴
app.include_router(query.router, prefix="/api/v1")
app.include_router(config.router, prefix="/api/v1")
app.include_router(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router)
# 根目錄路由:回傳前端 UI
@app.get("/", response_class=HTMLResponse, tags=["UI"])
async def read_root():
# 讀取同層級的 index.html
html_path = os.path.join(os.path.dirname(__file__), "index.html")
with open(html_path, "r", encoding="utf-8") as f:
return f.read()
if __name__ == "__main__":
import uvicorn
# 啟動伺服器
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)
================================================================================
FILE: test_scraper.py
================================================================================
from cmts_scraper import sync_cmts_leaves
# 💡 直接套用您在 shared.py 中驗證過的完美設定
CMTS_DEVICE = {
'device_type': 'cisco_ios',
'host': '10.14.110.4',
'username': 'admin',
'password': 'nsgadmin',
'port': 22,
'fast_cli': False,
'global_delay_factor': 2
}
# 測試用的葉節點路徑
TEST_LEAVES = [
"clock timezone",
"cable mac-domain 13:0/0.0 admin-state"
]
if __name__ == "__main__":
print("🚀 開始執行 CMTS 爬蟲測試...")
result = sync_cmts_leaves(CMTS_DEVICE, TEST_LEAVES)
print("\n📊 最終測試結果:")
import json
print(json.dumps(result, indent=4, ensure_ascii=False))
================================================================================
FILE: PROJECT_STATE.md
================================================================================
# 📖 Harmonic CMTS Manager - Project State (Living Document)
> ⚠️ **AI 助手請注意**:本專案的核心架構與開發規範已移至 `.clinerules`。此檔案僅作為「專案進度存檔」與「待辦事項追蹤」使用。
---
## ✅ 已完成開發階段 (Completed Phases)
### Phase 1: PostgreSQL 高可用性架構升級 (Completed)
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
- [x] 調整 `init_db.py` 為非同步啟動腳本。
### Phase 2: 設備配置備份與快照機制 (Completed)
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`)。
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。
- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
---
## 🚧 目前開發階段 (Current Phase)
### Phase 3: 智慧差異還原與歷史預覽 (Smart Diff Recovery & Preview)
- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)。
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
- [ ] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 ReadableStream 即時渲染後端傳來的 SSH 逐行執行 Log。
- [ ] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**
- 實作 `/api/v1/backups/{id}/restore` API改為 Streaming Response (串流回應)。
- 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止並回傳錯誤 Chunk。
---
## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
- **[未來計畫] 自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。
================================================================================
FILE: static/api.js
================================================================================
// --- static/api.js ---
// ==========================================
// 1. 鎖定管理 (Lock Management)
// ==========================================
export async function apiAcquireLock(path, userId, username, host) {
const res = await fetch('/api/v1/locks/acquire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, username, host })
});
const data = await res.json();
return { status: res.status, data };
}
export async function apiReleaseLock(path, userId, host) {
return fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, host })
});
}
export async function apiSendHeartbeat(path, userId, host) {
return fetch('/api/v1/locks/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, host })
});
}
export async function apiGetLockStatus(host) {
// 帶入 host 參數,實現 IP 隔離查詢
const url = host ? `/api/v1/locks/status?host=${encodeURIComponent(host)}` : '/api/v1/locks/status';
const response = await fetch(url);
return response.json();
}
// ==========================================
// 2. 樹狀圖與選項快取 (Tree & Options)
// ==========================================
export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
// 🌟 將 config_type 加入 URL 參數中
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`;
if (skipFilter) url += '&skip_filter=true';
const response = await fetch(url);
return response.json();
}
// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
export async function apiGetLeafOptions(host, configType = 'running') {
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
return response.json();
}
// 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
export async function apiSyncLeafOptions(host, paths, configType = 'running') {
return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
}
// 🌟 3. 清除快取 (加上 configType 查詢參數)
export async function apiClearCache(host, paths, configType = 'running') {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paths)
});
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
return response.json();
}
export async function apiGetCmtsVersion(host, username, password) {
const url = `/api/v1/cmts-version?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiGetScanStatus(host, configType = 'running') {
try {
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`);
const data = await response.json();
return data.is_scanning;
} catch (e) {
return false;
}
}
// ==========================================
// 3. 指令生成與執行 (CLI Generation & Execution)
// ==========================================
export async function apiGenerateCli(diffs, interfaces) {
const response = await fetch('/api/v1/cmts-generate-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ diffs, interfaces_with_admin_state: interfaces })
});
return response.json();
}
export async function apiExecuteConfig(script, host, username, password) {
const response = await fetch('/api/v1/cmts-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script, host, username, password })
});
return response.json();
}
// ==========================================
// 4. 系統設定與過濾器 (System Settings)
// ==========================================
// 🌟 加上 configType 參數
export async function apiGetTreeFilters(configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
return response.json();
}
// 🌟 加上 configType 參數
export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
});
return response.json();
}
// ==========================================
// 5. MAC Domain 與綜合查詢 (MAC Domain & Query)
// ==========================================
export async function apiGetMacDomainList(host, username, password) {
const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiGetMacDomainConfig(target, host, username, password) {
const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiExecuteQuery(queryType, target, host, username, password) {
const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
// ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ==========================================
// 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
// 🌟 核心修改:讀取串流資料
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
while (true) {
const { done, value } = await reader.read();
if (done) break; // 伺服器斷開連線 (任務完成)
// 解碼二進位資料為文字
const chunk = decoder.decode(value, { stream: true });
// 因為一次可能收到多行 JSON我們用換行符號切開
const lines = chunk.split("\n").filter(line => line.trim() !== "");
for (const line of lines) {
try {
const data = JSON.parse(line);
onProgress(data); // 將解析後的資料傳給 UI 介面
} catch (e) {
console.error("JSON 解析錯誤:", e, line);
}
}
}
}
================================================================================
FILE: static/app.js
================================================================================
/* --- static/app.js --- */
// ============================================================================
// 📦 模組匯入區 (Imports)
// ============================================================================
// 1. 終端機模組 (Terminal)
import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, downloadTerminal, isConnected } from './terminal.js';
// 2. API 網路請求模組 (API)
import {
apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus
} from './api.js';
// 3. 共用工具模組 (Utils)
import { getGlobalConnectionInfo } from './utils.js';
// 4. 樹狀圖 UI 模組 (Tree UI)
import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js';
// 5. 編輯模式與鎖定模組 (Edit Mode)
import {
releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
} from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain)
import {
hasMacDomainData, resetMacDomainData, loadMacDomainList, fetchMacDomainConfig,
populateFormFromData, revertFormChanges, toggleGroupVisibility,
loadDsGroupData, loadUsGroupData, generateMacDomainCLI, executeBondingConfig
} from './mac-domain.js';
// ============================================================================
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
// ============================================================================
let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
function initGlobalSSE(mode = 'running') {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
if (globalSSE) globalSSE.close();
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
const statusEl = document.getElementById('scan-status');
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
if (data.event === 'scan_start' || data.event === 'progress') {
setAdminButtonsState(true, "⏳ 系統更新中...");
}
if (data.event === 'scan_start') {
if (statusEl) {
statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'progress') {
if (statusEl) {
statusEl.innerHTML = `⏳ 背景掃描進度:<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;
}
});
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」
let activeLockState = new Set();
let lockPollingTimer = null;
function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer);
lockPollingTimer = setInterval(async () => {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
try {
const result = await apiGetLockStatus(connInfo.host);
if (result.status === 'success') {
const currentLocks = result.data;
const newLockState = new Set();
// 🎯 任務 1處理「新增鎖定」或「持續鎖定」的節點
for (const [path, lockInfo] of Object.entries(currentLocks)) {
if (lockInfo.user_id !== SESSION_USER_ID) {
newLockState.add(path);
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
targetBtns.forEach(btn => {
if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
btn.innerText = "🔒";
}
});
}
}
// 🔓 任務 2處理「解除鎖定」的節點
activeLockState.forEach(oldPath => {
if (!newLockState.has(oldPath)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
targetBtns.forEach(btn => {
if (btn.innerText === "🔒") {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
});
}
});
// 💾 任務 3更新特務的記憶
activeLockState = newLockState;
}
} catch (error) {
console.warn("獲取鎖定狀態失敗:", error);
}
}, 15000);
}
// 頁面載入與縮放初始化
window.onload = () => {
initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監聽
startLockStatusPolling(); // 啟動鎖定狀態輪詢
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
};
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
// 閒置計時器重置邏輯 (超時將自動釋放所有編輯鎖定)
function resetIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
}
// 🌟 效能急救:節流閥 (Throttle) 機制
let throttleTimer = false;
function throttledReset() {
if (!throttleTimer) {
resetIdleTimer();
throttleTimer = true;
setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設
}
}
window.addEventListener('mousemove', throttledReset);
window.addEventListener('keydown', throttledReset);
window.addEventListener('click', throttledReset);
// ============================================================================
// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
// ============================================================================
// 切換主頁籤 (終端機 / 設備查詢 / 設備配置 / 系統設定)
function switchTab(tabId, element) {
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
const targetTab = document.getElementById(tabId);
if (targetTab) targetTab.classList.add('active');
if (element) element.classList.add('active');
if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
}
// 切換「設備查詢」內的子任務表單
function switchQueryTask() {
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
const selectedTaskId = document.getElementById('queryTask').value;
const targetForm = document.getElementById(selectedTaskId);
if (targetForm) targetForm.classList.add('active');
}
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
let lastTreeMode = null;
// 切換「設備配置」內的子任務表單
function switchConfigTask() {
document.querySelectorAll('#config-tab .task-form').forEach(form => {
form.classList.remove('active');
form.style.display = 'none';
});
const selectedTaskId = document.getElementById('configTask').value;
let targetFormId = selectedTaskId;
if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') {
targetFormId = 'form-tree-config';
}
const targetForm = document.getElementById(targetFormId);
if (targetForm) {
targetForm.classList.add('active');
targetForm.style.display = 'block';
const titleSpan = document.getElementById('tree-form-title');
const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running';
if (titleSpan) {
titleSpan.textContent = currentMode === 'running'
? '🌳 設備配置樹狀圖 (running-config)'
: '🌳 完整設備配置樹狀圖 (full configuration)';
}
if (targetFormId === 'form-tree-config') {
// 🌟 魔法所在:純視覺切換,不銷毀資料!
const runningContainer = document.getElementById('tree-container-running');
const fullContainer = document.getElementById('tree-container-full');
if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none';
if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none';
if (lastTreeMode !== currentMode) {
const statusEl = document.getElementById('scan-status');
if (statusEl) statusEl.textContent = '';
// 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏!
const loadingMsg = document.getElementById('loading-message');
if (loadingMsg) loadingMsg.style.display = 'none';
lastTreeMode = currentMode;
}
}
initGlobalSSE(currentMode);
}
}
// 啟動配置任務 (樹狀圖載入 或 MAC Domain 精靈)
function startConfigTask() {
if (!isConnected) {
alert("請先點擊畫面上方的「連線至 CMTS」成功後再執行載入任務");
return;
}
switchConfigTask();
const selectedTaskId = document.getElementById('configTask').value;
if (selectedTaskId === 'form-bonding-config') {
if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
resetAllManagerData();
loadMacDomainList();
}
// 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType
else if (selectedTaskId === 'form-running-config') {
fetchFullConfig('running');
}
else if (selectedTaskId === 'form-full-config') {
fetchFullConfig('full'); // 🌟 補上 full 參數
}
}
// 切換查詢輸入框的啟用狀態 (全域指令不需輸入目標)
function toggleQueryInputs(mode) {
const type = document.getElementById('queryType' + mode).value;
const targetInput = document.getElementById('queryTarget' + mode);
const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum'];
if (globalTypes.includes(type)) {
targetInput.disabled = true;
targetInput.style.backgroundColor = '#e8ecef';
targetInput.placeholder = "此查詢為全域指令,不需輸入目標";
targetInput.value = "";
} else {
targetInput.disabled = false;
targetInput.style.backgroundColor = '#f8f9fa';
targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)";
}
}
// 重置所有管理介面的資料與 UI 狀態
function resetAllManagerData() {
resetMacDomainData();
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache();
const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none';
const previewEl = document.getElementById('cliPreviewContainer');
if (previewEl) {
previewEl.textContent = '';
previewEl.style.display = 'none';
}
const deployBtn = document.getElementById('btnDeployConfig');
if (deployBtn) {
deployBtn.style.display = 'none';
deployBtn.disabled = true;
}
const mdSelect = document.getElementById('cfgMacDomain');
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>';
const fetchStatus = document.getElementById('fetchStatus');
if (fetchStatus) {
fetchStatus.textContent = "請先讀取設備狀態...";
fetchStatus.style.color = "#7f8c8d";
}
document.getElementById('queryTargetCm').value = '';
document.getElementById('queryTargetRpd').value = '';
// 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
if (typeof loadBackupHistory === 'function') {
loadBackupHistory();
}
}
// ============================================================================
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
// ============================================================================
// 執行綜合查詢 (CM / RPD)
async function executeQuery(mode) {
if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕");
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
const queryType = document.getElementById('queryType' + mode).value;
const target = document.getElementById('queryTarget' + mode).value.trim();
const isDebug = document.getElementById('debugQuery' + mode).checked;
openModal(connInfo.host);
const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">正在向 ${connInfo.host} 查詢中,請稍候...</span>`;
try {
const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') {
outputEl.style.color = "#e0e0e0";
outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data;
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
}
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `連線失敗: ${error}`;
}
}
// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀)
async function fetchFullConfig(configType = 'running') {
const loadingMsg = document.getElementById('loading-message');
const treeContainer = document.getElementById(`tree-container-${configType}`);
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
if (loadingMsg) {
loadingMsg.style.display = 'inline-block';
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
}
if (treeContainer) treeContainer.innerHTML = '';
let needAutoScan = false;
let progressInterval = null;
try {
// --- 1. 版本守門員 (維持原樣) ---
try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
if (cachedVersion && cachedVersion !== currentVersion) {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本${currentVersion}\n快取紀錄版本${cachedVersion}\n\n為確保指令正確系統強烈建議清空舊版快取。是否立即清空`);
if (doClear) {
const allKeys = Object.keys(optData);
await apiClearCache(connInfo.host, allKeys, configType);
needAutoScan = true;
}
}
}
} catch (vErr) {
console.warn("版本檢查失敗,略過", vErr);
}
// --- 2. 載入完整配置 (ReadableStream 串流接收) ---
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalTreeData = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
}
}, 500);
} else {
// 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
// 1. 強制填滿 100% 並給予成功提示
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感
}
// 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
await new Promise(resolve => setTimeout(resolve, 600));
// 3. 恢復原本的顏色
if (loadingMsg) loadingMsg.style.color = "";
}
// 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
if (loadingMsg) loadingMsg.innerHTML = data.message;
}
}
else if (data.status === 'success') {
finalTreeData = data.data;
}
else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
window.currentTreeData = finalTreeData;
// --- 3. 防呆攔截與畫面渲染 ---
const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
if (currentSelectedTask !== expectedTask) return;
if (treeContainer) {
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
if (loadingMsg) loadingMsg.style.display = 'none';
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
if (isSomeoneScanning) {
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000);
} else {
enableGlobalScanButton();
if (needAutoScan) {
setTimeout(() => scanGlobalMissingOptions(configType), 500);
}
}
}
} catch (error) {
if (treeContainer) treeContainer.innerHTML = `<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 = ""; // 確保重置顏色
}
}
}
// ============================================================================
// 🌟 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);
}
}
// 載入真實設備配置以供過濾器勾選
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';
container.style.display = 'none';
try {
// 🌟 根據模式載入對應的配置檔 (Running 或 Full)
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') {
container.style.display = 'block';
// 🌟 傳遞 mode 給 buildRealFilterTree
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
} else {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
}
} catch (error) {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`;
} finally {
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';
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
})
});
const result = await response.json();
if (result.status === 'success') {
alert(`✅ ${result.message}`);
document.getElementById('snapshotName').value = ''; // 清空名稱輸入框
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框
}
loadBackupHistory(); // 重新載入歷史紀錄
} else {
alert(`❌ 備份失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
} finally {
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: #7f8c8d;">⏳ 正在載入歷史紀錄...</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) {
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>
<!-- 🟢 新增描述欄位,加上防撐破設計與 title 提示 -->
<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>
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="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: #3498db; 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>
<button id="btn-confirm-restore" onclick="executeSmartRestore('${snapshotId}', '${snapshotName}', ${safeCommands})" class="btn-modern btn-save" style="position: absolute; right: 45px; top: 12px; background-color: #e74c3c; padding: 6px 12px; font-size: 13px; border-radius: 4px; color: white; border: none; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">⚠️ 確認無誤,立即寫入設備並 Commit</button>
`;
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'];
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 控制捲軸)
// 移除 min-height 和 overflow-y讓它完全撐滿父容器
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 {
// 3. 發送 Fetch 請求 (不使用 await response.json())
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 })
});
// 4. 處理 ReadableStream (串流接收 NDJSON)
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 });
// 用換行符號切割出完整的 JSON 字串
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 });
// 根據狀態渲染不同顏色的 Log
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') {
// 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
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}`;
} 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(event) {
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = '';
}
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
document.addEventListener("DOMContentLoaded", checkScanButtonState);
// ==========================================
// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯
// ==========================================
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
document.addEventListener('DOMContentLoaded', () => {
const appTitle = document.getElementById('app-title');
function unlockAdminFeatures() {
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.style.display = 'inline-block';
});
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
btn.style.display = 'inline-block';
});
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
let clickCount = 0;
let clickTimer = null;
if (appTitle) {
appTitle.addEventListener('click', () => {
clickCount++;
if (clickCount === 5) {
unlockAdminFeatures();
alert('🔓 已解鎖系統維護者模式!');
clickCount = 0;
}
clearTimeout(clickTimer);
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
});
}
});
// 統一控制 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; // 🌟 確保過濾器綁定到全域
================================================================================
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();
};
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(200px, 1fr)); gap: 12px; 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 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; }
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
.modal-close:hover { color: #e74c3c; }
/* 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; } }
================================================================================
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 = {};
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);
if (keysToRelease.length === 0) 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();
}
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) {
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';
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) 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;
const editBtn = document.getElementById(`edit-btn-${elementId}`);
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) {}
const pathsToSync = [];
const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
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) {
alert("❌ 無法連線到鎖定伺服器:" + error.message);
}
}
export async function startEditLeaf(path, 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';
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) 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;
document.getElementById(`edit-btn-${elementId}`).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);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</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++) {
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
found = true; break;
}
}
}
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) {
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) {
alert("❌ 無法連線到鎖定伺服器:" + error.message);
}
}
export async function cancelEditFolder(path, 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];
}
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).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) {
const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
if (ACTIVE_HEARTBEATS[lockKey]) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey];
}
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).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 = '#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 mdSelect = document.getElementById('cfgMacDomain');
const statusSpan = document.getElementById('fetchStatus');
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12";
try {
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = '';
result.data.forEach(md => {
const option = document.createElement('option');
option.value = md;
option.textContent = md;
mdSelect.appendChild(option);
});
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60";
} else {
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>';
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
statusSpan.style.color = "#e74c3c";
}
} catch (error) {
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>';
statusSpan.textContent = "連線失敗:" + error;
statusSpan.style.color = "#e74c3c";
}
}
export async function fetchMacDomainConfig() {
const target = document.getElementById('cfgMacDomain').value.trim();
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !target) return alert("請確認設備連線資訊與 MAC Domain");
document.getElementById('fetchStatus').textContent = "⏳ 正在讀取並解析設定中...";
document.getElementById('fetchStatus').style.color = "#f39c12";
try {
const result = await apiGetMacDomainConfig(target, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') {
currentMacDomainData = result.data;
populateFormFromData();
document.getElementById('macDomainConfigArea').style.display = 'block';
document.getElementById('fetchStatus').textContent = "✅ 讀取成功!";
document.getElementById('fetchStatus').style.color = "#27ae60";
} else {
alert("讀取失敗:" + result.detail);
document.getElementById('fetchStatus').textContent = "❌ 讀取失敗";
}
} catch (error) {
alert("連線錯誤:" + error);
}
}
export function populateFormFromData() {
const d = currentMacDomainData;
if (!d) return;
document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack';
document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled';
document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled';
document.getElementById('g_bat30').value = d.common_settings['cm-battery-mode-30-support'] || 'disabled';
document.getElementById('g_docsis40').value = d.common_settings['docsis40'] || 'disabled';
document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled';
document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled';
document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down';
document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || '';
document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || '';
document.getElementById('b_us_phy').value = d.basic_channel_sets['us-phy-channel-set'] || '';
document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || '';
document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || '';
const dsSelect = document.getElementById('select_ds_group');
dsSelect.innerHTML = '<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}`;
try {
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') {
outputEl.style.color = "#e0e0e0";
outputEl.textContent = `✅ 設定執行完成!\n==================================================\n${result.data}`;
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 設定執行失敗:\n${result.message}`;
}
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
}
}
================================================================================
FILE: static/utils.js
================================================================================
// --- static/utils.js ---
// 加上 export讓其他檔案可以使用這個函數
export function getGlobalConnectionInfo() {
const host = document.getElementById('cmtsHost').value.trim();
const user = document.getElementById('cmtsUser').value.trim();
const pass = document.getElementById('cmtsPass').value.trim();
if (!host || !user || !pass) {
alert("請在畫面上方完整輸入目標設備的 IP、帳號與密碼");
return null;
}
return { host, user, pass };
}
================================================================================
FILE: static/tree-ui.js
================================================================================
// --- static/tree-ui.js ---
// ==========================================
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ==========================================
export const folderDataCache = {};
export const filterFolderCache = {};
// 🧹 [修復 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>
`;
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: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
✏️
</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);`;
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: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
✏️
</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 logging
import asyncssh
import asyncio
import re
import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, List
# 引入 DB 函數
from database import (
insert_config_backup,
get_config_backup_list,
get_config_backup_detail,
delete_config_backup
)
from cmts_scraper import fetch_raw_config
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/backups",
tags=["Backups"]
)
# ==========================================
# 📦 Pydantic Models
# ==========================================
class SnapshotRequest(BaseModel):
host: str
username: str
password: str
snapshot_name: str
description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述
config_type: str = "running"
class RestoreRequest(BaseModel):
host: str
username: str
password: str
class ExecuteRestoreRequest(BaseModel):
host: str
username: str
password: str
commands: List[str] # 接收前端確認後的差異指令清單
# ==========================================
# 🛠️ 核心演算法:深度差異比對 (Deep Diff)
# ==========================================
# 🌟 新增小工具:拔除 [0], [1] 虛擬後綴,還原真實 CLI
def clean_virtual_key(key: str) -> str:
return re.sub(r'\s*\[\d+\]$', '', key)
def build_add_commands(node: dict, current_path: str) -> list:
"""遞迴展開所有需要新增的絕對路徑指令"""
commands = []
if not node:
commands.append(current_path)
return commands
for key, val in node.items():
if key.startswith('_'):
continue
# 🌟 使用乾淨的 key 來組合路徑
real_key = clean_virtual_key(key)
path = f"{current_path} {real_key}".strip()
if isinstance(val, dict):
commands.extend(build_add_commands(val, path))
else:
val_str = f" {val}" if val else ""
commands.append(f"{path}{val_str}")
return commands
def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list:
"""比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
commands = []
for key, curr_val in current_node.items():
if key.startswith('_'):
continue
# 🌟 使用乾淨的 key
real_key = clean_virtual_key(key)
path = f"{current_path} {real_key}".strip()
if key not in snapshot_node:
commands.append(f"no {path}")
else:
snap_val = snapshot_node[key]
if isinstance(curr_val, dict) and isinstance(snap_val, dict):
commands.extend(generate_diff_commands(curr_val, snap_val, path))
elif isinstance(curr_val, dict) or isinstance(snap_val, dict):
commands.append(f"no {path}")
if isinstance(snap_val, dict):
commands.extend(build_add_commands(snap_val, path))
else:
val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}")
elif curr_val != snap_val:
val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}")
for key, snap_val in snapshot_node.items():
if key.startswith('_'):
continue
# 🌟 使用乾淨的 key
real_key = clean_virtual_key(key)
path = f"{current_path} {real_key}".strip()
if key not in current_node:
if isinstance(snap_val, dict):
commands.extend(build_add_commands(snap_val, path))
else:
val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}")
return commands
# ==========================================
# 🚀 API 路由
# ==========================================
@router.get("/", summary="取得歷史快照列表")
async def list_backups(host: str, config_type: str = "running"):
records = await get_config_backup_list(host, config_type)
if records is None:
return {"status": "error", "message": "資料庫查詢失敗"}
return {"status": "success", "data": records}
@router.get("/{backup_id}", summary="取得特定快照詳細內容")
async def get_backup_detail(backup_id: str):
record = await get_config_backup_detail(backup_id)
if not record:
return {"status": "error", "message": "找不到該筆備份資料"}
return {"status": "success", "data": record}
@router.delete("/{backup_id}", summary="刪除特定快照")
async def delete_backup(backup_id: str):
success = await delete_config_backup(backup_id)
if not success:
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
return {"status": "success", "message": "快照已成功刪除"}
@router.post("/snapshot", summary="手動建立設備快照")
async def create_snapshot(req: SnapshotRequest):
try:
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
parsed_tree = parse_cli_to_tree(raw_cli)
backup_id = await insert_config_backup(
host=req.host,
config_type=req.config_type,
raw_cli=raw_cli,
parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name,
description=req.description, # 🟢 將描述傳遞給資料庫函數
is_auto=False
)
if not backup_id:
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}
return {
"status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id,
"host": req.host
}
except Exception as e:
logger.error(f"❌ 建立快照失敗: {e}")
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
try:
backup_record = await get_config_backup_detail(backup_id)
if not backup_record:
return {"status": "error", "message": "找不到指定的備份紀錄"}
snapshot_tree = backup_record.get("parsed_tree")
if not snapshot_tree:
return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
# 🌟 關鍵修正:動態取得該快照的 config_type避免蘋果比橘子
snapshot_config_type = backup_record.get("config_type", "running")
# 🌟 關鍵修正:將硬編碼的 "running" 改為 snapshot_config_type
raw_current = await fetch_raw_config(req.host, req.username, req.password, snapshot_config_type)
if not raw_current:
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
current_tree = parse_cli_to_tree(raw_current)
diff_commands = generate_diff_commands(current_tree, snapshot_tree)
return {
"status": "success",
"data": {
"commands": diff_commands
}
}
except Exception as e:
logger.error(f"差異分析失敗: {str(e)}")
return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"}
@router.post("/{backup_id}/restore", summary="執行差異還原 (串流即時回報)")
async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
commands = req.commands
if not commands:
async def empty_success():
yield json.dumps({"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}) + "\n"
return StreamingResponse(empty_success(), media_type="application/x-ndjson")
async def restore_generator():
# 🌟 [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\)#")
# 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
import traceback
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
router = APIRouter()
@router.websocket("/ws/terminal")
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
await websocket.accept()
conn = None
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:
print("[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:
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
traceback.print_exc()
async def forward_to_ssh():
try:
while True:
data_text = await websocket.receive_text()
data_bytes = data_text.encode('utf-8')
# 攔截 xterm.js 預設的 DEL (\x7f),轉換為 BS (\x08)
if b'\x7f' in data_bytes:
data_bytes = data_bytes.replace(b'\x7f', b'\x08')
process.stdin.write(data_bytes)
await process.stdin.drain()
except WebSocketDisconnect:
# 主動拋出,讓外層捕捉以進行資源回收
raise
except asyncio.CancelledError:
pass
except Exception as e:
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
traceback.print_exc()
ws_task = asyncio.create_task(forward_to_ws())
ssh_task = asyncio.create_task(forward_to_ssh())
done, pending = await asyncio.wait(
[ws_task, ssh_task],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except WebSocketDisconnect:
print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...")
if ws_task and not ws_task.done():
ws_task.cancel()
if ssh_task and not ssh_task.done():
ssh_task.cancel()
except Exception as e:
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
try:
await websocket.send_text(error_msg)
except:
pass
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
traceback.print_exc()
finally:
# 🌟 確保 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": ...} }
# ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120
# 🌟 新增 host 欄位
class LockAcquireReq(BaseModel):
host: str
path: str
user_id: str
username: str
class LockActionReq(BaseModel):
host: str
path: str
user_id: str
def clean_expired_locks():
current_time = time.time()
expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
for k in expired_keys:
del ACTIVE_LOCKS[k]
def is_path_locked_by_others(target_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
clean_expired_locks()
prefix = f"{target_host}@@"
for locked_key, lock_info in ACTIVE_LOCKS.items():
# 🌟 只檢查同一台設備 (IP) 的鎖定
if not locked_key.startswith(prefix):
continue
locked_path = locked_key.split("@@", 1)[1]
if lock_info["user_id"] == user_id:
continue
if target_path == locked_path:
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
if target_path.startswith(locked_path + "::"):
return lock_info, f"父層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法編輯子區塊"
if locked_path.startswith(target_path + "::"):
return lock_info, f"子層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法鎖定整個父區塊"
return None, ""
@router.post("/acquire")
async def acquire_lock(req: LockAcquireReq):
conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
if conflict_lock:
raise HTTPException(status_code=409, detail=error_msg)
lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id,
"username": req.username,
"expires_at": time.time() + LOCK_TIMEOUT
}
return {"status": "success", "message": f"成功鎖定路徑: {req.path}"}
@router.post("/heartbeat")
async def heartbeat_lock(req: LockActionReq):
clean_expired_locks()
lock_key = f"{req.host}@@{req.path}"
if lock_key not in ACTIVE_LOCKS:
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
return {"status": "success", "message": "心跳更新成功"}
@router.post("/release")
async def release_lock(req: LockActionReq):
lock_key = f"{req.host}@@{req.path}"
if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
del ACTIVE_LOCKS[lock_key]
return {"status": "success", "message": "鎖定已釋放"}
@router.get("/status")
async def get_lock_status(host: str = Query(None)):
"""獲取特定設備的鎖定狀態"""
clean_expired_locks()
if host:
prefix = f"{host}@@"
# 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 pathUI 不用改!
filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
return {"status": "success", "data": filtered_locks}
return {"status": "success", "data": {}}
================================================================================
FILE: routers/query.py
================================================================================
# --- routers/query.py ---
import re
from fastapi import APIRouter, HTTPException, Query
from netmiko import ConnectHandler
from shared import CMTS_DEVICE
router = APIRouter()
def parse_cm_output(raw_text: str) -> list:
parsed_data = []
lines = raw_text.splitlines()
data_started = False
for line in lines:
if not line.strip(): continue
if line.strip().startswith('---'):
data_started = True
continue
if line.strip().startswith('==='): break
if data_started:
columns = re.split(r'\s{2,}', line.strip())
if len(columns) >= 8:
cm_info = {
"downstream": columns[0], "upstream": columns[1],
"bond_cap": columns[2], "ofdm_cap": columns[3],
"mac_address": columns[4], "ip_address": columns[5],
"num_cpe": int(columns[6]), "state": columns[7]
}
parsed_data.append(cm_info)
return parsed_data
@router.get("/cable-modems")
async def get_cable_modems():
net_connect = None
try:
net_connect = ConnectHandler(**CMTS_DEVICE)
raw_output = str(net_connect.send_command("show cable modem"))
structured_data = parse_cm_output(raw_output)
return {"status": "success", "total_count": len(structured_data), "data": structured_data}
except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/configuration")
async def get_configuration():
net_connect = None
try:
net_connect = ConnectHandler(**CMTS_DEVICE)
net_connect.send_command_timing("config")
raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120)
net_connect.send_command_timing("exit")
return {"status": "success", "data": raw_output}
except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@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(...)
):
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
target_str = f" {target.strip()}" if target.strip() else ""
commands = {
"base": f"show cable modem{target_str}",
"cpe": f"show cable modem{target_str} cpe",
"cpe_dhcp": f"show cable modem{target_str} cpe dhcp",
"cpe_ipv6": f"show cable modem{target_str} cpe ipv6",
"bonding": f"show cable modem{target_str} bonding",
"bonding_ds": f"show cable modem{target_str} bonding downstream",
"bonding_us": f"show cable modem{target_str} bonding upstream",
"phy": f"show cable modem{target_str} phy",
"verbose": f"show cable modem{target_str} verbose",
"ofdm_profile": f"show cable modem{target_str} ofdm-profile",
"ofdma_profile": f"show cable modem{target_str} ofdma-profile",
"dhcp_verbose": f"show cable modem{target_str} dhcp verbose",
"service_flow": f"show cable modem{target_str} service-flow",
"service_flow_verbose": f"show cable modem{target_str} service-flow verbose",
"qos": f"show cable modem{target_str} qos",
"uptime": f"show cable modem{target_str} uptime",
"ugs": f"show cable modem{target_str} ugs",
"cm_status": f"show cable modem{target_str} cm-status",
"partial_mode": "show cable modem partial-mode",
"hop": "show cable hop",
"flap_list": "show cable flap-list",
"flap_sum": "show cable flap-sum",
"rpd_base": f"show cable rpd{target_str}",
"rpd_verbose": f"show cable rpd{target_str} verbose",
"rpd_ptp_time": f"show cable rpd{target_str} ptp time-property",
"rpd_ptp_verbose": f"show cable rpd{target_str} ptp verbose",
"rpd_counters_map": f"show cable rpd{target_str} counters map",
"rpd_capabilities": f"show cable rpd{target_str} capabilities",
"rpd_video_counters": f"show cable rpd{target_str} video-channel counters",
"rpd_env_temp": f"show cable rpd{target_str} environment temperature",
"rpd_env_volt": f"show cable rpd{target_str} environment voltage",
"rpd_session": f"show cable rpd{target_str} session",
"rpd_reset_history": f"show cable rpd{target_str} reset-history",
"rpd_port_transceiver": f"show cable rpd{target_str} port-transceiver",
}
if query_type not in commands:
raise ValueError(f"未知的查詢類型: {query_type}")
cli_command = commands[query_type] + " | nomore"
net_connect = ConnectHandler(**device)
raw_output = net_connect.send_command(cli_command, read_timeout=15)
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)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-config")
async def get_mac_domain_config(target: str, host: str, username: str, password: str):
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
# 💡 正名工程:更新字典的 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)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-list")
async def get_cmts_mac_domain_list(
host: str = Query(...),
username: str = Query(...),
password: str = Query(...)
):
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
import re
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
mac_domains = list(dict.fromkeys(matches))
return {"status": "success", "data": mac_domains}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-version")
async def get_cmts_version(
host: str = Query(...),
username: str = Query(...),
password: str = Query(...)
):
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command("show version", read_timeout=15))
import re
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
if match:
return {"status": "success", "version": match.group(1)}
else:
return {"status": "error", "message": "無法解析版本號", "raw_output": raw_output}
except Exception as e:
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
finally:
if net_connect:
net_connect.disconnect()
================================================================================
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
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}) 已更新"}
================================================================================
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)}"}