8509 lines
401 KiB
Plaintext
8509 lines
401 KiB
Plaintext
================================================================================
|
||
PROJECT SOURCE CODE EXPORT (FULL)
|
||
================================================================================
|
||
|
||
|
||
================================================================================
|
||
FILE: database.py
|
||
================================================================================
|
||
import asyncpg
|
||
import json
|
||
import uuid
|
||
from typing import Dict, List, Optional, Any
|
||
from logger import get_logger
|
||
|
||
# ==========================================
|
||
# 💡 PostgreSQL 連線與操作 (高可用性版)
|
||
# ==========================================
|
||
|
||
logger = get_logger("app.database")
|
||
|
||
# DB 設定 (從您的 init_db.py 中提取)
|
||
DB_CONFIG = {
|
||
"database": "cmts_nms",
|
||
"user": "swpa",
|
||
"password": "swpa4920",
|
||
"host": "127.0.0.1",
|
||
"port": "5432"
|
||
}
|
||
|
||
_pool: Optional[asyncpg.Pool] = None
|
||
|
||
async def init_db_pool():
|
||
"""初始化非同步連線池"""
|
||
global _pool
|
||
try:
|
||
if _pool is None:
|
||
_pool = await asyncpg.create_pool(
|
||
database=DB_CONFIG["database"],
|
||
user=DB_CONFIG["user"],
|
||
password=DB_CONFIG["password"],
|
||
host=DB_CONFIG["host"],
|
||
port=int(DB_CONFIG["port"]),
|
||
min_size=1,
|
||
max_size=10
|
||
)
|
||
logger.info("✅ PostgreSQL Connection Pool Initialized.")
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to initialize DB Pool: {e}")
|
||
|
||
async def close_db_pool():
|
||
"""關閉非同步連線池"""
|
||
global _pool
|
||
if _pool:
|
||
await _pool.close()
|
||
_pool = None
|
||
logger.info("🔌 PostgreSQL Connection Pool Closed.")
|
||
|
||
async def get_pool() -> Optional[asyncpg.Pool]:
|
||
if _pool is None:
|
||
await init_db_pool()
|
||
return _pool
|
||
|
||
# ------------------------------------------
|
||
# CRUD Functions for cmts_options
|
||
# ------------------------------------------
|
||
|
||
async def upsert_leaf_option(host: str, config_type: str, path: str, data: dict) -> bool:
|
||
"""將選項寫入或更新至資料庫"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return False
|
||
|
||
query = """
|
||
INSERT INTO cmts_options (host, config_type, path, data)
|
||
VALUES ($1, $2, $3, $4)
|
||
ON CONFLICT (host, config_type, path)
|
||
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
await conn.execute(query, host, config_type, path, json.dumps(data))
|
||
return True
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (upsert_leaf_option): {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (upsert_leaf_option): {e}")
|
||
return False
|
||
|
||
async def get_all_leaf_options(host: str, config_type: str) -> Optional[Dict[str, Any]]:
|
||
"""取得特定 host 與 config_type 的所有選項"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return None
|
||
|
||
query = """
|
||
SELECT path, data FROM cmts_options
|
||
WHERE host = $1 AND config_type = $2;
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
records = await conn.fetch(query, host, config_type)
|
||
result = {}
|
||
for record in records:
|
||
# asyncpg returns strings for JSON if not explicitly configured with type mapping,
|
||
# but usually it's fine to just json.loads it.
|
||
data_val = record['data']
|
||
if isinstance(data_val, str):
|
||
result[record['path']] = json.loads(data_val)
|
||
else:
|
||
result[record['path']] = data_val
|
||
return result
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (get_all_leaf_options): {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (get_all_leaf_options): {e}")
|
||
return None
|
||
|
||
async def delete_leaf_options(host: str, config_type: str, paths: List[str]) -> int:
|
||
"""刪除特定路徑的選項快取"""
|
||
pool = await get_pool()
|
||
if not pool or not paths:
|
||
return -1
|
||
|
||
query = """
|
||
DELETE FROM cmts_options
|
||
WHERE host = $1 AND config_type = $2 AND path = ANY($3);
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
# Execute returns the command tag, e.g., 'DELETE 5'
|
||
status = await conn.execute(query, host, config_type, paths)
|
||
deleted_count = int(status.split()[-1])
|
||
return deleted_count
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (delete_leaf_options): {e}")
|
||
return -1
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (delete_leaf_options): {e}")
|
||
return -1
|
||
|
||
# ------------------------------------------
|
||
# CRUD Functions for device_status
|
||
# ------------------------------------------
|
||
|
||
async def upsert_device_status(host: str, config_type: str, metadata: dict) -> bool:
|
||
"""更新設備的 metadata"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return False
|
||
|
||
cmts_version = metadata.get("cmts_version")
|
||
last_scanned = metadata.get("last_scanned", None)
|
||
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
# 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號!
|
||
if cmts_version and cmts_version != "unknown":
|
||
query = """
|
||
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
|
||
VALUES ($1, $2, $3, $4)
|
||
ON CONFLICT (host, config_type)
|
||
DO UPDATE SET
|
||
cmts_version = EXCLUDED.cmts_version,
|
||
last_scanned = EXCLUDED.last_scanned,
|
||
updated_at = CURRENT_TIMESTAMP;
|
||
"""
|
||
await conn.execute(query, host, config_type, cmts_version, last_scanned)
|
||
else:
|
||
query = """
|
||
INSERT INTO device_status (host, config_type, last_scanned)
|
||
VALUES ($1, $2, $3)
|
||
ON CONFLICT (host, config_type)
|
||
DO UPDATE SET
|
||
last_scanned = EXCLUDED.last_scanned,
|
||
updated_at = CURRENT_TIMESTAMP;
|
||
"""
|
||
await conn.execute(query, host, config_type, last_scanned)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"❌ DB Error (upsert_device_status): {e}")
|
||
return False
|
||
|
||
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
|
||
"""獲取設備 metadata"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return None
|
||
|
||
query = """
|
||
SELECT cmts_version, last_scanned FROM device_status
|
||
WHERE host = $1 AND config_type = $2;
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
record = await conn.fetchrow(query, host, config_type)
|
||
if record:
|
||
return {
|
||
"cmts_version": record["cmts_version"],
|
||
"last_scanned": record["last_scanned"]
|
||
}
|
||
return None
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (get_device_status): {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (get_device_status): {e}")
|
||
return None
|
||
|
||
# ------------------------------------------
|
||
# CRUD Functions for system_filters (Tree Filters)
|
||
# ------------------------------------------
|
||
async def upsert_tree_filters(config_type: str, hidden_keys: List[str]) -> bool:
|
||
"""寫入全域的樹狀圖隱藏節點名單"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return False
|
||
|
||
# Use 'global' as a dummy host to keep schema simple if needed,
|
||
# but a dedicated system_filters table is better.
|
||
query = """
|
||
INSERT INTO system_filters (config_type, hidden_keys)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT (config_type)
|
||
DO UPDATE SET hidden_keys = EXCLUDED.hidden_keys, updated_at = CURRENT_TIMESTAMP;
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
await conn.execute(query, config_type, hidden_keys)
|
||
return True
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (upsert_tree_filters): {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (upsert_tree_filters): {e}")
|
||
return False
|
||
|
||
async def get_tree_filters(config_type: str) -> Optional[List[str]]:
|
||
"""讀取全域的樹狀圖隱藏節點名單"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return None
|
||
|
||
query = """
|
||
SELECT hidden_keys FROM system_filters
|
||
WHERE config_type = $1;
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
record = await conn.fetchrow(query, config_type)
|
||
if record:
|
||
return record["hidden_keys"]
|
||
return []
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (get_tree_filters): {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (get_tree_filters): {e}")
|
||
return None
|
||
|
||
# ==========================================
|
||
# CRUD Functions for config_backups (Phase 1 & 2)
|
||
# ==========================================
|
||
|
||
async def insert_config_backup(
|
||
host: str,
|
||
config_type: str,
|
||
raw_cli: str,
|
||
parsed_tree: dict,
|
||
snapshot_name: Optional[str] = None,
|
||
description: str = "", # 🟢 新增描述參數 (預設為空字串)
|
||
is_auto: bool = False
|
||
) -> Optional[str]:
|
||
"""新增一筆設備配置備份,回傳產生的 Backup ID"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return None
|
||
|
||
backup_id = str(uuid.uuid4())
|
||
|
||
# 🟢 SQL 語句加入 description 與對應的 $5
|
||
query = """
|
||
INSERT INTO config_backups
|
||
(id, host, config_type, snapshot_name, description, is_auto, raw_cli, parsed_tree)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
await conn.execute(
|
||
query,
|
||
backup_id,
|
||
host,
|
||
config_type,
|
||
snapshot_name,
|
||
description, # 🟢 傳入描述參數
|
||
is_auto,
|
||
raw_cli,
|
||
json.dumps(parsed_tree)
|
||
)
|
||
return backup_id
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (insert_config_backup): {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (insert_config_backup): {e}")
|
||
return None
|
||
|
||
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
||
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return None
|
||
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
if config_type == "all":
|
||
# 🟢 SELECT 加入 description
|
||
query = """
|
||
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
|
||
FROM config_backups
|
||
WHERE host = $1
|
||
ORDER BY timestamp DESC;
|
||
"""
|
||
records = await conn.fetch(query, host)
|
||
else:
|
||
# 🟢 SELECT 加入 description
|
||
query = """
|
||
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
|
||
FROM config_backups
|
||
WHERE host = $1 AND config_type = $2
|
||
ORDER BY timestamp DESC;
|
||
"""
|
||
records = await conn.fetch(query, host, config_type)
|
||
|
||
return [
|
||
{
|
||
"id": str(r["id"]),
|
||
"host": r["host"],
|
||
"config_type": r["config_type"],
|
||
"timestamp": r["timestamp"].isoformat(),
|
||
"snapshot_name": r["snapshot_name"],
|
||
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
|
||
"is_auto": r["is_auto"]
|
||
}
|
||
for r in records
|
||
]
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (get_config_backup_list): {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (get_config_backup_list): {e}")
|
||
return None
|
||
|
||
async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
|
||
"""取得特定快照的完整內容 (包含 parsed_tree,供 Phase 3 還原使用)"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return None
|
||
|
||
# 🟢 SELECT 加入 description
|
||
query = """
|
||
SELECT id, host, timestamp, snapshot_name, description, is_auto, parsed_tree
|
||
FROM config_backups
|
||
WHERE id = $1;
|
||
"""
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
record = await conn.fetchrow(query, backup_id)
|
||
if record:
|
||
data_val = record['parsed_tree']
|
||
parsed_tree = json.loads(data_val) if isinstance(data_val, str) else data_val
|
||
|
||
return {
|
||
"id": str(record["id"]),
|
||
"host": record["host"],
|
||
"timestamp": record["timestamp"].isoformat(),
|
||
"snapshot_name": record["snapshot_name"],
|
||
"description": record["description"], # 🟢 將資料庫的描述放入回傳字典
|
||
"is_auto": record["is_auto"],
|
||
"parsed_tree": parsed_tree
|
||
}
|
||
return None
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (get_config_backup_detail): {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (get_config_backup_detail): {e}")
|
||
return None
|
||
|
||
async def delete_config_backup(backup_id: str) -> bool:
|
||
"""刪除指定的快照"""
|
||
pool = await get_pool()
|
||
if not pool:
|
||
return False
|
||
|
||
query = "DELETE FROM config_backups WHERE id = $1;"
|
||
try:
|
||
async with pool.acquire() as conn:
|
||
status = await conn.execute(query, backup_id)
|
||
# status 會是 'DELETE 1' 或 'DELETE 0'
|
||
return int(status.split()[-1]) > 0
|
||
except asyncpg.PostgresError as e:
|
||
logger.error(f"❌ DB Error (delete_config_backup): {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
|
||
return False
|
||
|
||
|
||
================================================================================
|
||
FILE: shared.py
|
||
================================================================================
|
||
# --- shared.py ---
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import re
|
||
from collections import defaultdict
|
||
|
||
DEBUG_MODE = False
|
||
USE_DB = True # PostgreSQL Feature Toggle
|
||
|
||
def debug_print(msg: str):
|
||
if DEBUG_MODE:
|
||
print(msg)
|
||
|
||
# 預設的 CMTS 連線樣板
|
||
CMTS_DEVICE = {
|
||
'device_type': 'cisco_ios',
|
||
'host': '10.14.110.4',
|
||
'username': 'admin',
|
||
'password': 'nsgadmin',
|
||
'port': 22,
|
||
'fast_cli': False,
|
||
'global_delay_factor': 2
|
||
}
|
||
|
||
def parse_cli_to_tree(cli_text: str) -> dict:
|
||
"""
|
||
將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
|
||
"""
|
||
# ==========================================
|
||
# 🌟 階段 0:預處理 (清理雜訊,移除危險的自動黏合)
|
||
# ==========================================
|
||
cli_text = cli_text.replace('\r\n', '\n').replace('\r', '\n')
|
||
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
||
|
||
raw_lines = cli_text.split('\n')
|
||
fixed_lines = []
|
||
|
||
for line in raw_lines:
|
||
line = ansi_escape.sub('', line)
|
||
line = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]', '', line)
|
||
|
||
if not line.strip():
|
||
continue
|
||
|
||
# 💡 乾淨俐落:直接加入陣列,不再做危險的字串長度黏合判斷
|
||
fixed_lines.append(line)
|
||
|
||
lines = [line.rstrip() for line in fixed_lines if line.strip()]
|
||
|
||
# ==========================================
|
||
# 🌟 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
|
||
# ==========================================
|
||
def build_raw_tree(start_idx, current_indent):
|
||
nodes = []
|
||
i = start_idx
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
stripped = line.strip()
|
||
indent = len(line) - len(line.lstrip())
|
||
|
||
if indent < current_indent and stripped != '!':
|
||
return nodes, i
|
||
|
||
if stripped == '!':
|
||
i += 1
|
||
continue
|
||
|
||
has_children = False
|
||
next_i = i + 1
|
||
while next_i < len(lines) and lines[next_i].strip() == '!':
|
||
next_i += 1
|
||
|
||
if next_i < len(lines):
|
||
next_line = lines[next_i]
|
||
next_indent = len(next_line) - len(next_line.lstrip())
|
||
if next_indent > indent:
|
||
has_children = True
|
||
|
||
if has_children:
|
||
children, next_i = build_raw_tree(i + 1, next_indent)
|
||
nodes.append({"type": "folder", "content": stripped, "children": children})
|
||
i = next_i
|
||
else:
|
||
nodes.append({"type": "leaf", "content": stripped})
|
||
i += 1
|
||
return nodes, i
|
||
|
||
raw_tree, _ = build_raw_tree(0, 0)
|
||
|
||
# ==========================================
|
||
# 🌟 階段 2:邏輯群組化與字典合併 (保留嚴格型別防護)
|
||
# ==========================================
|
||
def deep_merge(dict1, dict2):
|
||
for k, v in dict2.items():
|
||
if k in dict1:
|
||
if isinstance(dict1[k], dict) and isinstance(v, dict):
|
||
deep_merge(dict1[k], v)
|
||
else:
|
||
idx = 1
|
||
while f"{k} [{idx}]" in dict1:
|
||
idx += 1
|
||
dict1[f"{k} [{idx}]"] = v
|
||
else:
|
||
dict1[k] = v
|
||
|
||
def nest_folder_key(key_string, value_dict, target_dict):
|
||
parts = key_string.split()
|
||
current = target_dict
|
||
for i, part in enumerate(parts):
|
||
if i == len(parts) - 1:
|
||
if part not in current:
|
||
current[part] = value_dict
|
||
else:
|
||
if isinstance(current[part], dict) and isinstance(value_dict, dict):
|
||
deep_merge(current[part], value_dict)
|
||
else:
|
||
idx = 1
|
||
while f"{part} (衝突 {idx})" in current:
|
||
idx += 1
|
||
current[f"{part} (衝突 {idx})"] = value_dict
|
||
else:
|
||
if part not in current or not isinstance(current[part], dict):
|
||
if part in current and not isinstance(current[part], dict):
|
||
old_val = current[part]
|
||
current[part] = {"[0]": old_val}
|
||
else:
|
||
current[part] = {}
|
||
current = current[part]
|
||
|
||
def group_leaves(leaf_strings):
|
||
grouped = {}
|
||
first_word_map = defaultdict(list)
|
||
|
||
for s in leaf_strings:
|
||
parts = s.split(maxsplit=1)
|
||
first_word = parts[0]
|
||
remainder = parts[1] if len(parts) > 1 else ""
|
||
first_word_map[first_word].append(remainder)
|
||
|
||
for prefix, remainders in first_word_map.items():
|
||
if len(remainders) == 1:
|
||
grouped[prefix] = remainders[0]
|
||
else:
|
||
valid_remainders = [r for r in remainders if r]
|
||
empty_count = len(remainders) - len(valid_remainders)
|
||
|
||
group_dict = {}
|
||
if valid_remainders:
|
||
sub_grouped = group_leaves(valid_remainders)
|
||
is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
|
||
|
||
if is_all_empty_vals:
|
||
for i, k in enumerate(sub_grouped.keys()):
|
||
group_dict[f"[{i}]"] = k
|
||
else:
|
||
idx = 0
|
||
for k, v in sub_grouped.items():
|
||
if isinstance(v, dict):
|
||
group_dict[k] = v
|
||
elif v == "":
|
||
group_dict[f"[{idx}]"] = k
|
||
idx += 1
|
||
else:
|
||
group_dict[k] = v
|
||
|
||
for _ in range(empty_count):
|
||
idx = 0
|
||
while f"[{idx}]" in group_dict:
|
||
idx += 1
|
||
group_dict[f"[{idx}]"] = ""
|
||
|
||
grouped[prefix] = group_dict
|
||
|
||
return grouped
|
||
|
||
def fold_nodes(nodes):
|
||
result = {}
|
||
for node in [n for n in nodes if n["type"] == "folder"]:
|
||
folded_children = fold_nodes(node["children"])
|
||
nest_folder_key(node["content"], folded_children, result)
|
||
|
||
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
|
||
if leaves:
|
||
leaf_dict = group_leaves(leaves)
|
||
deep_merge(result, leaf_dict)
|
||
return result
|
||
|
||
return fold_nodes(raw_tree)
|
||
|
||
def deep_split_tree(data):
|
||
"""將平坦的 CLI 字典,依照空白字元拆分成深層巢狀結構 (具備終極型態衝突保護)"""
|
||
if not isinstance(data, dict):
|
||
return data
|
||
|
||
nested_tree = {}
|
||
for key, value in data.items():
|
||
processed_value = deep_split_tree(value)
|
||
|
||
parts = str(key).strip().split()
|
||
if not parts:
|
||
continue
|
||
|
||
current = nested_tree
|
||
for i, part in enumerate(parts):
|
||
if i == len(parts) - 1:
|
||
if part not in current:
|
||
current[part] = processed_value
|
||
else:
|
||
# 💡 終極衝突保護:安全轉型,絕不遺失資料
|
||
if isinstance(current[part], dict) and isinstance(processed_value, dict):
|
||
current[part].update(processed_value)
|
||
elif isinstance(current[part], dict) and not isinstance(processed_value, dict):
|
||
# 原本是資料夾,新來的是字串 -> 塞入虛擬 key [0], [1]
|
||
idx = 0
|
||
while f"[{idx}]" in current[part]: idx += 1
|
||
current[part][f"[{idx}]"] = processed_value
|
||
elif not isinstance(current[part], dict) and isinstance(processed_value, dict):
|
||
# 原本是字串,新來的是資料夾 -> 升級成資料夾,並保留原字串至 [0]
|
||
old_val = current[part]
|
||
current[part] = processed_value
|
||
idx = 0
|
||
while f"[{idx}]" in current[part]: idx += 1
|
||
current[part][f"[{idx}]"] = old_val
|
||
else:
|
||
# 兩者都是字串 -> 升級成資料夾,包含兩個字串
|
||
old_val = current[part]
|
||
current[part] = {"[0]": old_val, "[1]": processed_value}
|
||
else:
|
||
if part not in current:
|
||
current[part] = {}
|
||
elif not isinstance(current[part], dict):
|
||
# 中間節點原本是字串,必須升級為資料夾,並保留原字串
|
||
old_val = current[part]
|
||
current[part] = {"[0]": old_val}
|
||
current = current[part]
|
||
|
||
return nested_tree
|
||
|
||
# ==========================================
|
||
# 💡 系統設定管理 (System Settings Manager)
|
||
# ==========================================
|
||
SETTINGS_FILE = "system_settings.json"
|
||
|
||
def load_settings():
|
||
"""讀取系統設定,若檔案不存在則回傳預設的黑名單"""
|
||
if os.path.exists(SETTINGS_FILE):
|
||
try:
|
||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
pass
|
||
# 預設隱藏這些比較雜亂且不常修改的系統層級設定
|
||
return {"hidden_keys": ["logging", "privilege", "aaa", "certificate", "line"]}
|
||
|
||
def save_settings(settings_data):
|
||
"""將系統設定寫入 JSON 檔案"""
|
||
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(settings_data, f, indent=4, ensure_ascii=False)
|
||
|
||
# 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖
|
||
cmts_config_locks = defaultdict(asyncio.Lock)
|
||
|
||
|
||
================================================================================
|
||
FILE: index.html
|
||
================================================================================
|
||
<!DOCTYPE html>
|
||
<html lang="zh-TW">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Harmonic CMTS Manager</title>
|
||
<link rel="icon" type="image/png" href="/static/my_icon.png">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
|
||
<!-- 引入獨立的 CSS 樣式表 -->
|
||
<link rel="stylesheet" href="/static/style.css" />
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 1. 標題加上 id="app-title" -->
|
||
<h1 id="app-title" style="cursor: default;">🎙️ Harmonic CMTS Admin</h1>
|
||
|
||
<div class="global-settings">
|
||
<label><strong>🌐 目標設備:</strong></label>
|
||
<input type="text" id="cmtsHost" placeholder="IP 地址" value="10.14.110.4" style="width: 130px;">
|
||
<input type="text" id="cmtsUser" placeholder="帳號" value="admin" style="width: 100px;">
|
||
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;">
|
||
|
||
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
|
||
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
|
||
<button onclick="disconnectWebSocket()" id="btnDisconnect" class="btn-modern btn-disconnect" style="display: none;">斷開連線</button>
|
||
<span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
|
||
<button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
|
||
<button class="tab-btn" onclick="switchTab('config-tab', this)">🛠️ CMTS 設備配置</button>
|
||
<!-- 💡 新增:系統設定頁籤 -->
|
||
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
|
||
<!-- 💡 新增:備份與還原頁籤 -->
|
||
<button class="tab-btn" onclick="switchTab('backup-tab', this); loadBackupHistory();">💾 設備備份與還原</button>
|
||
</div>
|
||
|
||
<!-- Tab 1: True SSH Terminal -->
|
||
<div id="cli-tab" class="tab-content active">
|
||
<div class="control-group">
|
||
<label><strong>快捷指令:</strong></label>
|
||
<select id="fixedCmd">
|
||
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
|
||
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
|
||
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
|
||
</select>
|
||
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
|
||
|
||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||
<button onclick="downloadTerminal()" class="btn-modern btn-slate">💾 下載紀錄</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="terminal-container"></div>
|
||
</div>
|
||
|
||
<!-- Tab 2: CMTS 狀態查詢 -->
|
||
<div id="query-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
|
||
<div class="manager-container">
|
||
|
||
<div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
||
<label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
|
||
<select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
|
||
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
|
||
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
|
||
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 表單 1:Cable 狀態綜合查詢 -->
|
||
<div id="form-cm-query" class="task-form active">
|
||
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
|
||
<div class="form-grid">
|
||
<div class="form-row">
|
||
<label>目標設備 (Cable Modem MAC)</label>
|
||
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||
<datalist id="cm-mac-list"></datalist>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>查詢動作 (Action)</label>
|
||
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')">
|
||
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
|
||
<option value="base">基本狀態 (show cable modem)</option>
|
||
<option value="cpe">CPE 資訊 (cpe)</option>
|
||
<option value="cpe_dhcp">CPE DHCP (cpe dhcp)</option>
|
||
<option value="cpe_ipv6">CPE IPv6 (cpe ipv6)</option>
|
||
<option value="bonding">綁定摘要 (bonding)</option>
|
||
<option value="bonding_ds">下行綁定 (bonding downstream)</option>
|
||
<option value="bonding_us">上行綁定 (bonding upstream)</option>
|
||
<option value="phy">實體層狀態 (phy)</option>
|
||
<option value="verbose">詳細資訊 (verbose)</option>
|
||
<option value="ofdm_profile">OFDM Profile (ofdm-profile)</option>
|
||
<option value="ofdma_profile">OFDMA Profile (ofdma-profile)</option>
|
||
<option value="dhcp_verbose">DHCP 詳細資訊 (dhcp verbose)</option>
|
||
<option value="service_flow">Service Flow (service-flow)</option>
|
||
<option value="service_flow_verbose">Service Flow 詳細 (service-flow verbose)</option>
|
||
<option value="qos">QoS 資訊 (qos)</option>
|
||
<option value="uptime">運行時間 (uptime)</option>
|
||
<option value="ugs">UGS 資訊 (ugs)</option>
|
||
<option value="cm_status">CM 狀態 (cm-status)</option>
|
||
</optgroup>
|
||
<optgroup label="全域狀態查詢 (不分特定目標)">
|
||
<option value="partial_mode">Partial Mode (partial-mode)</option>
|
||
<option value="hop">Cable Hop (show cable hop)</option>
|
||
<option value="flap_list">Flap List (show cable flap-list)</option>
|
||
<option value="flap_sum">Flap Summary (show cable flap-sum)</option>
|
||
</optgroup>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button onclick="executeQuery('Cm')" class="btn-modern btn-load">執行查詢</button>
|
||
<div style="display: flex; align-items: center;">
|
||
<input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
||
<label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表單 2:RPD 狀態綜合查詢 -->
|
||
<div id="form-rpd-query" class="task-form">
|
||
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
|
||
<div class="form-grid">
|
||
<div class="form-row">
|
||
<label>目標設備 (RPD VC:VS)</label>
|
||
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
|
||
<datalist id="rpd-vcvs-list"></datalist>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>查詢動作 (Action)</label>
|
||
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')">
|
||
<option value="rpd_base">基本狀態 (show cable rpd)</option>
|
||
<option value="rpd_verbose">詳細資訊 (verbose)</option>
|
||
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
|
||
<option value="rpd_ptp_verbose">PTP 詳細資訊 (ptp verbose)</option>
|
||
<option value="rpd_counters_map">Counters Map (counters map)</option>
|
||
<option value="rpd_capabilities">能力資訊 (capabilities)</option>
|
||
<option value="rpd_video_counters">Video Counters (video-channel counters)</option>
|
||
<option value="rpd_env_temp">環境溫度 (environment temperature)</option>
|
||
<option value="rpd_env_volt">環境電壓 (environment voltage)</option>
|
||
<option value="rpd_session">Session (session)</option>
|
||
<option value="rpd_reset_history">重啟歷史 (reset-history)</option>
|
||
<option value="rpd_port_transceiver">光模組資訊 (port-transceiver)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button onclick="executeQuery('Rpd')" class="btn-modern btn-load">執行查詢</button>
|
||
<div style="display: flex; align-items: center;">
|
||
<input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
||
<label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表單 3:CM 一鍵診斷中心 -->
|
||
<div id="form-cm-diagnostics" class="task-form">
|
||
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
|
||
|
||
<!-- 搜尋區塊 -->
|
||
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
|
||
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC:</label>
|
||
<input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
|
||
<datalist id="diag-cm-mac-list"></datalist>
|
||
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
|
||
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
|
||
</div>
|
||
|
||
<!-- 結果顯示區塊 (預設隱藏) -->
|
||
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
|
||
|
||
<!-- 上半部:基本資訊與 PHY 狀態 -->
|
||
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||
<!-- 基本資訊卡片 -->
|
||
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
|
||
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
|
||
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
|
||
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
|
||
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
|
||
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- PHY 狀態卡片 -->
|
||
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
|
||
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
|
||
<tr style="border-bottom: 1px solid #ecf0f1;">
|
||
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
|
||
<td id="diagResTx" style="font-weight: bold;">-</td>
|
||
</tr>
|
||
<tr>
|
||
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
|
||
<td id="diagResRx" style="font-weight: bold;">-</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- 🌟 新增:上行通道標籤區塊 -->
|
||
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
|
||
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
|
||
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||
<!-- 動態生成標籤 -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 下半部:Chart.js MER 圖表 -->
|
||
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
|
||
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
|
||
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
|
||
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||
<!-- 動態生成按鈕 -->
|
||
</div>
|
||
</h4>
|
||
<div style="position: relative; height: 280px; width: 100%;">
|
||
<canvas id="merChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 3: CMTS 設備配置 -->
|
||
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
|
||
<div class="manager-container">
|
||
|
||
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
||
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
|
||
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
|
||
<!-- 🌟 拆分為兩個獨立的選項 -->
|
||
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
|
||
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
|
||
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
|
||
</select>
|
||
<button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
|
||
</div>
|
||
|
||
<!-- 表單 3:MAC Domain 配置精靈 -->
|
||
<div id="form-bonding-config" class="task-form active">
|
||
<h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
|
||
|
||
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
|
||
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain:</label>
|
||
<input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
||
<datalist id="mac-domain-list"></datalist>
|
||
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
|
||
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
|
||
</div>
|
||
|
||
<div id="macDomainConfigArea" style="display: none; margin-top: 20px;">
|
||
<!-- Common Settings -->
|
||
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
|
||
<div class="form-row"><label>Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||
<div class="form-row"><label>CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||
<div class="form-row"><label>CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||
<div class="form-row"><label>DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||
<div class="form-row"><label>DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||
<div class="form-row"><label>US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||
</div>
|
||
|
||
<!-- [Basic] DS/US Channel Sets -->
|
||
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||
<div class="form-row"><label>DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
|
||
<div class="form-row"><label>DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
|
||
<div class="form-row"><label>US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
|
||
<div class="form-row"><label>DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
|
||
<div class="form-row"><label>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
|
||
</div>
|
||
|
||
<!-- [Static] Downstream Bonding Groups -->
|
||
<div id="section_group_ds" style="margin-top: 30px;">
|
||
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
|
||
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||
<label>選擇要編輯的 DS Group:</label>
|
||
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
|
||
<input type="text" id="input_new_ds_group" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||
<div class="form-row"><label>Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
|
||
<div class="form-row"><label>OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
|
||
<div class="form-row"><label>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- [Static] Upstream Bonding Groups -->
|
||
<div id="section_group_us" style="margin-top: 30px;">
|
||
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
|
||
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||
<label>選擇要編輯的 US Group:</label>
|
||
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
|
||
<input type="text" id="input_new_us_group" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||
<div class="form-row"><label>US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
|
||
<div class="form-row"><label>OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
|
||
<div class="form-row"><label>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CLI 預覽區塊 -->
|
||
<div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>
|
||
|
||
<div class="form-actions">
|
||
<button id="btnRevert" onclick="revertFormChanges()" class="btn-modern btn-disconnect" style="margin-right: 15px;">🔄 復原重填</button>
|
||
<button onclick="generateMacDomainCLI()" class="btn-modern btn-scan">⚙️ 產生絕對路徑指令預覽</button>
|
||
<button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表單 4:設備配置樹狀圖 (共用容器) -->
|
||
<div id="form-tree-config" class="task-form" style="display: none;">
|
||
<!-- 頂部:標題與按鈕區塊 (維持原樣) -->
|
||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
|
||
<div style="display: flex; align-items: center; gap: 15px;">
|
||
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
|
||
<span id="tree-form-title">🌲 設備配置樹狀圖</span>
|
||
</h3>
|
||
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
|
||
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
|
||
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
||
</span>
|
||
</div>
|
||
<div style="display: flex; gap: 10px;">
|
||
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描局部缺失</button>
|
||
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">清除局部快取</button>
|
||
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
|
||
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 🌟 左右分屏容器 (關鍵修改:align-items: stretch 讓左右強制等高) -->
|
||
<div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
|
||
|
||
<!-- 左側:樹狀圖 (保留 max-height: 600px,超過才出捲軸) -->
|
||
<div id="tree-container-running" class="tree-view-instance" style="flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
||
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||
</div>
|
||
|
||
<div id="tree-container-full" class="tree-view-instance" style="display: none; flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
||
<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||
</div>
|
||
|
||
<!-- 🖱️ 拖曳分隔線 (拔除寫死的 height: 400px) -->
|
||
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; flex-shrink: 0; align-items: center; justify-content: center; z-index: 10;" title="左右拖曳調整寬度">
|
||
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
|
||
</div>
|
||
|
||
<!-- 右側:CLI 預覽與執行結果外框 -->
|
||
<div id="side-cli-preview" style="flex: 1; min-width: 300px; max-width: 50%; display: none; box-sizing: border-box;">
|
||
|
||
<!-- 🌟 內部黑底容器 (使用 flex column 與 height: 100% 完美填滿外框) -->
|
||
<div style="display: flex; flex-direction: column; height: 100%; background: #1e1e1e; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #34495e; box-sizing: border-box;">
|
||
|
||
<!-- 頂部標題列 -->
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
|
||
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
|
||
<div style="display: flex; align-items: center; gap: 20px;">
|
||
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
|
||
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
|
||
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 模式一:指令預覽框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||
<textarea id="side-cli-textarea" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; outline: none; box-sizing: border-box; white-space: pre; overflow-x: auto; overflow-y: auto; margin: 0; display: block; resize: none; min-height: 150px;"></textarea>
|
||
|
||
<!-- 模式二:執行結果框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||
<div id="side-execution-result" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; box-sizing: border-box; overflow-x: auto; overflow-y: auto; margin: 0; display: none; white-space: pre; min-height: 150px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 💡 Tab 4: 系統設定 (System Settings) -->
|
||
<div id="settings-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe; text-align: left;">
|
||
<div class="manager-container" style="display: block; max-width: 100%;">
|
||
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block;">
|
||
|
||
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
|
||
⚙️ 全域系統設定 (God Mode Filters)
|
||
</h3>
|
||
|
||
<div style="background: #f8f9fa; border-left: 4px solid #3498db; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
|
||
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
|
||
請勾選您希望在「完整設備配置樹狀圖」中 <b>隱藏</b> 的設定項目。<br>
|
||
<span style="color: #7f8c8d; font-size: 14px;">💡 點擊下方按鈕載入設備實時配置,您可以深入展開並勾選任何層級的節點進行過濾。</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
|
||
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
|
||
<label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
|
||
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
|
||
<option value="running">Running 配置過濾器</option>
|
||
<option value="full">Full 配置過濾器</option>
|
||
</select>
|
||
|
||
<button onclick="loadRealConfigForFilters()" class="btn-modern btn-load">
|
||
📥 載入設備配置以設定過濾
|
||
</button>
|
||
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
|
||
</div>
|
||
|
||
<!-- 樹狀 Checkbox 容器 -->
|
||
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
|
||
</div>
|
||
|
||
<div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
|
||
<button onclick="saveSystemSettings()" class="btn-modern btn-connect" style="padding: 10px 20px; font-size: 15px;">
|
||
💾 儲存並套用設定
|
||
</button>
|
||
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 🌟 新增:伺服器日誌管控面板 -->
|
||
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
|
||
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
|
||
🎛️ 伺服器日誌管控 (Server Log Management)
|
||
</h3>
|
||
|
||
<div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
|
||
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
|
||
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,<b>無需重啟伺服器</b>。<br>
|
||
<span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b> 或 <b>ERROR</b>,僅在需要排查問題時開啟 <b>DEBUG</b>。</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 矩陣式下拉選單容器 -->
|
||
<div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
|
||
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 💡 Tab 5: 設備備份與還原 -->
|
||
<div id="backup-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
|
||
<div class="manager-container">
|
||
|
||
<!-- 上半部:建立新快照 -->
|
||
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
|
||
|
||
<!-- 💡 修正:移除負 margin,讓底線與內容邊界對齊 (如同系統設定頁籤) -->
|
||
<div style="display: flex; justify-content: flex-start; align-items: center; gap: 15px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px;">
|
||
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px;">
|
||
📸 建立新快照
|
||
</h3>
|
||
|
||
<button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save" style="padding: 8px 20px; font-size: 14px; background-color: #2980b9; border: none; box-shadow: 0 2px 4px rgba(41, 128, 185, 0.3);">
|
||
🚀 立即執行備份
|
||
</button>
|
||
|
||
<span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none; font-weight: bold;">
|
||
⏳ 正在連線設備並抓取設定,請稍候...
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 表單網格區 -->
|
||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
|
||
<div>
|
||
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||
快照名稱 <span style="color: #e74c3c;">*</span>
|
||
</label>
|
||
<input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
|
||
</div>
|
||
<div>
|
||
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||
配置類型
|
||
</label>
|
||
<select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
|
||
<option value="running">Running Config (運行中配置)</option>
|
||
<option value="full">Full Config (完整配置)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 描述區 -->
|
||
<div style="margin-bottom: 0;">
|
||
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||
備份描述 <span style="color: #7f8c8d; font-weight: normal; font-size: 13px;">(選填)</span>
|
||
</label>
|
||
<input type="text" id="snapshotDescription" placeholder="請簡述此次備份的目的,例如:升級至 Unify FDD firmware,驗收通過" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px;">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 下半部:歷史紀錄列表 -->
|
||
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
|
||
|
||
<!-- 💡 修正:移除負 margin,讓底線與內容邊界對齊 -->
|
||
<div style="display: flex; align-items: center; gap: 20px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px; flex-wrap: wrap;">
|
||
|
||
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px; white-space: nowrap;">
|
||
📁 歷史備份紀錄
|
||
</h3>
|
||
|
||
<div style="display: flex; gap: 10px; align-items: center;">
|
||
<input type="text" id="filter-keyword" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
|
||
|
||
<select id="filter-type" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
|
||
<option value="">所有類型</option>
|
||
<option value="running">running</option>
|
||
<option value="full">full</option>
|
||
</select>
|
||
|
||
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
|
||
<div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
|
||
<input type="date" id="filter-date-start" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||
<span style="color: #7f8c8d; font-size: 13px;">至</span>
|
||
<input type="date" id="filter-date-end" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||
</div>
|
||
|
||
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
|
||
🔄 重新整理
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表格區塊 -->
|
||
<table style="width: 100%; border-collapse: collapse; text-align: left;">
|
||
<thead>
|
||
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
||
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
|
||
<th style="padding: 10px; color: #34495e; width: 25%;">快照名稱</th>
|
||
<th style="padding: 10px; color: #34495e; width: 25%;">描述</th>
|
||
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
|
||
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="backup-history-tbody">
|
||
<tr>
|
||
<td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 獨立的彈出式輸出視窗 (Modal) -->
|
||
<div id="outputModal" class="modal-overlay">
|
||
<div class="modal-container">
|
||
<div class="modal-header">
|
||
<div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
|
||
<span>📄 執行結果</span>
|
||
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
|
||
</div>
|
||
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
|
||
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body">
|
||
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 🌟 新增:God Mode 授權視窗 (Modal) -->
|
||
<div id="godModeModal" class="modal-overlay">
|
||
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
|
||
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
|
||
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
|
||
<div class="modal-title" style="justify-content: center; width: 100%;">
|
||
<span>🔐 系統進階授權</span>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
|
||
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
|
||
請輸入維護者密碼以解鎖隱藏功能
|
||
</p>
|
||
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
|
||
|
||
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
|
||
|
||
<div style="display: flex; gap: 15px; justify-content: center;">
|
||
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
|
||
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 引入獨立的 JavaScript 邏輯 -->
|
||
<script type="module" src="/static/app.js"></script>
|
||
|
||
</body>
|
||
</html>
|
||
|
||
|
||
================================================================================
|
||
FILE: init_db.py
|
||
================================================================================
|
||
import asyncio
|
||
import asyncpg
|
||
import logging
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# DB 設定
|
||
DB_CONFIG = {
|
||
"database": "cmts_nms",
|
||
"user": "swpa",
|
||
"password": "swpa4920",
|
||
"host": "127.0.0.1",
|
||
"port": "5432"
|
||
}
|
||
|
||
async def init_database():
|
||
try:
|
||
logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...")
|
||
conn = await asyncpg.connect(
|
||
database=DB_CONFIG["database"],
|
||
user=DB_CONFIG["user"],
|
||
password=DB_CONFIG["password"],
|
||
host=DB_CONFIG["host"],
|
||
port=int(DB_CONFIG["port"])
|
||
)
|
||
|
||
# 1. 建立選項快取表 cmts_options
|
||
logger.info("🛠️ 正在建立 cmts_options 資料表...")
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS cmts_options (
|
||
host VARCHAR(255) NOT NULL,
|
||
config_type VARCHAR(50) NOT NULL,
|
||
path VARCHAR(500) NOT NULL,
|
||
data JSONB NOT NULL,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (host, config_type, path)
|
||
);
|
||
""")
|
||
|
||
# 2. 建立設備狀態表 device_status
|
||
logger.info("🛠️ 正在建立 device_status 資料表...")
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS device_status (
|
||
host VARCHAR(255) NOT NULL,
|
||
config_type VARCHAR(50) NOT NULL,
|
||
cmts_version VARCHAR(100) DEFAULT 'unknown',
|
||
last_scanned VARCHAR(100),
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (host, config_type)
|
||
);
|
||
""")
|
||
|
||
# 3. 建立過濾器設定表 system_filters
|
||
logger.info("🛠️ 正在建立 system_filters 資料表...")
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS system_filters (
|
||
config_type VARCHAR(50) PRIMARY KEY,
|
||
hidden_keys TEXT[] DEFAULT '{}',
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
""")
|
||
|
||
# 4. 建立設備配置備份表 config_backups (Phase 1 新增)
|
||
logger.info("🛠️ 正在建立 config_backups 資料表與索引...")
|
||
await conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS config_backups (
|
||
id UUID PRIMARY KEY,
|
||
host VARCHAR(255) NOT NULL,
|
||
config_type VARCHAR(50) NOT NULL DEFAULT 'running',
|
||
timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
snapshot_name VARCHAR(255),
|
||
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
||
raw_cli TEXT,
|
||
parsed_tree JSONB,
|
||
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
||
);
|
||
""")
|
||
|
||
# 建立複合索引以加速列表查詢與排序
|
||
await conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
||
ON config_backups (host, config_type, timestamp DESC);
|
||
""")
|
||
|
||
await conn.close()
|
||
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ 資料庫初始化失敗: {e}")
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(init_database())
|
||
|
||
|
||
================================================================================
|
||
FILE: cmts_scraper.py
|
||
================================================================================
|
||
# --- cmts_scraper.py ---
|
||
import asyncio
|
||
import asyncssh
|
||
import re
|
||
import json
|
||
import os
|
||
import time
|
||
import database
|
||
from shared import USE_DB
|
||
from logger import get_logger
|
||
|
||
logger = get_logger("app.scraper")
|
||
|
||
def parse_question_mark_output(output: str) -> dict:
|
||
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
|
||
hint_lines = []
|
||
options = []
|
||
current_value = None
|
||
format_desc = None
|
||
is_format_only = False
|
||
|
||
state = "INIT"
|
||
has_description = False
|
||
has_bracket_value = False
|
||
|
||
# 🌟 Case-5 特殊處理:dhcp-relay 混合型欄位 (選項 + IP輸入)
|
||
# 直接在開頭掃描完整輸出,若包含 dhcp-relay,強制轉為純文字輸入框
|
||
if "dhcp-relay" in output.lower():
|
||
is_format_only = True
|
||
format_desc = "IP address or options"
|
||
|
||
for line in output.splitlines():
|
||
line = line.strip()
|
||
# 過濾雜訊與終端機提示字元
|
||
if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
|
||
continue
|
||
|
||
if line.startswith('Description:'):
|
||
state = "DESC"
|
||
has_description = True
|
||
hint_lines.append(line)
|
||
continue
|
||
|
||
if line.startswith('Possible completions:'):
|
||
state = "COMPLETIONS"
|
||
# 🌟 解除限制:無論有沒有 Description,都把這行加進 hint
|
||
hint_lines.append(line)
|
||
continue
|
||
|
||
if state == "DESC":
|
||
hint_lines.append(line)
|
||
|
||
elif state == "COMPLETIONS":
|
||
# Case 1~4: 無 Description 時,將後續選項說明也納入提示
|
||
# 🌟 解除限制:無論有沒有 Description,都把這行加進 hint
|
||
hint_lines.append(line)
|
||
|
||
# 規則 1: <格式說明>[當前值]
|
||
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
|
||
if match_format:
|
||
is_format_only = True
|
||
format_desc = match_format.group(1).strip()
|
||
if match_format.group(2):
|
||
current_value = match_format.group(2).strip()
|
||
continue
|
||
|
||
# 規則 1.5: 型態, 最小值 .. 最大值
|
||
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
|
||
if match_range:
|
||
is_format_only = True
|
||
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
|
||
continue
|
||
|
||
# 規則 1.6: IP address 等純文字關鍵字
|
||
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
|
||
if match_keyword:
|
||
is_format_only = True
|
||
format_desc = match_keyword.group(1)
|
||
continue
|
||
|
||
# 規則 2: 選項列表處理
|
||
match_current = re.search(r"^\[([^\]]+)\]", line)
|
||
if match_current:
|
||
has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單
|
||
if not current_value:
|
||
current_value = match_current.group(1).strip()
|
||
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
|
||
else:
|
||
clean_line = line.strip()
|
||
|
||
# 🌟 Case-6 防呆:過濾垂直列表的說明文字
|
||
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線
|
||
if re.search(r"\s{3,}", clean_line):
|
||
clean_line = re.split(r"\s{3,}", clean_line)[0]
|
||
|
||
parts = clean_line.split()
|
||
for p in parts:
|
||
if p and p not in ["|", ".."]:
|
||
options.append(p)
|
||
|
||
# 🌟 Case 2, 3, 4: 子命令防呆機制
|
||
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字
|
||
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
|
||
is_format_only = True
|
||
options = []
|
||
|
||
# 確保現值一定包含在選項中(如果它是一般的下拉選單)
|
||
if current_value and options and current_value not in options:
|
||
options.append(current_value)
|
||
|
||
hint_text = "\n".join(hint_lines).strip()
|
||
|
||
return {
|
||
"hint": hint_text,
|
||
# 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
|
||
"options": list(dict.fromkeys(options)) if not is_format_only else [],
|
||
"current_value": current_value,
|
||
"format_desc": format_desc,
|
||
"is_format_only": is_format_only
|
||
}
|
||
|
||
def parse_device_response(output: str) -> dict:
|
||
"""支援多種編輯狀態格式解析"""
|
||
match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
|
||
|
||
if match:
|
||
enum_content = match.group(1)
|
||
desc_content = match.group(2)
|
||
current_value = match.group(3).strip()
|
||
|
||
if enum_content:
|
||
if enum_content.strip().lower() == "list":
|
||
return {"type": "list", "options": [], "current_value": current_value}
|
||
else:
|
||
return {
|
||
"type": "enum",
|
||
"options": [opt.strip() for opt in enum_content.split(",") if opt.strip()],
|
||
"current_value": current_value
|
||
}
|
||
elif desc_content:
|
||
return {
|
||
"type": "string_or_number",
|
||
"options": [],
|
||
"current_value": current_value,
|
||
"format_desc": desc_content.strip()
|
||
}
|
||
|
||
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
|
||
|
||
# 🌟 1. 函數簽名加上 config_type 參數,預設為 "running"
|
||
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
|
||
try:
|
||
total_paths = len(leaf_paths)
|
||
processed_count = 0
|
||
result_data = {}
|
||
BATCH_SIZE = 30
|
||
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
|
||
|
||
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
|
||
|
||
for batch_idx, batch in enumerate(batches):
|
||
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
||
|
||
try:
|
||
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
|
||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||
|
||
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||
output = ""
|
||
while True:
|
||
try:
|
||
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||
if not chunk: break
|
||
output += chunk
|
||
if "--More--" in chunk or "More" in chunk:
|
||
process.stdin.write(" ")
|
||
await process.stdin.drain()
|
||
# 🌟 精準 Prompt 偵測
|
||
if prompt_pattern and re.search(prompt_pattern, output):
|
||
break
|
||
except asyncio.TimeoutError:
|
||
break
|
||
return output
|
||
|
||
# 🌟 新增:在第一批次連線時,先抓取設備版本
|
||
if cmts_version == "unknown":
|
||
# 🌟 補上 | nomore,並稍微延長等待時間,確保不會被分頁卡住
|
||
process.stdin.write("show version | nomore\n")
|
||
await process.stdin.drain()
|
||
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
|
||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
||
if match:
|
||
cmts_version = match.group(1)
|
||
|
||
process.stdin.write("config\n")
|
||
await process.stdin.drain()
|
||
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||
|
||
for path in batch:
|
||
# 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
|
||
await asyncio.sleep(0.01)
|
||
|
||
# --- 步驟 1:發送 '?' 查詢 ---
|
||
command_to_send = f"{path} ?"
|
||
process.stdin.write(command_to_send)
|
||
await process.stdin.drain()
|
||
|
||
output_question = await read_until_quiet(timeout=1.0)
|
||
q_data = parse_question_mark_output(output_question)
|
||
|
||
backspaces = "\x08" * (len(command_to_send) + 5)
|
||
process.stdin.write(backspaces)
|
||
await process.stdin.drain()
|
||
await read_until_quiet(timeout=0.5)
|
||
|
||
parsed_data = {
|
||
"hint": q_data["hint"],
|
||
"options": q_data["options"],
|
||
"type": "list" if q_data["options"] else "unknown",
|
||
"current_value": q_data.get("current_value")
|
||
}
|
||
|
||
# --- 步驟 2:判斷是否需要發送 Enter ---
|
||
if q_data.get("is_format_only"):
|
||
parsed_data["type"] = "string_or_number"
|
||
|
||
elif not q_data["options"]:
|
||
process.stdin.write(f"{path}\n")
|
||
await process.stdin.drain()
|
||
|
||
output_enter = await read_until_quiet(timeout=1.0)
|
||
enter_data = parse_device_response(output_enter)
|
||
|
||
parsed_data["type"] = enter_data["type"]
|
||
parsed_data["options"] = enter_data["options"]
|
||
parsed_data["current_value"] = enter_data["current_value"]
|
||
|
||
if enter_data["type"] != "unknown":
|
||
process.stdin.write("\x03")
|
||
await process.stdin.drain()
|
||
else:
|
||
logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
|
||
process.stdin.write("\n")
|
||
await process.stdin.drain()
|
||
|
||
await read_until_quiet(timeout=0.5)
|
||
|
||
result_data[path] = parsed_data
|
||
|
||
processed_count += 1
|
||
event_data = {
|
||
"event": "progress",
|
||
"current": processed_count,
|
||
"total": total_paths,
|
||
"current_path": path
|
||
}
|
||
yield json.dumps(event_data) + "\n"
|
||
|
||
process.stdin.write("exit\n")
|
||
await process.stdin.drain()
|
||
await read_until_quiet(timeout=1.0)
|
||
|
||
except Exception as e:
|
||
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
|
||
continue
|
||
|
||
# --- 步驟 3:寫入快取 (DB or JSON) ---
|
||
try:
|
||
db_success = False
|
||
current_ts = int(time.time())
|
||
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
|
||
# 1. 嘗試寫入資料庫
|
||
if USE_DB:
|
||
try:
|
||
# 寫入 metadata
|
||
if cmts_version != "unknown":
|
||
await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
|
||
else:
|
||
await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time})
|
||
|
||
# 寫入 options
|
||
db_write_count = 0
|
||
for p in batch:
|
||
if p in result_data:
|
||
result_data[p]["updated_at"] = current_ts
|
||
success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
|
||
if success:
|
||
db_write_count += 1
|
||
|
||
if db_write_count > 0:
|
||
db_success = True
|
||
logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
|
||
else:
|
||
logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
|
||
|
||
# 2. 如果 DB 寫入失敗或 USE_DB=False,則 Fallback 寫入 JSON
|
||
if not db_success:
|
||
# 🌟 動態決定快取檔名 (加入 IP 隔離)
|
||
safe_host = host.replace(".", "_")
|
||
cache_file = f"{safe_host}_{config_type}_cache.json"
|
||
|
||
def read_json_from_file(filepath):
|
||
if os.path.exists(filepath):
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
return {}
|
||
|
||
cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
|
||
|
||
# 🌟 新增:寫入 Metadata (版本與掃描時間)
|
||
if "__metadata__" not in cache_data:
|
||
cache_data["__metadata__"] = {}
|
||
|
||
if cmts_version != "unknown":
|
||
cache_data["__metadata__"]["cmts_version"] = cmts_version
|
||
|
||
cache_data["__metadata__"]["last_scanned"] = formatted_time
|
||
|
||
for p in batch:
|
||
if p in result_data:
|
||
result_data[p]["updated_at"] = current_ts
|
||
cache_data[p] = result_data[p]
|
||
|
||
def write_json_to_file(filepath, data):
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||
|
||
await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
|
||
logger.info(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
|
||
|
||
except Exception as e:
|
||
logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
|
||
|
||
if batch_idx < len(batches) - 1:
|
||
await asyncio.sleep(2)
|
||
|
||
yield json.dumps({"event": "done", "message": "快取更新完成"}) + "\n"
|
||
|
||
except Exception as e:
|
||
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
|
||
|
||
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
|
||
"""連線至設備並抓取完整的純文字設定檔"""
|
||
try:
|
||
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期
|
||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||
|
||
async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
|
||
output = ""
|
||
while True:
|
||
try:
|
||
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||
if not chunk: break
|
||
output += chunk
|
||
# 自動翻頁
|
||
if "--More--" in chunk or "More" in chunk:
|
||
process.stdin.write(" ")
|
||
await process.stdin.drain()
|
||
# 🌟 精準 Prompt 偵測
|
||
if prompt_pattern and re.search(prompt_pattern, output):
|
||
break
|
||
except asyncio.TimeoutError:
|
||
break
|
||
return output
|
||
|
||
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
||
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
|
||
|
||
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
|
||
if config_type == "full":
|
||
# 1. 先進入 config 模式
|
||
process.stdin.write("config\n")
|
||
await process.stdin.drain()
|
||
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
|
||
|
||
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
|
||
cmd = "show full-configuration | nomore"
|
||
process.stdin.write(f"{cmd}\n")
|
||
await process.stdin.drain()
|
||
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
|
||
|
||
# 3. 抓完後退回上一層 (保持良好習慣)
|
||
process.stdin.write("exit\n")
|
||
await process.stdin.drain()
|
||
|
||
else:
|
||
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
|
||
cmd = "show running-config | nomore"
|
||
process.stdin.write(f"{cmd}\n")
|
||
await process.stdin.drain()
|
||
raw_output = await read_until_quiet(timeout=3.0)
|
||
|
||
# 最終退出設備
|
||
process.stdin.write("exit\n")
|
||
await process.stdin.drain()
|
||
|
||
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
|
||
# 🌟 關鍵修正 1:強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
|
||
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
|
||
|
||
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
|
||
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
|
||
|
||
# 3. 清除純文字的 --More-- (防呆)
|
||
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
|
||
|
||
# 🌟 新增 4:清除 CableOS 終端機特有的 (END) 結尾標記
|
||
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
|
||
|
||
# 關鍵修正:這裡改用 cleaned_output 來切行!
|
||
lines = cleaned_output.splitlines()
|
||
|
||
# 🌟 關鍵修正 2:加入 strip() 避免空白干擾,確保精準踢掉提示字元
|
||
clean_lines = [
|
||
line for line in lines
|
||
if not line.strip().startswith(cmd)
|
||
and not line.strip().startswith("admin@")
|
||
]
|
||
return "\n".join(clean_lines).strip()
|
||
|
||
except Exception as e:
|
||
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
|
||
|
||
finally:
|
||
if conn:
|
||
conn.close()
|
||
|
||
def parse_config_to_tree(raw_cli: str) -> dict:
|
||
"""將純文字設定檔轉換為簡單的階層式 JSON 樹狀圖 (Phase 3 會用到)"""
|
||
# 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化
|
||
tree = {}
|
||
# 暫時回傳空字典,確保 Phase 1 & 2 能順利走通
|
||
# 真正的樹狀解析邏輯我們可以在 Phase 3 完善
|
||
return {"_raw_length": len(raw_cli), "status": "pending_parser"}
|
||
|
||
================================================================================
|
||
FILE: main.py
|
||
================================================================================
|
||
# --- main.py ---
|
||
import os
|
||
from fastapi import FastAPI
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import HTMLResponse
|
||
from contextlib import asynccontextmanager
|
||
import database
|
||
|
||
# 引入我們剛剛拆分出來的路由模組
|
||
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Startup
|
||
await database.init_db_pool()
|
||
yield
|
||
# Shutdown
|
||
await database.close_db_pool()
|
||
|
||
app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan)
|
||
|
||
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
|
||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||
|
||
# 🌟 統一標準:所有 REST API 都在這裡掛載 /api/v1 前綴
|
||
app.include_router(query.router, prefix="/api/v1")
|
||
app.include_router(config.router, prefix="/api/v1")
|
||
app.include_router(leaf_options.router, prefix="/api/v1")
|
||
app.include_router(lock.router, prefix="/api/v1")
|
||
app.include_router(backup.router, prefix="/api/v1")
|
||
app.include_router(diagnostics.router, prefix="/api/v1")
|
||
app.include_router(auth.router, prefix="/api/v1")
|
||
|
||
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
||
app.include_router(terminal.router)
|
||
|
||
# 根目錄路由:回傳前端 UI
|
||
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
||
async def read_root():
|
||
# 讀取同層級的 index.html
|
||
html_path = os.path.join(os.path.dirname(__file__), "index.html")
|
||
with open(html_path, "r", encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
# 啟動伺服器
|
||
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)
|
||
|
||
|
||
================================================================================
|
||
FILE: test_scraper.py
|
||
================================================================================
|
||
from cmts_scraper import sync_cmts_leaves
|
||
|
||
# 💡 直接套用您在 shared.py 中驗證過的完美設定
|
||
CMTS_DEVICE = {
|
||
'device_type': 'cisco_ios',
|
||
'host': '10.14.110.4',
|
||
'username': 'admin',
|
||
'password': 'nsgadmin',
|
||
'port': 22,
|
||
'fast_cli': False,
|
||
'global_delay_factor': 2
|
||
}
|
||
|
||
# 測試用的葉節點路徑
|
||
TEST_LEAVES = [
|
||
"clock timezone",
|
||
"cable mac-domain 13:0/0.0 admin-state"
|
||
]
|
||
|
||
if __name__ == "__main__":
|
||
print("🚀 開始執行 CMTS 爬蟲測試...")
|
||
result = sync_cmts_leaves(CMTS_DEVICE, TEST_LEAVES)
|
||
|
||
print("\n📊 最終測試結果:")
|
||
import json
|
||
print(json.dumps(result, indent=4, ensure_ascii=False))
|
||
|
||
|
||
================================================================================
|
||
FILE: logger.py
|
||
================================================================================
|
||
# --- logger.py ---
|
||
import logging
|
||
import sys
|
||
|
||
# 定義終端機輸出的 ANSI 顏色
|
||
COLORS = {
|
||
"DEBUG": "\033[36m", # 青色 (Cyan)
|
||
"INFO": "\033[32m", # 綠色 (Green)
|
||
"WARNING": "\033[33m", # 黃色 (Yellow)
|
||
"ERROR": "\033[31m", # 紅色 (Red)
|
||
"CRITICAL": "\033[1;31m",# 粗體紅色 (Bold Red)
|
||
"RESET": "\033[0m" # 重置顏色
|
||
}
|
||
|
||
class ColoredFormatter(logging.Formatter):
|
||
def format(self, record):
|
||
log_color = COLORS.get(record.levelname, COLORS["RESET"])
|
||
# 格式:時間 | 等級 | 模組名稱 | 訊息
|
||
format_str = f"{log_color}%(asctime)s | %(levelname)-7s | %(name)-15s | %(message)s{COLORS['RESET']}"
|
||
formatter = logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S")
|
||
return formatter.format(record)
|
||
|
||
# 定義系統中受管控的模組清單
|
||
MANAGED_LOGGERS = [
|
||
"app.database", # 資料庫操作
|
||
"app.scraper", # 爬蟲與快取
|
||
"app.diagnostics", # CM 診斷
|
||
"app.backup", # 備份與還原
|
||
"app.ssh" # SSH 連線引擎
|
||
]
|
||
|
||
def setup_logger(name: str, level=logging.INFO):
|
||
logger = logging.getLogger(name)
|
||
logger.setLevel(level)
|
||
# 避免重複添加 Handler 導致日誌印兩次
|
||
if not logger.handlers:
|
||
handler = logging.StreamHandler(sys.stdout)
|
||
handler.setFormatter(ColoredFormatter())
|
||
logger.addHandler(handler)
|
||
logger.propagate = False # 防止日誌往上傳遞給 root logger (避免被 FastAPI 預設格式覆蓋)
|
||
return logger
|
||
|
||
# 系統啟動時,初始化所有模組,預設等級為 INFO
|
||
for name in MANAGED_LOGGERS:
|
||
setup_logger(name, logging.INFO)
|
||
|
||
def get_logger(name: str):
|
||
"""供各個檔案引入 logger 使用"""
|
||
if name not in MANAGED_LOGGERS:
|
||
return setup_logger(name)
|
||
return logging.getLogger(name)
|
||
|
||
def get_all_log_levels():
|
||
"""取得目前所有模組的日誌等級 (供前端 UI 顯示)"""
|
||
return {name: logging.getLevelName(logging.getLogger(name).level) for name in MANAGED_LOGGERS}
|
||
|
||
def set_log_level(name: str, level_str: str):
|
||
"""動態設定特定模組的日誌等級"""
|
||
if name in MANAGED_LOGGERS:
|
||
level = getattr(logging, level_str.upper(), logging.INFO)
|
||
logging.getLogger(name).setLevel(level)
|
||
return True
|
||
return False
|
||
|
||
|
||
================================================================================
|
||
FILE: PROJECT_STATE.md
|
||
================================================================================
|
||
# 📖 Harmonic CMTS Manager - Project State (Living Document)
|
||
|
||
> ⚠️ **AI 助手請注意**:本專案的核心架構與開發規範已移至 `.clinerules`。此檔案僅作為「專案進度存檔」與「待辦事項追蹤」使用。
|
||
|
||
---
|
||
|
||
## ✅ 已完成開發階段 (Completed Phases)
|
||
|
||
### Phase 1: PostgreSQL 高可用性架構升級 (Completed)
|
||
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
|
||
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
|
||
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
|
||
- [x] 調整 `init_db.py` 為非同步啟動腳本。
|
||
|
||
### Phase 2: 設備配置備份與快照機制 (Completed)
|
||
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。
|
||
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
|
||
- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。
|
||
- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
|
||
|
||
### Phase 3: 智慧差異還原與歷史預覽 (Completed)
|
||
- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
|
||
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。
|
||
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
|
||
- [x] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 `ReadableStream` 即時渲染後端傳來的 SSH 逐行執行 Log,並具備自動捲動功能。
|
||
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:
|
||
- 實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應)。
|
||
- 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止、發送 `abort` 撤銷變更,並回傳錯誤 Chunk。
|
||
- [x] **效能與體驗優化**:完成 DOM 陣列渲染優化、Terminal 資源回收 (防殭屍連線)、精準 Prompt 偵測,以及 UI 視窗連動防呆機制。
|
||
|
||
---
|
||
|
||
## 🚧 目前開發階段 (Current Phase)
|
||
|
||
### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management)
|
||
- [ ] **自動備份攔截 (Auto-Backup Interceptor)**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制,確保每次變更前都有還原點。
|
||
- [ ] **備份保留策略 (Retention Policy)**:實作定期清理過期或過多歷史快照的機制,防止資料庫無限膨脹。
|
||
|
||
---
|
||
|
||
## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
|
||
- 目前系統運行穩定,各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。
|
||
|
||
|
||
================================================================================
|
||
FILE: static/api.js
|
||
================================================================================
|
||
// --- static/api.js ---
|
||
|
||
// ==========================================
|
||
// 1. 鎖定管理 (Lock Management)
|
||
// ==========================================
|
||
export async function apiAcquireLock(path, userId, username, host) {
|
||
const res = await fetch('/api/v1/locks/acquire', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path, user_id: userId, username, host })
|
||
});
|
||
const data = await res.json();
|
||
return { status: res.status, data };
|
||
}
|
||
|
||
export async function apiReleaseLock(path, userId, host) {
|
||
return fetch('/api/v1/locks/release', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path, user_id: userId, host })
|
||
});
|
||
}
|
||
|
||
export async function apiSendHeartbeat(path, userId, host) {
|
||
return fetch('/api/v1/locks/heartbeat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path, user_id: userId, host })
|
||
});
|
||
}
|
||
|
||
export async function apiGetLockStatus(host) {
|
||
// 帶入 host 參數,實現 IP 隔離查詢
|
||
const url = host ? `/api/v1/locks/status?host=${encodeURIComponent(host)}` : '/api/v1/locks/status';
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
// ==========================================
|
||
// 2. 樹狀圖與選項快取 (Tree & Options)
|
||
// ==========================================
|
||
export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
|
||
// 🌟 將 config_type 加入 URL 參數中
|
||
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`;
|
||
|
||
if (skipFilter) url += '&skip_filter=true';
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
|
||
export async function apiGetLeafOptions(host, configType = 'running') {
|
||
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
|
||
return response.json();
|
||
}
|
||
|
||
// 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
|
||
export async function apiSyncLeafOptions(host, paths, configType = 'running') {
|
||
return fetch('/api/v1/cmts-leaf-options/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
|
||
});
|
||
}
|
||
|
||
// 🌟 3. 清除快取 (加上 configType 查詢參數)
|
||
export async function apiClearCache(host, paths, configType = 'running') {
|
||
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(paths)
|
||
});
|
||
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiGetCmtsVersion(host, username, password) {
|
||
const url = `/api/v1/cmts-version?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiGetScanStatus(host, configType = 'running') {
|
||
try {
|
||
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`);
|
||
const data = await response.json();
|
||
return data.is_scanning;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 3. 指令生成與執行 (CLI Generation & Execution)
|
||
// ==========================================
|
||
export async function apiGenerateCli(diffs, interfaces) {
|
||
const response = await fetch('/api/v1/cmts-generate-cli', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ diffs, interfaces_with_admin_state: interfaces })
|
||
});
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiExecuteConfig(script, host, username, password) {
|
||
const response = await fetch('/api/v1/cmts-config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ script, host, username, password })
|
||
});
|
||
return response.json();
|
||
}
|
||
|
||
// ==========================================
|
||
// 4. 系統設定與過濾器 (System Settings)
|
||
// ==========================================
|
||
// 🌟 加上 configType 參數
|
||
export async function apiGetTreeFilters(configType = 'running') {
|
||
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
|
||
return response.json();
|
||
}
|
||
|
||
// 🌟 加上 configType 參數
|
||
export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
|
||
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
|
||
});
|
||
return response.json();
|
||
}
|
||
|
||
// ==========================================
|
||
// 5. MAC Domain 與綜合查詢 (MAC Domain & Query)
|
||
// ==========================================
|
||
export async function apiGetMacDomainList(host, username, password) {
|
||
const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiGetMacDomainConfig(target, host, username, password) {
|
||
const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiExecuteQuery(queryType, target, host, username, password) {
|
||
const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
// 🌟 新增:動態抓取設備清單 API
|
||
export async function apiListCms(host, username, password) {
|
||
const url = `/api/v1/list-cms?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiListRpds(host, username, password) {
|
||
const url = `/api/v1/list-rpds?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
// ==========================================
|
||
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
|
||
// ==========================================
|
||
// 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
|
||
export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
|
||
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
|
||
});
|
||
|
||
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
|
||
|
||
// 🌟 核心修改:讀取串流資料
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break; // 伺服器斷開連線 (任務完成)
|
||
|
||
// 解碼二進位資料為文字
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
|
||
// 因為一次可能收到多行 JSON,我們用換行符號切開
|
||
const lines = chunk.split("\n").filter(line => line.trim() !== "");
|
||
|
||
for (const line of lines) {
|
||
try {
|
||
const data = JSON.parse(line);
|
||
onProgress(data); // 將解析後的資料傳給 UI 介面
|
||
} catch (e) {
|
||
console.error("JSON 解析錯誤:", e, line);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function apiGetCmDiagnostics(host, username, password, mac) {
|
||
const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`;
|
||
const response = await fetch(url);
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.detail || "伺服器發生錯誤");
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiGetLogLevels() {
|
||
const response = await fetch('/api/v1/settings/logs');
|
||
return response.json();
|
||
}
|
||
|
||
export async function apiSetLogLevel(module, level) {
|
||
const response = await fetch('/api/v1/settings/logs', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ module, level })
|
||
});
|
||
return response.json();
|
||
}
|
||
|
||
// ==========================================
|
||
// 7. 授權與驗證 API (Auth)
|
||
// ==========================================
|
||
export async function apiVerifyGodMode(password) {
|
||
const response = await fetch('/api/v1/auth/god-mode', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.detail || "驗證失敗");
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
|
||
================================================================================
|
||
FILE: static/app.js
|
||
================================================================================
|
||
/* --- static/app.js --- */
|
||
|
||
// ============================================================================
|
||
// 📦 模組匯入區 (Imports)
|
||
// ============================================================================
|
||
|
||
// 1. 終端機模組 (Terminal)
|
||
import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, downloadTerminal, isConnected } from './terminal.js';
|
||
|
||
// 2. API 網路請求模組 (API)
|
||
import {
|
||
apiGetFullConfig, apiClearCache, apiExecuteQuery,
|
||
apiGetTreeFilters, apiSaveTreeFilters,
|
||
apiSyncLeafOptionsStream, apiGetCmtsVersion,
|
||
apiGetScanStatus, apiGetLockStatus,
|
||
apiGetCmDiagnostics,
|
||
apiListCms, apiListRpds,
|
||
apiGetLogLevels, apiSetLogLevel,
|
||
apiVerifyGodMode
|
||
} from './api.js';
|
||
|
||
// 3. 共用工具模組 (Utils)
|
||
import { getGlobalConnectionInfo } from './utils.js';
|
||
|
||
// 4. 樹狀圖 UI 模組 (Tree UI)
|
||
import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js';
|
||
|
||
// 5. 編輯模式與鎖定模組 (Edit Mode)
|
||
import {
|
||
releaseAllLocks, startEditFolder, startEditLeaf,
|
||
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
|
||
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId
|
||
} from './edit-mode.js';
|
||
|
||
// 6. MAC Domain 配置模組 (MAC Domain)
|
||
import {
|
||
hasMacDomainData, resetMacDomainData, loadMacDomainList, fetchMacDomainConfig,
|
||
populateFormFromData, revertFormChanges, toggleGroupVisibility,
|
||
loadDsGroupData, loadUsGroupData, generateMacDomainCLI, executeBondingConfig
|
||
} from './mac-domain.js';
|
||
|
||
|
||
// ============================================================================
|
||
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
|
||
// ============================================================================
|
||
|
||
let idleTimer;
|
||
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
|
||
let globalSSE = null;
|
||
|
||
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
|
||
function initGlobalSSE(mode = 'running') {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
|
||
if (globalSSE) globalSSE.close();
|
||
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
||
|
||
globalSSE.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
const statusEl = document.getElementById('scan-status');
|
||
|
||
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
|
||
if (data.event === 'scan_start' || data.event === 'progress') {
|
||
setAdminButtonsState(true, "⏳ 系統更新中...");
|
||
}
|
||
|
||
if (data.event === 'scan_start') {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
|
||
statusEl.style.color = "#f39c12";
|
||
}
|
||
}
|
||
else if (data.event === 'progress') {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
|
||
statusEl.style.color = "#f39c12";
|
||
}
|
||
}
|
||
else if (data.event === 'done') {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
|
||
statusEl.style.color = "#27ae60";
|
||
setTimeout(() => statusEl.textContent = "", 5000);
|
||
}
|
||
// 掃描完成才解鎖
|
||
setAdminButtonsState(false);
|
||
localStorage.removeItem('scanLockUntil');
|
||
}
|
||
else if (data.event === 'error') {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
|
||
statusEl.style.color = "#e74c3c";
|
||
}
|
||
// 發生錯誤也要解鎖
|
||
setAdminButtonsState(false);
|
||
localStorage.removeItem('scanLockUntil');
|
||
}
|
||
};
|
||
}
|
||
|
||
// 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線
|
||
window.addEventListener('beforeunload', () => {
|
||
if (globalSSE) {
|
||
globalSSE.close();
|
||
globalSSE = null;
|
||
}
|
||
});
|
||
|
||
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離)
|
||
let activeLockState = new Set();
|
||
let lockPollingTimer = null;
|
||
let currentPollingHost = null; // 記錄當前輪詢的設備 IP
|
||
window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } }
|
||
window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown)
|
||
|
||
function startLockStatusPolling() {
|
||
if (lockPollingTimer) clearInterval(lockPollingTimer);
|
||
|
||
lockPollingTimer = setInterval(async () => {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return;
|
||
const host = connInfo.host;
|
||
|
||
// 🛡️ 跨設備防呆:如果使用者切換了 IP,必須清空舊設備的鎖定記憶,防止幽靈解鎖
|
||
if (currentPollingHost !== host) {
|
||
activeLockState.clear();
|
||
currentPollingHost = host;
|
||
}
|
||
|
||
try {
|
||
const result = await apiGetLockStatus(host);
|
||
if (result.status === 'success') {
|
||
const currentLocks = result.data;
|
||
window.globalActiveLocks[host] = currentLocks;
|
||
const newLockState = new Set();
|
||
const now = Date.now();
|
||
|
||
// 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點
|
||
for (const [path, lockInfo] of Object.entries(currentLocks)) {
|
||
const lockKey = `${host}@@${path}`;
|
||
|
||
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
|
||
if (window.recentlyReleasedLocks[lockKey]) {
|
||
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
|
||
continue; // 跳過,不當作鎖定
|
||
} else {
|
||
delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶
|
||
}
|
||
}
|
||
|
||
newLockState.add(lockKey); // 記憶體加入 Host 標籤
|
||
|
||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
|
||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
|
||
|
||
targetBtns.forEach(btn => {
|
||
// 略過目前正在編輯的那個實體按鈕
|
||
if (btn.id === `edit-btn-${currentEditElementId}`) return;
|
||
|
||
if (btn.innerText !== "🔒") {
|
||
btn.style.pointerEvents = 'none';
|
||
btn.style.opacity = '0.2';
|
||
|
||
const btnPath = btn.getAttribute('data-path');
|
||
if (btnPath === path) {
|
||
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
|
||
if (lockInfo.user_id !== SESSION_USER_ID) {
|
||
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
||
} else {
|
||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
||
}
|
||
} else {
|
||
// 子節點被父節點鎖定
|
||
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||
}
|
||
btn.innerText = "🔒";
|
||
}
|
||
});
|
||
}
|
||
|
||
// 🔓 任務 2:處理「解除鎖定」的節點
|
||
activeLockState.forEach(oldKey => {
|
||
const [oldHost, oldPath] = oldKey.split('@@');
|
||
|
||
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
|
||
if (oldHost === host && !newLockState.has(oldKey)) {
|
||
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
|
||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
|
||
|
||
targetBtns.forEach(btn => {
|
||
if (btn.innerText === "🔒") {
|
||
const btnPath = btn.getAttribute('data-path');
|
||
let stillLockedByOther = false;
|
||
|
||
// 檢查自己是否還在鎖定清單中
|
||
if (currentLocks[btnPath]) {
|
||
stillLockedByOther = true;
|
||
} else {
|
||
// 檢查是否有其他父節點鎖定它
|
||
for (const lockedPath of Object.keys(currentLocks)) {
|
||
if (btnPath.startsWith(lockedPath + "::")) {
|
||
stillLockedByOther = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!stillLockedByOther) {
|
||
btn.style.pointerEvents = 'auto';
|
||
btn.style.opacity = '0.3';
|
||
btn.title = "鎖定並編輯此項目";
|
||
btn.innerText = "✏️";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// 💾 任務 3:更新特務的記憶
|
||
activeLockState = newLockState;
|
||
}
|
||
} catch (error) {
|
||
console.warn("獲取鎖定狀態失敗:", error);
|
||
}
|
||
}, 15000);
|
||
}
|
||
|
||
// 頁面載入與縮放初始化
|
||
window.onload = () => {
|
||
initTerminal();
|
||
resetIdleTimer(); // 頁面載入時啟動閒置計時器
|
||
initGlobalSSE(); // 啟動全域廣播監聽
|
||
startLockStatusPolling(); // 啟動鎖定狀態輪詢
|
||
|
||
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
|
||
const configTaskSelect = document.getElementById('configTask');
|
||
if (configTaskSelect) {
|
||
configTaskSelect.dispatchEvent(new Event('change'));
|
||
}
|
||
};
|
||
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
|
||
|
||
// 閒置計時器重置邏輯 (超時將自動釋放所有編輯鎖定)
|
||
function resetIdleTimer() {
|
||
clearTimeout(idleTimer);
|
||
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
|
||
}
|
||
|
||
// 🌟 效能急救:節流閥 (Throttle) 機制
|
||
let throttleTimer = false;
|
||
function throttledReset() {
|
||
if (!throttleTimer) {
|
||
resetIdleTimer();
|
||
throttleTimer = true;
|
||
setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設
|
||
}
|
||
}
|
||
window.addEventListener('mousemove', throttledReset);
|
||
window.addEventListener('keydown', throttledReset);
|
||
window.addEventListener('click', throttledReset);
|
||
|
||
// ============================================================================
|
||
// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
|
||
// ============================================================================
|
||
|
||
// 切換主頁籤 (終端機 / 設備查詢 / 設備配置 / 系統設定)
|
||
function switchTab(tabId, element) {
|
||
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||
|
||
const targetTab = document.getElementById(tabId);
|
||
if (targetTab) targetTab.classList.add('active');
|
||
if (element) element.classList.add('active');
|
||
|
||
if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
|
||
}
|
||
|
||
// 切換「設備查詢」內的子任務表單
|
||
// ============================================================================
|
||
// 🌟 設備查詢動態清單快取 (新增)
|
||
// ============================================================================
|
||
let cachedDeviceLists = { cms: null, rpds: null, host: null };
|
||
|
||
// 切換「設備查詢」內的子任務表單
|
||
function switchQueryTask() {
|
||
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
|
||
const selectedTaskId = document.getElementById('queryTask').value;
|
||
const targetForm = document.getElementById(selectedTaskId);
|
||
if (targetForm) targetForm.classList.add('active');
|
||
|
||
// 🌟 觸發背景動態載入 Datalist
|
||
loadDynamicDatalist(selectedTaskId);
|
||
}
|
||
|
||
// 🌟 新增:背景載入 Datalist 邏輯
|
||
async function loadDynamicDatalist(taskType) {
|
||
// 若尚未連線,則不觸發背景抓取 (不跳警告,默默 return)
|
||
if (!isConnected) return;
|
||
|
||
const host = document.getElementById('cmtsHost').value.trim();
|
||
const user = document.getElementById('cmtsUser').value.trim();
|
||
const pass = document.getElementById('cmtsPass').value.trim();
|
||
if (!host || !user || !pass) return;
|
||
|
||
// 若切換了設備 IP,清空舊快取
|
||
if (cachedDeviceLists.host !== host) {
|
||
cachedDeviceLists = { cms: null, rpds: null, host: host };
|
||
}
|
||
|
||
if (taskType === 'form-cm-query' || taskType === 'form-cm-diagnostics') {
|
||
const inputId = taskType === 'form-cm-query' ? 'queryTargetCm' : 'diagMacInput';
|
||
const listId = taskType === 'form-cm-query' ? 'cm-mac-list' : 'diag-cm-mac-list';
|
||
const defaultPlaceholder = taskType === 'form-cm-query' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例如: 6467.7240.4076";
|
||
|
||
await fetchAndBindList('cms', inputId, listId, defaultPlaceholder, host, user, pass);
|
||
} else if (taskType === 'form-rpd-query') {
|
||
await fetchAndBindList('rpds', 'queryTargetRpd', 'rpd-vcvs-list', "例: 13:0 (留白則查詢全體)", host, user, pass);
|
||
}
|
||
}
|
||
|
||
// 🌟 新增:執行 API 呼叫與 UX 狀態切換
|
||
async function fetchAndBindList(type, inputId, listId, defaultPlaceholder, host, user, pass) {
|
||
const inputEl = document.getElementById(inputId);
|
||
const datalistEl = document.getElementById(listId);
|
||
if (!inputEl || !datalistEl) return;
|
||
|
||
// 如果已經有快取,直接綁定並結束
|
||
if (cachedDeviceLists[type]) {
|
||
populateDatalist(datalistEl, cachedDeviceLists[type]);
|
||
return;
|
||
}
|
||
|
||
// 狀態 1:發出請求前 (鎖定輸入框,提示載入中)
|
||
inputEl.disabled = true;
|
||
inputEl.placeholder = "🔄 載入設備清單中...";
|
||
inputEl.style.backgroundColor = "#fdfefe";
|
||
|
||
try {
|
||
let data;
|
||
if (type === 'cms') {
|
||
data = await apiListCms(host, user, pass);
|
||
cachedDeviceLists.cms = data.cms || [];
|
||
} else {
|
||
data = await apiListRpds(host, user, pass);
|
||
cachedDeviceLists.rpds = data.rpds || [];
|
||
}
|
||
|
||
// 狀態 2:請求成功 (塞入選項,恢復輸入框)
|
||
populateDatalist(datalistEl, cachedDeviceLists[type]);
|
||
inputEl.placeholder = defaultPlaceholder;
|
||
} catch (error) {
|
||
console.error(`[Background Task] Failed to load ${type}:`, error);
|
||
// 狀態 3:請求失敗 (提示失敗,但仍解鎖讓使用者手動輸入)
|
||
inputEl.placeholder = "⚠️ 清單載入失敗,請手動輸入";
|
||
} finally {
|
||
inputEl.disabled = false;
|
||
inputEl.style.backgroundColor = ""; // 恢復預設背景色
|
||
}
|
||
}
|
||
|
||
// 🌟 新增:將陣列轉換為 <option> 塞入 <datalist>
|
||
function populateDatalist(datalistEl, items) {
|
||
datalistEl.innerHTML = '';
|
||
items.forEach(item => {
|
||
const option = document.createElement('option');
|
||
option.value = item;
|
||
datalistEl.appendChild(option);
|
||
});
|
||
}
|
||
|
||
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
|
||
let lastTreeMode = null;
|
||
|
||
// 切換「設備配置」內的子任務表單
|
||
function switchConfigTask() {
|
||
document.querySelectorAll('#config-tab .task-form').forEach(form => {
|
||
form.classList.remove('active');
|
||
form.style.display = 'none';
|
||
});
|
||
|
||
const selectedTaskId = document.getElementById('configTask').value;
|
||
|
||
let targetFormId = selectedTaskId;
|
||
if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') {
|
||
targetFormId = 'form-tree-config';
|
||
}
|
||
|
||
const targetForm = document.getElementById(targetFormId);
|
||
if (targetForm) {
|
||
targetForm.classList.add('active');
|
||
targetForm.style.display = 'block';
|
||
|
||
const titleSpan = document.getElementById('tree-form-title');
|
||
const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running';
|
||
|
||
if (titleSpan) {
|
||
titleSpan.textContent = currentMode === 'running'
|
||
? '🌳 設備配置樹狀圖 (running-config)'
|
||
: '🌳 完整設備配置樹狀圖 (full configuration)';
|
||
}
|
||
|
||
if (targetFormId === 'form-tree-config') {
|
||
// 🌟 魔法所在:純視覺切換,不銷毀資料!
|
||
const runningContainer = document.getElementById('tree-container-running');
|
||
const fullContainer = document.getElementById('tree-container-full');
|
||
if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none';
|
||
if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none';
|
||
|
||
if (lastTreeMode !== currentMode) {
|
||
const statusEl = document.getElementById('scan-status');
|
||
if (statusEl) statusEl.textContent = '';
|
||
|
||
// 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏!
|
||
const loadingMsg = document.getElementById('loading-message');
|
||
if (loadingMsg) loadingMsg.style.display = 'none';
|
||
|
||
lastTreeMode = currentMode;
|
||
}
|
||
}
|
||
|
||
initGlobalSSE(currentMode);
|
||
}
|
||
}
|
||
|
||
// 啟動配置任務 (樹狀圖載入 或 MAC Domain 精靈)
|
||
function startConfigTask() {
|
||
if (!isConnected) {
|
||
alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!");
|
||
return;
|
||
}
|
||
switchConfigTask();
|
||
const selectedTaskId = document.getElementById('configTask').value;
|
||
|
||
if (selectedTaskId === 'form-bonding-config') {
|
||
if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
|
||
resetAllManagerData();
|
||
loadMacDomainList();
|
||
}
|
||
// 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType
|
||
else if (selectedTaskId === 'form-running-config') {
|
||
fetchFullConfig('running');
|
||
}
|
||
else if (selectedTaskId === 'form-full-config') {
|
||
fetchFullConfig('full'); // 🌟 補上 full 參數
|
||
}
|
||
}
|
||
|
||
// 切換查詢輸入框的啟用狀態 (全域指令不需輸入目標)
|
||
function toggleQueryInputs(mode) {
|
||
const type = document.getElementById('queryType' + mode).value;
|
||
const targetInput = document.getElementById('queryTarget' + mode);
|
||
const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum'];
|
||
|
||
if (globalTypes.includes(type)) {
|
||
targetInput.disabled = true;
|
||
targetInput.style.backgroundColor = '#e8ecef';
|
||
targetInput.placeholder = "此查詢為全域指令,不需輸入目標";
|
||
targetInput.value = "";
|
||
} else {
|
||
targetInput.disabled = false;
|
||
targetInput.style.backgroundColor = '#f8f9fa';
|
||
targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)";
|
||
}
|
||
}
|
||
|
||
// 重置所有管理介面的資料與 UI 狀態
|
||
function resetAllManagerData() {
|
||
resetMacDomainData();
|
||
|
||
// 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓取
|
||
if (typeof cachedDeviceLists !== 'undefined') {
|
||
cachedDeviceLists = { cms: null, rpds: null, host: null };
|
||
}
|
||
|
||
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
|
||
clearTreeCache();
|
||
|
||
// 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體
|
||
if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
|
||
activeLockState.clear();
|
||
}
|
||
|
||
const configArea = document.getElementById('macDomainConfigArea');
|
||
if (configArea) configArea.style.display = 'none';
|
||
|
||
const previewEl = document.getElementById('cliPreviewContainer');
|
||
if (previewEl) {
|
||
previewEl.textContent = '';
|
||
previewEl.style.display = 'none';
|
||
}
|
||
|
||
const deployBtn = document.getElementById('btnDeployConfig');
|
||
if (deployBtn) {
|
||
deployBtn.style.display = 'none';
|
||
deployBtn.disabled = true;
|
||
}
|
||
|
||
const mdInput = document.getElementById('cfgMacDomain');
|
||
if (mdInput) {
|
||
mdInput.value = '';
|
||
mdInput.placeholder = '請先點擊上方載入任務...';
|
||
}
|
||
const mdDatalist = document.getElementById('mac-domain-list');
|
||
if (mdDatalist) mdDatalist.innerHTML = '';
|
||
|
||
const fetchStatus = document.getElementById('fetchStatus');
|
||
if (fetchStatus) {
|
||
fetchStatus.textContent = "請先讀取設備狀態...";
|
||
fetchStatus.style.color = "#7f8c8d";
|
||
}
|
||
|
||
document.getElementById('queryTargetCm').value = '';
|
||
document.getElementById('queryTargetRpd').value = '';
|
||
|
||
// 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
|
||
if (typeof loadBackupHistory === 'function') {
|
||
loadBackupHistory();
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
|
||
// ============================================================================
|
||
|
||
// 執行綜合查詢 (CM / RPD) - 🌟 導入前端模擬動態訊息流
|
||
async function executeQuery(mode) {
|
||
if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕!");
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
const queryType = document.getElementById('queryType' + mode).value;
|
||
const target = document.getElementById('queryTarget' + mode).value.trim();
|
||
const isDebug = document.getElementById('debugQuery' + mode).checked;
|
||
|
||
openModal(connInfo.host);
|
||
const outputEl = document.getElementById('modalOutput');
|
||
|
||
// 🌟 模擬動態訊息流邏輯
|
||
const states = [
|
||
`🔌 正在建立 SSH 連線至 ${connInfo.host}...`,
|
||
`📥 正在向設備發送查詢指令...`,
|
||
`🧩 正在等待設備回傳資料...`
|
||
];
|
||
let progressIdx = 0;
|
||
outputEl.innerHTML = `<span id="query-progress-text" style="color: #f39c12; font-weight: bold;">${states[0]}</span>`;
|
||
|
||
const progressInterval = setInterval(() => {
|
||
progressIdx++;
|
||
if (progressIdx < states.length) {
|
||
const textEl = document.getElementById('query-progress-text');
|
||
if (textEl) textEl.innerText = states[progressIdx];
|
||
}
|
||
}, 1500); // 每 1.5 秒切換一次狀態
|
||
|
||
try {
|
||
const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
|
||
clearInterval(progressInterval); // 收到結果,停止輪播
|
||
|
||
if (result.status === 'success') {
|
||
outputEl.style.color = "#e0e0e0";
|
||
outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data;
|
||
} else {
|
||
outputEl.style.color = "#e74c3c";
|
||
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
|
||
}
|
||
} catch (error) {
|
||
clearInterval(progressInterval); // 發生錯誤,停止輪播
|
||
outputEl.style.color = "#e74c3c";
|
||
outputEl.textContent = `連線失敗: ${error}`;
|
||
}
|
||
}
|
||
|
||
// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀)
|
||
async function fetchFullConfig(configType = 'running') {
|
||
const loadingMsg = document.getElementById('loading-message');
|
||
const treeContainer = document.getElementById(`tree-container-${configType}`);
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
if (loadingMsg) {
|
||
loadingMsg.style.display = 'inline-block';
|
||
loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色
|
||
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
|
||
}
|
||
if (treeContainer) treeContainer.innerHTML = '';
|
||
|
||
let needAutoScan = false;
|
||
let progressInterval = null;
|
||
|
||
try {
|
||
try {
|
||
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
|
||
if (versionRes.status === 'success') {
|
||
const currentVersion = versionRes.version;
|
||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
|
||
const optData = await optRes.json();
|
||
const cachedVersion = optData.__metadata__?.cmts_version;
|
||
|
||
// 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾
|
||
if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) {
|
||
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
|
||
if (doClear) {
|
||
const allKeys = Object.keys(optData);
|
||
await apiClearCache(connInfo.host, allKeys, configType);
|
||
needAutoScan = true;
|
||
}
|
||
}
|
||
}
|
||
} catch (vErr) {
|
||
console.warn("版本檢查失敗,略過", vErr);
|
||
}
|
||
|
||
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
|
||
|
||
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
let finalTreeData = null;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
for (let line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const data = JSON.parse(line);
|
||
|
||
if (data.status === 'progress') {
|
||
if (data.message.includes('正在下載')) {
|
||
let progress = 0;
|
||
if (progressInterval) clearInterval(progressInterval);
|
||
|
||
progressInterval = setInterval(() => {
|
||
progress += (95 - progress) * 0.06;
|
||
if (loadingMsg) {
|
||
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
|
||
}
|
||
}, 500);
|
||
} else {
|
||
if (progressInterval) {
|
||
clearInterval(progressInterval);
|
||
progressInterval = null;
|
||
if (loadingMsg) {
|
||
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
|
||
loadingMsg.style.color = "#27ae60";
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 600));
|
||
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色
|
||
}
|
||
if (loadingMsg) {
|
||
loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色
|
||
loadingMsg.innerHTML = data.message;
|
||
}
|
||
}
|
||
}
|
||
else if (data.status === 'success') {
|
||
finalTreeData = data.data;
|
||
}
|
||
else if (data.status === 'error') {
|
||
throw new Error(data.message);
|
||
}
|
||
} catch (e) {
|
||
console.warn("解析串流 JSON 失敗:", line);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
|
||
|
||
window.currentTreeData = finalTreeData;
|
||
|
||
const currentSelectedTask = document.getElementById('configTask').value;
|
||
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
|
||
|
||
if (currentSelectedTask !== expectedTask) return;
|
||
|
||
if (treeContainer) {
|
||
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
|
||
if (loadingMsg) loadingMsg.style.display = 'none';
|
||
|
||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
|
||
if (isSomeoneScanning) {
|
||
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
|
||
lockScanButton(60000);
|
||
} else {
|
||
enableGlobalScanButton();
|
||
if (needAutoScan) {
|
||
setTimeout(() => scanGlobalMissingOptions(configType), 500);
|
||
}
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
|
||
} finally {
|
||
if (progressInterval) clearInterval(progressInterval);
|
||
if (loadingMsg) {
|
||
loadingMsg.style.display = 'none';
|
||
loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 CM 一鍵診斷中心邏輯 (多頻道切換與紅綠燈實作)
|
||
// ============================================================================
|
||
let merChartInstance = null;
|
||
let globalMerData = {}; // 暫存所有頻道的 MER 資料供切換使用
|
||
|
||
window.runCmDiagnostics = async function() {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
const macInput = document.getElementById('diagMacInput').value.trim();
|
||
if (!macInput) return alert("請輸入目標 CM 的 MAC Address!");
|
||
|
||
const btn = document.getElementById('btnRunDiag');
|
||
const msg = document.getElementById('diagStatusMsg');
|
||
const resultArea = document.getElementById('diagResultArea');
|
||
|
||
btn.disabled = true;
|
||
btn.style.opacity = '0.7';
|
||
msg.style.display = 'inline-block';
|
||
resultArea.style.display = 'none';
|
||
|
||
try {
|
||
const result = await apiGetCmDiagnostics(connInfo.host, connInfo.user, connInfo.pass, macInput);
|
||
|
||
if (result.status === 'success') {
|
||
const data = result.data;
|
||
|
||
// 1. 填入基本資訊
|
||
document.getElementById('diagResMac').textContent = data.mac;
|
||
document.getElementById('diagResIp').textContent = data.ip || 'N/A';
|
||
document.getElementById('diagResCpe').textContent = data.cpe_count;
|
||
|
||
const stateEl = document.getElementById('diagResState');
|
||
stateEl.textContent = data.state || 'N/A';
|
||
stateEl.style.color = (data.state && data.state.toLowerCase().includes('online')) ? '#27ae60' : '#e74c3c';
|
||
|
||
document.getElementById('diagResTx').textContent = data.phy.tx_power !== null ? data.phy.tx_power : 'N/A';
|
||
document.getElementById('diagResRx').textContent = data.phy.rx_power !== null ? data.phy.rx_power : 'N/A';
|
||
|
||
// 2. 渲染上行 SNR (Upstreams) 標籤群
|
||
const usContainer = document.getElementById('upstreamSnrContainer');
|
||
usContainer.innerHTML = '';
|
||
if (data.upstreams && data.upstreams.length > 0) {
|
||
data.upstreams.forEach(us => {
|
||
let color = '#e74c3c'; // 預設紅 (差: < 30)
|
||
if (us.snr >= 35) color = '#27ae60'; // 綠 (優: >= 35)
|
||
else if (us.snr >= 30) color = '#f39c12'; // 橘 (尚可: 30~34.9)
|
||
|
||
usContainer.innerHTML += `
|
||
<div style="background-color: ${color}; color: white; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; box-shadow: 0 1px 3px rgba(0,0,0,0.2);">
|
||
${us.channel} : ${us.snr}
|
||
</div>
|
||
`;
|
||
});
|
||
} else {
|
||
usContainer.innerHTML = '<span style="color: #bdc3c7; font-size: 13px;">無資料</span>';
|
||
}
|
||
|
||
// 3. 處理 OFDM MER 頻道按鈕與圖表
|
||
globalMerData = data.mer_channels;
|
||
const tabsContainer = document.getElementById('ofdmChannelTabs');
|
||
tabsContainer.innerHTML = '';
|
||
|
||
const channels = Object.keys(globalMerData);
|
||
if (channels.length > 0) {
|
||
channels.forEach((ch, index) => {
|
||
const chData = globalMerData[ch];
|
||
// 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅)
|
||
let bgCol = '#bdc3c7'; // 預設灰色 (無資料)
|
||
if (chData.health === 'good') bgCol = '#27ae60';
|
||
else if (chData.health === 'warning') bgCol = '#f39c12';
|
||
else if (chData.health === 'critical') bgCol = '#e74c3c';
|
||
|
||
const btn = document.createElement('button');
|
||
// 💡 拔除方正的 class,改用純手工膠囊樣式
|
||
btn.style.boxSizing = 'border-box';
|
||
btn.style.cursor = 'pointer';
|
||
btn.style.backgroundColor = bgCol;
|
||
btn.style.color = '#ffffff';
|
||
btn.style.padding = '6px 14px';
|
||
btn.style.borderRadius = '20px'; // 🌟 完美的膠囊圓角
|
||
btn.style.fontSize = '12px';
|
||
btn.style.fontWeight = 'bold';
|
||
btn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
|
||
btn.style.transition = 'all 0.2s ease';
|
||
|
||
// 點擊與未點擊的狀態區分 (透明度與外框)
|
||
btn.style.opacity = index === 0 ? '1' : '0.5';
|
||
btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent';
|
||
|
||
// 標籤文字加入最低 MER 數值提示
|
||
btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`;
|
||
|
||
btn.onclick = () => {
|
||
// 重置所有按鈕外觀
|
||
Array.from(tabsContainer.children).forEach(b => {
|
||
b.style.opacity = '0.5';
|
||
b.style.border = '2px solid transparent';
|
||
});
|
||
// 突顯當前點擊的按鈕
|
||
btn.style.opacity = '1';
|
||
btn.style.border = '2px solid #2c3e50';
|
||
// 重新繪圖
|
||
renderMerChart(ch, chData.histogram);
|
||
};
|
||
tabsContainer.appendChild(btn);
|
||
});
|
||
|
||
// 預設渲染第一個頻道的圖表
|
||
renderMerChart(channels[0], globalMerData[channels[0]].histogram);
|
||
} else {
|
||
tabsContainer.innerHTML = '<span style="color: #e74c3c; font-size: 14px; font-weight: bold;">⚠️ 設備無回傳任何 OFDM 頻道 MER 數據</span>';
|
||
renderMerChart('無資料', {});
|
||
}
|
||
|
||
resultArea.style.display = 'flex';
|
||
}
|
||
} catch (error) {
|
||
alert(`❌ 診斷失敗: ${error.message}`);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.style.opacity = '1';
|
||
msg.style.display = 'none';
|
||
}
|
||
};
|
||
|
||
function renderMerChart(channelName, histogramData) {
|
||
if (merChartInstance) {
|
||
merChartInstance.destroy();
|
||
}
|
||
const ctx = document.getElementById('merChart').getContext('2d');
|
||
|
||
if (!histogramData || Object.keys(histogramData).length === 0) {
|
||
merChartInstance = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: { labels: ['無資料'], datasets: [{ label: 'Subcarriers', data: [0] }] },
|
||
options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: '無法取得 MER 數據' } } }
|
||
});
|
||
return;
|
||
}
|
||
|
||
const labels = Object.keys(histogramData).sort((a, b) => parseInt(a) - parseInt(b));
|
||
const dataPoints = labels.map(label => histogramData[label]);
|
||
|
||
merChartInstance = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: labels.map(l => `${l} dB`),
|
||
datasets: [{
|
||
label: 'Subcarriers 數量',
|
||
data: dataPoints,
|
||
backgroundColor: 'rgba(155, 89, 182, 0.6)',
|
||
borderColor: 'rgba(142, 68, 173, 1)',
|
||
borderWidth: 1,
|
||
borderRadius: 4,
|
||
hoverBackgroundColor: 'rgba(155, 89, 182, 0.9)'
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: { display: true, text: 'Subcarriers 數量', font: { weight: 'bold' } },
|
||
grid: { color: '#ecf0f1' }
|
||
},
|
||
x: {
|
||
title: { display: true, text: 'MER (dB)', font: { weight: 'bold' } },
|
||
grid: { display: false }
|
||
}
|
||
},
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: `顯示中頻道: ${channelName}`,
|
||
font: { size: 15, weight: 'bold' },
|
||
color: '#2c3e50'
|
||
},
|
||
legend: { display: false },
|
||
tooltip: {
|
||
backgroundColor: 'rgba(44, 62, 80, 0.9)',
|
||
titleFont: { size: 14 },
|
||
bodyFont: { size: 14 },
|
||
padding: 10,
|
||
callbacks: {
|
||
label: function(context) {
|
||
return ` ${context.raw} 個 Subcarriers`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
|
||
// ============================================================================
|
||
|
||
// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
|
||
async function enhanceInputWithOptions(path, elementId, mode = 'running') {
|
||
// 🌟 1. 取得當前連線的 IP
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return;
|
||
|
||
try {
|
||
// 🌟 2. GET 請求:網址加上 host 參數
|
||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
||
const optData = await optRes.json();
|
||
|
||
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
const leafCache = optData[cacheKey];
|
||
|
||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||
const container = document.getElementById(`container-${elementId}`);
|
||
const input = container.querySelector('.edit-input');
|
||
|
||
if (input) {
|
||
const listId = `dl-${elementId}`;
|
||
input.setAttribute('list', listId);
|
||
|
||
let dataList = document.getElementById(listId);
|
||
if (!dataList) {
|
||
dataList = document.createElement('datalist');
|
||
dataList.id = listId;
|
||
container.appendChild(dataList);
|
||
}
|
||
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
|
||
}
|
||
} else {
|
||
// 🌟 3. POST 請求:把 host 塞進 body 裡面
|
||
fetch('/api/v1/cmts-leaf-options/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode })
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn("選項外掛載入失敗,維持純文字輸入", e);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 核心功能:執行掃描 (廣播升級版),加上 mode 參數
|
||
// ============================================================================
|
||
|
||
// 🌟 新增:遞迴提取所有快取 Key 的輔助函數 (不依賴網頁 DOM)
|
||
function extractAllCacheKeys(node, path = '') {
|
||
let keys = [];
|
||
for (const [key, value] of Object.entries(node)) {
|
||
const currentPath = path ? `${path}::${key}` : key;
|
||
if (typeof value === 'object' && value !== null) {
|
||
keys = keys.concat(extractAllCacheKeys(value, currentPath));
|
||
} else {
|
||
const origVal = (value === null ? '' : value).toString();
|
||
let cacheKey = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
if (currentPath.endsWith('::no')) {
|
||
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||
}
|
||
keys.push(cacheKey);
|
||
}
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
async function performScan(isGlobal, mode = 'running') {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
|
||
|
||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
|
||
if (isSomeoneScanning) {
|
||
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
|
||
return;
|
||
}
|
||
|
||
const treeContainer = document.getElementById(`tree-container-${mode}`);
|
||
if (!treeContainer) return;
|
||
|
||
const statusEl = document.getElementById('scan-status');
|
||
|
||
try {
|
||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
||
const optData = await optRes.json();
|
||
const pathsToSync = [];
|
||
|
||
if (isGlobal) {
|
||
// 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
|
||
if (!window.currentTreeData) return alert("請先載入設備配置!");
|
||
const allKeys = extractAllCacheKeys(window.currentTreeData);
|
||
|
||
allKeys.forEach(cacheKey => {
|
||
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
|
||
pathsToSync.push(cacheKey);
|
||
}
|
||
});
|
||
} else {
|
||
// 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的)
|
||
const allContainers = treeContainer.querySelectorAll('.leaf-container');
|
||
allContainers.forEach(container => {
|
||
const isHiddenByFolder = container.closest('details:not([open])') !== null;
|
||
if (!isHiddenByFolder) {
|
||
const path = container.getAttribute('data-path');
|
||
if (path) {
|
||
const origVal = container.getAttribute('data-original') || '';
|
||
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||
|
||
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
|
||
pathsToSync.push(cacheKey);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
if (pathsToSync.length === 0) {
|
||
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!";
|
||
statusEl.style.color = "#27ae60";
|
||
setTimeout(() => statusEl.textContent = "", 3000);
|
||
return;
|
||
}
|
||
|
||
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode })
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.status === 'busy') {
|
||
alert(result.message);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error("掃描請求發送失敗:", error);
|
||
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
|
||
statusEl.style.color = "#e74c3c";
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
|
||
// ============================================================================
|
||
async function performClear(isGlobal, mode = 'running') {
|
||
// 🌟 1. 取得當前連線的 IP
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
|
||
|
||
// 🌟 2. 檢查掃描狀態時,傳入 host
|
||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
|
||
if (isSomeoneScanning) {
|
||
alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突,請稍候掃描完成再執行清除動作。");
|
||
return;
|
||
}
|
||
|
||
let pathsToClear = [];
|
||
|
||
if (isGlobal) {
|
||
try {
|
||
// 🌟 3. GET 請求:網址加上 host 參數
|
||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
||
const optData = await optRes.json();
|
||
pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__');
|
||
} catch (e) {
|
||
console.error(e);
|
||
return alert("❌ 無法取得全域快取清單。");
|
||
}
|
||
} else {
|
||
const treeContainer = document.getElementById(`tree-container-${mode}`);
|
||
const elements = treeContainer ? treeContainer.querySelectorAll('.leaf-container') : [];
|
||
elements.forEach(el => {
|
||
const isHiddenByFolder = el.closest('details:not([open])') !== null;
|
||
if (!isHiddenByFolder) {
|
||
const path = el.getAttribute('data-path');
|
||
if (path) {
|
||
const cacheKey = path.replace(/::/g, ' ');
|
||
if (!pathsToClear.includes(cacheKey)) pathsToClear.push(cacheKey);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
if (pathsToClear.length === 0) return alert(isGlobal ? "目前沒有任何快取可以清除!" : "局部沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。");
|
||
|
||
const msg = isGlobal
|
||
? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)`
|
||
: `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後將自動為您重新掃描)`;
|
||
|
||
if (!confirm(msg)) return;
|
||
|
||
try {
|
||
// 🌟 4. 呼叫清除 API 時,傳入 host
|
||
const result = await apiClearCache(connInfo.host, pathsToClear, mode);
|
||
const statusEl = document.getElementById('scan-status');
|
||
if (statusEl) {
|
||
statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`;
|
||
statusEl.style.color = "#27ae60";
|
||
}
|
||
|
||
// 清除完畢後,自動呼叫簡化版的 performScan
|
||
await performScan(isGlobal, mode);
|
||
|
||
} catch (error) {
|
||
console.error("清除快取失敗:", error);
|
||
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");
|
||
}
|
||
}
|
||
|
||
// 綁定給 HTML 呼叫的 4 個獨立函數
|
||
function scanVisibleMissingOptions() {
|
||
performScan(false);
|
||
}
|
||
function scanGlobalMissingOptions() {
|
||
// 🌟 動態抓取當前選擇的模式,傳給 performScan
|
||
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
performScan(true, mode);
|
||
}
|
||
function clearVisibleCache() {
|
||
performClear(false);
|
||
}
|
||
function clearGlobalCache() {
|
||
// 🌟 動態抓取當前選擇的模式,傳給 performClear
|
||
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
performClear(true, mode);
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 5. 系統設定與過濾器 (System Settings & Filters)
|
||
// ============================================================================
|
||
|
||
let currentHiddenKeys = [];
|
||
|
||
// 🌟 新增:切換過濾器模式時觸發
|
||
async function switchFilterMode() {
|
||
const container = document.getElementById('filter-checkboxes');
|
||
if (container) container.style.display = 'none'; // 先隱藏樹狀圖,強迫使用者重新載入
|
||
await openSystemSettings();
|
||
}
|
||
|
||
// 開啟系統設定並讀取目前的過濾器清單
|
||
async function openSystemSettings() {
|
||
// 🌟 動態抓取過濾器模式
|
||
const modeSelect = document.getElementById('filter-mode-select');
|
||
const mode = modeSelect ? modeSelect.value : 'running';
|
||
|
||
try {
|
||
const result = await apiGetTreeFilters(mode);
|
||
if (result.status === 'success') currentHiddenKeys = result.data;
|
||
} catch (error) {
|
||
console.error("讀取設定失敗:", error);
|
||
}
|
||
|
||
// 🌟 新增:同時載入日誌等級
|
||
loadLogLevels();
|
||
}
|
||
|
||
async function loadLogLevels() {
|
||
try {
|
||
const res = await apiGetLogLevels();
|
||
if (res.status === 'success') {
|
||
const container = document.getElementById('log-settings-container');
|
||
container.innerHTML = '';
|
||
|
||
// 模組名稱對照表
|
||
const moduleNames = {
|
||
'app.database': '🗄️ 資料庫模組 (Database)',
|
||
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
|
||
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
|
||
'app.backup': '💾 備份與還原模組 (Backup)',
|
||
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)'
|
||
};
|
||
|
||
for (const [module, level] of Object.entries(res.data)) {
|
||
const displayName = moduleNames[module] || module;
|
||
// 根據等級改變下拉選單顏色
|
||
const bgColor = level === 'DEBUG' ? '#fcf3f2' : (level === 'ERROR' ? '#fdedec' : '#f8f9fa');
|
||
const textColor = level === 'DEBUG' ? '#c0392b' : (level === 'ERROR' ? '#e74c3c' : '#2c3e50');
|
||
|
||
container.innerHTML += `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 12px 15px; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<span style="font-weight: bold; color: #34495e; font-size: 14px;">${displayName}</span>
|
||
<select onchange="changeLogLevel('${module}', this.value)" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 13px; cursor: pointer; background-color: ${bgColor}; color: ${textColor}; transition: all 0.2s;">
|
||
<option value="DEBUG" ${level === 'DEBUG' ? 'selected' : ''}>🐞 DEBUG (詳細)</option>
|
||
<option value="INFO" ${level === 'INFO' ? 'selected' : ''}>🟢 INFO (一般)</option>
|
||
<option value="WARNING" ${level === 'WARNING' ? 'selected' : ''}>🟠 WARNING (警告)</option>
|
||
<option value="ERROR" ${level === 'ERROR' ? 'selected' : ''}>🔴 ERROR (錯誤)</option>
|
||
</select>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("載入日誌等級失敗", e);
|
||
}
|
||
}
|
||
|
||
// 💡 改成一般的 function 定義
|
||
async function changeLogLevel(module, level) {
|
||
try {
|
||
const res = await apiSetLogLevel(module, level);
|
||
if (res.status === 'success') {
|
||
loadLogLevels(); // 重新渲染以更新顏色
|
||
}
|
||
} catch (e) {
|
||
alert("設定日誌等級失敗!");
|
||
}
|
||
}
|
||
|
||
// 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
|
||
async function loadRealConfigForFilters() {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
|
||
|
||
const modeSelect = document.getElementById('filter-mode-select');
|
||
const mode = modeSelect ? modeSelect.value : 'running';
|
||
|
||
const loadingMsg = document.getElementById('filter-loading-msg');
|
||
const container = document.getElementById('filter-checkboxes');
|
||
|
||
loadingMsg.style.display = 'inline-block';
|
||
loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色
|
||
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
|
||
container.style.display = 'none';
|
||
|
||
let progressInterval = null; // 🌟 新增:進度條計時器
|
||
|
||
try {
|
||
const url = `/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
|
||
const response = await fetch(url);
|
||
|
||
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
let finalTreeData = null;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
for (let line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const data = JSON.parse(line);
|
||
|
||
if (data.status === 'progress') {
|
||
// 🌟 導入百分比動畫邏輯
|
||
if (data.message.includes('正在下載')) {
|
||
let progress = 0;
|
||
if (progressInterval) clearInterval(progressInterval);
|
||
|
||
progressInterval = setInterval(() => {
|
||
progress += (95 - progress) * 0.06;
|
||
if (loadingMsg) {
|
||
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (${Math.round(progress)}%)...`;
|
||
}
|
||
}, 500);
|
||
} else {
|
||
if (progressInterval) {
|
||
clearInterval(progressInterval);
|
||
progressInterval = null;
|
||
if (loadingMsg) {
|
||
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (100%) - 下載完成!`;
|
||
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 600));
|
||
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 恢復橘色
|
||
}
|
||
if (loadingMsg) {
|
||
loadingMsg.style.color = '#f39c12';
|
||
loadingMsg.innerHTML = data.message;
|
||
}
|
||
}
|
||
}
|
||
else if (data.status === 'success') {
|
||
finalTreeData = data.data;
|
||
}
|
||
else if (data.status === 'error') {
|
||
throw new Error(data.message);
|
||
}
|
||
} catch (e) {
|
||
console.warn("解析串流 JSON 失敗:", line);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (finalTreeData) {
|
||
container.style.display = 'block';
|
||
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
|
||
} else {
|
||
throw new Error("未收到完整的配置資料");
|
||
}
|
||
|
||
} catch (error) {
|
||
container.style.display = 'block';
|
||
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
|
||
} finally {
|
||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
||
loadingMsg.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 當點擊「父節點」時:同步勾選/取消所有子節點
|
||
function toggleChildCheckboxes(parentCheckbox) {
|
||
const details = parentCheckbox.closest('details');
|
||
if (details) {
|
||
const childCheckboxes = details.querySelectorAll('.filter-children-container .filter-checkbox');
|
||
childCheckboxes.forEach(cb => cb.checked = parentCheckbox.checked);
|
||
}
|
||
updateParentCheckboxState(parentCheckbox);
|
||
}
|
||
|
||
// 當點擊「子節點」時:往上檢查父節點是否該被勾選
|
||
function updateParentCheckboxState(element) {
|
||
const parentDetails = element.closest('.filter-children-container')?.closest('details');
|
||
if (!parentDetails) return;
|
||
|
||
const parentCheckbox = parentDetails.querySelector('summary .filter-checkbox');
|
||
const childCheckboxes = parentDetails.querySelectorAll('.filter-children-container .filter-checkbox');
|
||
|
||
if (parentCheckbox && childCheckboxes.length > 0) {
|
||
const allChecked = Array.from(childCheckboxes).every(cb => cb.checked);
|
||
parentCheckbox.checked = allChecked;
|
||
updateParentCheckboxState(parentCheckbox);
|
||
}
|
||
}
|
||
|
||
// 儲存系統設定 (過濾器)
|
||
async function saveSystemSettings() {
|
||
// 🌟 動態抓取過濾器模式
|
||
const modeSelect = document.getElementById('filter-mode-select');
|
||
const mode = modeSelect ? modeSelect.value : 'running';
|
||
|
||
const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
|
||
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
|
||
|
||
try {
|
||
const result = await apiSaveTreeFilters(selectedHiddenKeys, mode);
|
||
if (result.status === 'success') {
|
||
const statusText = document.getElementById('settings-status');
|
||
statusText.style.display = 'inline-block';
|
||
currentHiddenKeys = selectedHiddenKeys;
|
||
setTimeout(() => { statusText.style.display = 'none'; }, 3000);
|
||
}
|
||
} catch (error) {
|
||
console.error("儲存設定失敗:", error);
|
||
alert("儲存設定時發生錯誤!");
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 新增:設備備份與還原 (Backup & Restore)
|
||
// ============================================================================
|
||
|
||
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
||
let allBackupsData = [];
|
||
|
||
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
|
||
async function createSnapshot() {
|
||
if (!isConnected) {
|
||
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
||
}
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return;
|
||
|
||
const snapshotName = document.getElementById('snapshotName').value.trim();
|
||
if (!snapshotName) return alert("請輸入快照名稱!");
|
||
|
||
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
|
||
const configType = document.getElementById('backupConfigType').value;
|
||
const btn = document.getElementById('btnCreateSnapshot');
|
||
const msg = document.getElementById('backup-status-msg');
|
||
|
||
btn.disabled = true;
|
||
msg.style.display = 'inline-block';
|
||
msg.style.color = '#f39c12'; // 🌟 確保橘色
|
||
msg.innerHTML = '⏳ 準備連線至設備...';
|
||
|
||
let progressInterval = null; // 🌟 新增:進度條計時器
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/backups/snapshot', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
host: connInfo.host,
|
||
username: connInfo.user,
|
||
password: connInfo.pass,
|
||
snapshot_name: snapshotName,
|
||
description: snapshotDescription,
|
||
config_type: configType
|
||
})
|
||
});
|
||
|
||
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
let isSuccess = false;
|
||
let finalMessage = "";
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
for (let line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const data = JSON.parse(line);
|
||
if (data.status === 'progress') {
|
||
// 🌟 導入百分比動畫邏輯
|
||
if (data.message.includes('正在下載')) {
|
||
let progress = 0;
|
||
if (progressInterval) clearInterval(progressInterval);
|
||
|
||
progressInterval = setInterval(() => {
|
||
progress += (95 - progress) * 0.06;
|
||
if (msg) {
|
||
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
|
||
}
|
||
}, 500);
|
||
} else {
|
||
if (progressInterval) {
|
||
clearInterval(progressInterval);
|
||
progressInterval = null;
|
||
if (msg) {
|
||
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
|
||
msg.style.color = "#27ae60"; // 瞬間變綠色
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 600));
|
||
if (msg) msg.style.color = "#f39c12"; // 恢復橘色
|
||
}
|
||
if (msg) {
|
||
msg.style.color = '#f39c12';
|
||
msg.innerHTML = data.message;
|
||
}
|
||
}
|
||
} else if (data.status === 'success') {
|
||
isSuccess = true;
|
||
finalMessage = data.message;
|
||
} else if (data.status === 'error') {
|
||
throw new Error(data.message);
|
||
}
|
||
} catch (e) {
|
||
if (e.message) throw e;
|
||
console.warn("解析串流 JSON 失敗:", line);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isSuccess) {
|
||
alert(`✅ ${finalMessage}`);
|
||
document.getElementById('snapshotName').value = '';
|
||
if (document.getElementById('snapshotDescription')) {
|
||
document.getElementById('snapshotDescription').value = '';
|
||
}
|
||
loadBackupHistory();
|
||
}
|
||
} catch (error) {
|
||
alert(`❌ 發生錯誤: ${error.message}`);
|
||
} finally {
|
||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
||
btn.disabled = false;
|
||
msg.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 2. 載入歷史紀錄列表 (維持 IP 綁定,避免後端報錯)
|
||
async function loadBackupHistory() {
|
||
// 🌟 補回取得當前畫面上設定的 IP
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return;
|
||
|
||
const tbody = document.getElementById('backup-history-tbody');
|
||
if (!tbody) return;
|
||
|
||
// 🌟 修正:將原本的灰色改為橘色並加粗
|
||
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
|
||
|
||
try {
|
||
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
||
const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=all`);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
allBackupsData = result.data;
|
||
applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染
|
||
} else {
|
||
// 加入防呆:如果後端回傳 422 (detail),也能正確印出錯誤而不是 undefined
|
||
const errMsg = result.message || (result.detail ? JSON.stringify(result.detail) : '未知錯誤');
|
||
tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 載入失敗: ${errMsg}</td></tr>`;
|
||
}
|
||
} catch (error) {
|
||
tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 連線失敗: ${error.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
// 💡 前端多維度過濾器邏輯
|
||
window.applyBackupFilters = function() {
|
||
const tbody = document.getElementById('backup-history-tbody');
|
||
if (!tbody) return;
|
||
|
||
// 取得所有過濾條件 (加上 trim() 避免不小心多敲了空白鍵)
|
||
const keyword = (document.getElementById('filter-keyword')?.value || '').trim().toLowerCase();
|
||
const type = document.getElementById('filter-type')?.value || '';
|
||
const dateStart = document.getElementById('filter-date-start')?.value;
|
||
const dateEnd = document.getElementById('filter-date-end')?.value;
|
||
|
||
// 進行陣列過濾
|
||
const filteredData = allBackupsData.filter(item => {
|
||
// 💡 終極防呆版:確保變數存在,並同時搜尋名稱與描述
|
||
const safeName = (item.snapshot_name || '').toLowerCase();
|
||
const safeDesc = (item.description || '').toLowerCase();
|
||
const matchKeyword = safeName.includes(keyword) || safeDesc.includes(keyword);
|
||
|
||
const matchType = type === '' || item.config_type === type;
|
||
|
||
let matchDate = true;
|
||
const itemDate = new Date(item.timestamp);
|
||
if (dateStart) {
|
||
const start = new Date(dateStart);
|
||
start.setHours(0, 0, 0, 0);
|
||
if (itemDate < start) matchDate = false;
|
||
}
|
||
if (dateEnd) {
|
||
const end = new Date(dateEnd);
|
||
end.setHours(23, 59, 59, 999);
|
||
if (itemDate > end) matchDate = false;
|
||
}
|
||
|
||
return matchKeyword && matchType && matchDate;
|
||
});
|
||
|
||
// 渲染過濾後的結果
|
||
if (filteredData.length > 0) {
|
||
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
|
||
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
||
|
||
tbody.innerHTML = filteredData.map(item => `
|
||
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
||
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
||
<!-- 🟢 描述欄位 -->
|
||
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
||
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
||
</td>
|
||
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
||
<td style="padding: 12px 10px; text-align: right;">
|
||
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
||
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式,只有解鎖後才會出現 -->
|
||
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
} else {
|
||
// 🟢 修正:沒有資料時,colspan 改為 5
|
||
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
||
}
|
||
};
|
||
|
||
// 3. 檢視特定快照內容
|
||
async function viewBackup(backupId) {
|
||
openModal("載入快照中...");
|
||
const outputEl = document.getElementById('modalOutput');
|
||
outputEl.innerHTML = `<span style="color: #f39c12;">⏳ 正在向資料庫撈取快照資料,請稍候...</span>`;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/backups/${backupId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
// 確保這行真的有加進去,且檔案有按下 Ctrl+S 儲存!
|
||
const data = result.data;
|
||
|
||
// 🟤 將描述加入彈出視窗的標頭中
|
||
const descText = data.description ? data.description : '無';
|
||
const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【備份描述】: ${descText}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`;
|
||
|
||
// 如果後端有回傳 raw_cli 就優先顯示,否則將 parsed_tree 轉為字串顯示
|
||
const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2);
|
||
|
||
outputEl.style.color = "#e0e0e0";
|
||
outputEl.textContent = infoHeader + content;
|
||
} else {
|
||
outputEl.style.color = "#e74c3c";
|
||
outputEl.textContent = `❌ 讀取失敗: ${result.message}`;
|
||
}
|
||
} catch (error) {
|
||
outputEl.style.color = "#e74c3c";
|
||
outputEl.textContent = `❌ 連線失敗: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
// 4. 刪除快照
|
||
async function deleteBackup(backupId) {
|
||
if (!confirm("⚠️ 確定要永久刪除這筆備份紀錄嗎?此動作無法復原!")) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/backups/${backupId}`, { method: 'DELETE' });
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
loadBackupHistory(); // 刪除成功後重新整理列表
|
||
} else {
|
||
alert(`❌ 刪除失敗: ${result.message}`);
|
||
}
|
||
} catch (error) {
|
||
alert(`❌ 發生錯誤: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// 5. 請求設備差異分析 (安全還原第一步)
|
||
// 🌟 接收第三個參數 backupHost
|
||
async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
||
// 🛡️ 防線一:必須先連線
|
||
if (!isConnected) {
|
||
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行還原任務!");
|
||
}
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !connInfo.host) return;
|
||
|
||
// 🛡️ 防線二:嚴格阻斷跨設備還原
|
||
if (backupHost && backupHost !== connInfo.host) {
|
||
alert(
|
||
`🚨 【跨設備還原阻斷】🚨\n\n` +
|
||
`這份快照屬於設備:${backupHost}\n` +
|
||
`您目前連線的設備:${connInfo.host}\n\n` +
|
||
`⚠️ 系統目前禁止跨設備還原,以防止嚴重的網路衝突。請先連線至正確的設備再執行此操作!`
|
||
);
|
||
return; // 直接中斷,不進行後續 API 呼叫
|
||
}
|
||
|
||
const modal = document.getElementById('outputModal');
|
||
const modalOutput = document.getElementById('modalOutput');
|
||
const modalTargetInfo = document.getElementById('modalTargetInfo');
|
||
|
||
// 1. 移除進度條,改為純文字百分比動態顯示
|
||
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
|
||
// 🌟 修正:將原本的藍色改為橘色
|
||
modalOutput.innerHTML = `
|
||
<div id="diff-progress-text" style="color: #f39c12; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置,並與快照進行深度比對... (0%)</div>
|
||
`;
|
||
modal.classList.add('active');
|
||
|
||
// 啟動漸進式模擬進度邏輯 (最高停在 95%)
|
||
let progress = 0;
|
||
const progressInterval = setInterval(() => {
|
||
progress += (95 - progress) * 0.08;
|
||
const text = document.getElementById('diff-progress-text');
|
||
if (text) {
|
||
text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (${Math.round(progress)}%)`;
|
||
}
|
||
}, 400);
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
// API 回傳後,清除計時器並將進度填滿 100%
|
||
clearInterval(progressInterval);
|
||
const text = document.getElementById('diff-progress-text');
|
||
if (text) {
|
||
text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (100%) - 分析完成!`;
|
||
}
|
||
|
||
// 稍微延遲 0.5 秒讓使用者看清楚 100%,再渲染結果
|
||
setTimeout(() => {
|
||
if (result.status === 'success') {
|
||
const commands = result.data.commands;
|
||
let diffHtml = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單:</div>`;
|
||
|
||
if (commands.length === 0) {
|
||
diffHtml += `<div style="color: #27ae60; font-weight: bold; padding: 10px; background: rgba(39, 174, 96, 0.1); border-radius: 5px;">✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!</div>`;
|
||
} else {
|
||
const safeCommands = JSON.stringify(commands).replace(/"/g, '"');
|
||
|
||
// 2. 更改按鈕顏色:使用亮藍色 (#2980b9) 並加上陰影與邊框,凸顯立體感
|
||
modalTargetInfo.innerHTML = `
|
||
正在分析快照差異: ${snapshotName}
|
||
<span style="display: inline-flex; gap: 8px; margin-left: 35px; vertical-align: middle;">
|
||
<button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button>
|
||
<button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button>
|
||
</span>
|
||
`;
|
||
|
||
// 動態插入確認按鈕到 modal-action-container
|
||
const actionContainer = document.getElementById('modal-action-container');
|
||
if (actionContainer) {
|
||
const oldConfirmBtn = document.getElementById('btn-confirm-restore');
|
||
if (oldConfirmBtn) oldConfirmBtn.remove();
|
||
|
||
const confirmBtn = document.createElement('button');
|
||
confirmBtn.id = 'btn-confirm-restore';
|
||
confirmBtn.className = 'btn-modern btn-save';
|
||
confirmBtn.style.backgroundColor = '#e74c3c';
|
||
confirmBtn.style.padding = '6px 12px';
|
||
confirmBtn.style.fontSize = '13px';
|
||
confirmBtn.innerHTML = '⚠️ 確認無誤,立即寫入設備並 Commit';
|
||
confirmBtn.onclick = () => executeSmartRestore(snapshotId, snapshotName, commands);
|
||
|
||
actionContainer.insertBefore(confirmBtn, document.getElementById('btn-modal-close'));
|
||
}
|
||
|
||
diffHtml += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`;
|
||
commands.forEach(cmd => {
|
||
if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`;
|
||
else diffHtml += `<span style="color: #2ecc71;">+ ${cmd}</span>\n`;
|
||
});
|
||
diffHtml += `</pre>`;
|
||
|
||
diffHtml += `<pre id="view-cli" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: none; color: #ecf0f1;">`;
|
||
commands.forEach(cmd => { diffHtml += `${cmd}\n`; });
|
||
diffHtml += `<span style="color: #f39c12;">commit</span>\n`;
|
||
diffHtml += `</pre>`;
|
||
}
|
||
modalOutput.innerHTML = diffHtml;
|
||
} else {
|
||
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗:\n${result.message}</span>`;
|
||
}
|
||
}, 500);
|
||
|
||
} catch (error) {
|
||
clearInterval(progressInterval);
|
||
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// 6. 真正執行差異還原 (串接後端 Streaming API)
|
||
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
|
||
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit!\n確定要繼續嗎?`)) return;
|
||
|
||
// 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕
|
||
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
|
||
btnIds.forEach(id => {
|
||
const btn = document.getElementById(id);
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.style.opacity = '0.5';
|
||
btn.style.cursor = 'not-allowed';
|
||
btn.style.pointerEvents = 'none';
|
||
if (id === 'btn-confirm-restore') {
|
||
btn.innerText = '⏳ 正在寫入設備中...';
|
||
btn.style.backgroundColor = '#7f8c8d';
|
||
}
|
||
}
|
||
});
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
const modalOutput = document.getElementById('modalOutput');
|
||
|
||
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
|
||
modalOutput.innerHTML = `
|
||
<div id="restore-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; box-sizing: border-box;">
|
||
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...</div>
|
||
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
|
||
</div>
|
||
`;
|
||
const logContainer = document.getElementById('restore-log-container');
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
|
||
});
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
for (let line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const data = JSON.parse(line);
|
||
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
|
||
|
||
if (data.status === 'progress') {
|
||
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
|
||
} else if (data.status === 'success') {
|
||
let cleanOutput = data.output || '無';
|
||
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
|
||
|
||
logContainer.innerHTML += `
|
||
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 15px;">[${timeStr}] ${data.message}</div>
|
||
<div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳:<br><span style="color: #bdc3c7;">${cleanOutput}</span></div>
|
||
`;
|
||
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
|
||
|
||
// 成功後,隱藏確認按鈕,並恢復關閉按鈕
|
||
const confirmBtn = document.getElementById('btn-confirm-restore');
|
||
if (confirmBtn) confirmBtn.style.display = 'none';
|
||
|
||
const closeBtn = document.getElementById('btn-modal-close');
|
||
if (closeBtn) {
|
||
closeBtn.disabled = false;
|
||
closeBtn.style.opacity = '1';
|
||
closeBtn.style.cursor = 'pointer';
|
||
closeBtn.style.pointerEvents = 'auto';
|
||
}
|
||
} else if (data.status === 'error') {
|
||
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
|
||
if (data.output) {
|
||
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
|
||
}
|
||
restoreButtonsState(btnIds);
|
||
}
|
||
|
||
logContainer.scrollTop = logContainer.scrollHeight;
|
||
} catch (e) {
|
||
console.warn("解析串流 JSON 失敗:", line);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。<br>${error.message}</div>`;
|
||
restoreButtonsState(btnIds);
|
||
}
|
||
};
|
||
|
||
// 輔助函數:恢復按鈕狀態 (請放在 executeSmartRestore 下方)
|
||
function restoreButtonsState(btnIds) {
|
||
btnIds.forEach(id => {
|
||
const btn = document.getElementById(id);
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.style.opacity = '1';
|
||
btn.style.cursor = 'pointer';
|
||
btn.style.pointerEvents = 'auto';
|
||
if (id === 'btn-confirm-restore') {
|
||
btn.innerText = '⚠️ 確認無誤,重新嘗試寫入';
|
||
btn.style.backgroundColor = '#e74c3c';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
|
||
// ============================================================================
|
||
|
||
// 開啟共用輸出彈出視窗
|
||
function openModal(targetInfo) {
|
||
document.getElementById('outputModal').classList.add('active');
|
||
document.getElementById('modalTargetInfo').textContent = targetInfo ? `(${targetInfo})` : '';
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
// 關閉共用輸出彈出視窗
|
||
function closeModal() {
|
||
document.getElementById('outputModal').classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
|
||
// 清除動態加入的確認按鈕,避免污染下次開啟
|
||
const confirmBtn = document.getElementById('btn-confirm-restore');
|
||
if (confirmBtn) confirmBtn.remove();
|
||
}
|
||
|
||
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
|
||
document.addEventListener("DOMContentLoaded", checkScanButtonState);
|
||
|
||
// ==========================================
|
||
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
|
||
// ==========================================
|
||
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
|
||
|
||
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
|
||
function unlockAdminFeatures() {
|
||
// 1. 解鎖全域快取掃描按鈕
|
||
ADMIN_BTN_IDS.forEach(id => {
|
||
const btn = document.getElementById(id);
|
||
if (btn) btn.style.display = 'inline-block';
|
||
});
|
||
|
||
// 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
|
||
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
||
btn.style.display = 'inline-block';
|
||
});
|
||
|
||
// 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
|
||
document.querySelectorAll('.admin-only-action').forEach(btn => {
|
||
btn.style.display = 'inline-block';
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const appTitle = document.getElementById('app-title');
|
||
|
||
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage,關閉網頁即失效)
|
||
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
|
||
unlockAdminFeatures();
|
||
}
|
||
|
||
// 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門
|
||
|
||
// 🌟 3. 連點標題觸發機制
|
||
let clickCount = 0;
|
||
let clickTimer = null;
|
||
if (appTitle) {
|
||
appTitle.addEventListener('click', () => {
|
||
// 已解鎖則忽略
|
||
if (sessionStorage.getItem('godModeUnlocked') === 'true') return;
|
||
|
||
clickCount++;
|
||
if (clickCount === 5) {
|
||
openGodModeModal(); // 喚出密碼對話框
|
||
clickCount = 0;
|
||
}
|
||
clearTimeout(clickTimer);
|
||
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
|
||
});
|
||
}
|
||
|
||
// 🌟 4. 綁定密碼框的 Enter 快捷鍵
|
||
const pwInput = document.getElementById('godModePassword');
|
||
if (pwInput) {
|
||
pwInput.addEventListener('keypress', function(e) {
|
||
if (e.key === 'Enter') window.verifyGodMode();
|
||
});
|
||
}
|
||
});
|
||
|
||
// 🌟 Modal 操作與 API 驗證邏輯
|
||
window.openGodModeModal = function() {
|
||
const modal = document.getElementById('godModeModal');
|
||
const input = document.getElementById('godModePassword');
|
||
const errorSpan = document.getElementById('god-mode-error');
|
||
|
||
errorSpan.textContent = '';
|
||
input.value = '';
|
||
input.classList.remove('shake-animation');
|
||
|
||
modal.classList.add('active');
|
||
setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦
|
||
};
|
||
|
||
window.closeGodModeModal = function() {
|
||
document.getElementById('godModeModal').classList.remove('active');
|
||
};
|
||
|
||
window.verifyGodMode = async function() {
|
||
const input = document.getElementById('godModePassword');
|
||
const errorSpan = document.getElementById('god-mode-error');
|
||
const btn = document.getElementById('btn-unlock-godmode');
|
||
const pwd = input.value.trim();
|
||
|
||
if (!pwd) {
|
||
errorSpan.textContent = '密碼不可為空!';
|
||
triggerShake(input);
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = '⏳ 驗證中...';
|
||
errorSpan.textContent = '';
|
||
input.classList.remove('shake-animation');
|
||
|
||
try {
|
||
await apiVerifyGodMode(pwd);
|
||
|
||
// 驗證成功
|
||
sessionStorage.setItem('godModeUnlocked', 'true');
|
||
closeGodModeModal();
|
||
alert('🔓 密碼正確!已解鎖系統維護者模式。');
|
||
unlockAdminFeatures();
|
||
|
||
} catch (err) {
|
||
// 驗證失敗
|
||
errorSpan.textContent = '❌ 密碼錯誤,請重新輸入';
|
||
triggerShake(input);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '🚀 解鎖';
|
||
}
|
||
};
|
||
|
||
function triggerShake(element) {
|
||
element.classList.remove('shake-animation');
|
||
void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發
|
||
element.classList.add('shake-animation');
|
||
element.focus();
|
||
}
|
||
|
||
// 統一控制 4 顆按鈕的狀態
|
||
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
||
ADMIN_BTN_IDS.forEach(id => {
|
||
const btn = document.getElementById(id);
|
||
if (btn) {
|
||
btn.disabled = isBusy;
|
||
if (isBusy) {
|
||
btn.classList.add('is-busy');
|
||
if (id.includes('scan')) btn.innerText = text;
|
||
} else {
|
||
btn.classList.remove('is-busy');
|
||
// 恢復原本的文字
|
||
if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失";
|
||
if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function checkScanButtonState() {
|
||
const btn = document.getElementById('btn-scan-visible');
|
||
if (!btn) return;
|
||
const scanLockUntil = localStorage.getItem('scanLockUntil');
|
||
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
|
||
lockScanButton(parseInt(scanLockUntil) - Date.now());
|
||
}
|
||
}
|
||
|
||
function lockScanButton(durationMs) {
|
||
setAdminButtonsState(true, "⏳ 背景掃描執行中...");
|
||
setTimeout(() => {
|
||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
const treeContainer = document.getElementById(`tree-container-${currentMode}`);
|
||
if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) {
|
||
enableGlobalScanButton();
|
||
} else {
|
||
setAdminButtonsState(true, "⏳ 請先載入樹狀圖");
|
||
}
|
||
localStorage.removeItem('scanLockUntil');
|
||
}, durationMs);
|
||
}
|
||
|
||
function enableGlobalScanButton() {
|
||
const scanLockUntil = localStorage.getItem('scanLockUntil');
|
||
if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) {
|
||
setAdminButtonsState(false);
|
||
}
|
||
}
|
||
|
||
|
||
// ============================================================================
|
||
// 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫)
|
||
// ============================================================================
|
||
|
||
// --- 終端機綁定 ---
|
||
window.connectWebSocket = () => connectWebSocket(resetAllManagerData);
|
||
window.disconnectWebSocket = disconnectWebSocket;
|
||
window.injectCommand = injectCommand;
|
||
window.downloadTerminal = downloadTerminal;
|
||
|
||
// --- 頁籤與任務切換 ---
|
||
window.switchTab = switchTab;
|
||
window.switchQueryTask = switchQueryTask;
|
||
window.switchConfigTask = switchConfigTask;
|
||
window.startConfigTask = startConfigTask;
|
||
window.toggleQueryInputs = toggleQueryInputs;
|
||
|
||
// --- 查詢與 MAC Domain 精靈 ---
|
||
window.executeQuery = executeQuery;
|
||
window.fetchMacDomainConfig = fetchMacDomainConfig;
|
||
window.revertFormChanges = revertFormChanges;
|
||
window.toggleGroupVisibility = toggleGroupVisibility;
|
||
window.loadDsGroupData = loadDsGroupData;
|
||
window.loadUsGroupData = loadUsGroupData;
|
||
window.generateMacDomainCLI = generateMacDomainCLI;
|
||
window.executeBondingConfig = executeBondingConfig;
|
||
window.loadMacDomainList = loadMacDomainList;
|
||
|
||
// --- 系統設定與快取掃描 ---
|
||
window.openSystemSettings = openSystemSettings;
|
||
window.loadRealConfigForFilters = loadRealConfigForFilters;
|
||
window.saveSystemSettings = saveSystemSettings;
|
||
window.scanVisibleMissingOptions = scanVisibleMissingOptions;
|
||
window.scanGlobalMissingOptions = scanGlobalMissingOptions;
|
||
window.clearVisibleCache = clearVisibleCache;
|
||
window.clearGlobalCache = clearGlobalCache;
|
||
window.previewNodeCLI = previewNodeCLI;
|
||
window.switchFilterMode = switchFilterMode;
|
||
|
||
// --- 樹狀圖展開與編輯操作 ---
|
||
window.expandAll = expandAll;
|
||
window.collapseAll = collapseAll;
|
||
window.startEditFolder = startEditFolder;
|
||
window.startEditLeaf = startEditLeaf;
|
||
window.cancelEditFolder = cancelEditFolder;
|
||
window.cancelEditLeaf = cancelEditLeaf;
|
||
window.previewFolderCLI = previewFolderCLI;
|
||
window.previewLeafCLI = previewLeafCLI;
|
||
window.hideSideCLI = hideSideCLI;
|
||
window.executeSideCLI = executeSideCLI;
|
||
|
||
// --- 其他 UI 輔助 ---
|
||
window.toggleChildCheckboxes = toggleChildCheckboxes;
|
||
window.updateParentCheckboxState = updateParentCheckboxState;
|
||
window.closeModal = closeModal;
|
||
|
||
// --- 備份與還原 ---
|
||
window.createSnapshot = createSnapshot;
|
||
window.loadBackupHistory = loadBackupHistory;
|
||
window.viewBackup = viewBackup;
|
||
window.deleteBackup = deleteBackup;
|
||
window.restoreBackup = restoreBackup;
|
||
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
|
||
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
|
||
|
||
================================================================================
|
||
FILE: static/terminal.js
|
||
================================================================================
|
||
// --- static/terminal.js ---
|
||
|
||
// 1. 從剛剛建立的 utils.js 引入工具
|
||
import { getGlobalConnectionInfo } from './utils.js';
|
||
|
||
// 2. 宣告終端機專用的變數
|
||
let term, fitAddon, ws;
|
||
export let isConnected = false;
|
||
|
||
// 3. 終端機文字上色 (內部使用,不需要 export)
|
||
function colorizeTerminalStream(text) {
|
||
let res = text;
|
||
|
||
// 1. IP 地址:改為「柔和冰藍色」 (移除 1; 粗體)
|
||
res = res.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g, '\x1b[38;5;111m$1\x1b[0m');
|
||
|
||
// 2. MAC 地址:改為「柔和薰衣草紫」 (移除 1; 粗體)
|
||
res = res.replace(/\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b/g, '\x1b[38;5;140m$1\x1b[0m');
|
||
|
||
// 3. 正常狀態:維持「柔和薄荷綠」
|
||
res = res.replace(/\b([a-z]-online|online|init\([^)]+\)|up|active|success|yes)\b/gi, '\x1b[38;5;114m$1\x1b[0m');
|
||
|
||
// 4. 異常狀態:維持「柔和珊瑚紅」
|
||
res = res.replace(/\b(offline|down|admin-down|error|fail|failed|shutdown|reject|invalid|no)\b/gi, '\x1b[38;5;204m$1\x1b[0m');
|
||
|
||
// 5. 介面名稱 (Cable/RPD等):改為「柔和奶油黃」 (原本是 \x1b[33m)
|
||
res = res.replace(/\b(Cable|GigabitEthernet|TenGigabitEthernet|Upstream|Downstream|Mac-domain|bonding-group|rpd)\b/gi, '\x1b[38;5;179m$1\x1b[0m');
|
||
|
||
return res;
|
||
}
|
||
|
||
// 4. 匯出 (export) 所有需要被外部呼叫的函數
|
||
export function initTerminal() {
|
||
term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 });
|
||
fitAddon = new FitAddon.FitAddon();
|
||
term.loadAddon(fitAddon);
|
||
term.open(document.getElementById('terminal-container'));
|
||
fitAddon.fit();
|
||
term.writeln('Welcome to Harmonic cOS Web Terminal.');
|
||
term.writeln('Please confirm the target device above and click "連線至 CMTS"... \r\n');
|
||
|
||
term.onData((data) => {
|
||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||
});
|
||
}
|
||
|
||
export function connectWebSocket(resetCallback) {
|
||
if (ws && ws.readyState === WebSocket.OPEN) return;
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數
|
||
|
||
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
||
|
||
ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = () => {
|
||
isConnected = true;
|
||
const btnLoadTask = document.getElementById('btn-load-task');
|
||
if (btnLoadTask) {
|
||
btnLoadTask.disabled = false;
|
||
btnLoadTask.style.backgroundColor = '#c0392b';
|
||
btnLoadTask.style.cursor = 'pointer';
|
||
}
|
||
document.getElementById('wsStatus').textContent = `狀態:已連線`;
|
||
document.getElementById('wsStatus').style.color = '#27ae60';
|
||
document.getElementById('btnConnect').style.display = 'none';
|
||
document.getElementById('btnDisconnect').style.display = 'inline-block';
|
||
document.getElementById('cmtsHost').disabled = true;
|
||
document.getElementById('cmtsUser').disabled = true;
|
||
document.getElementById('cmtsPass').disabled = true;
|
||
term.focus();
|
||
|
||
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
|
||
const queryTaskSelect = document.getElementById('queryTask');
|
||
if (queryTaskSelect) {
|
||
queryTaskSelect.dispatchEvent(new Event('change'));
|
||
}
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
term.write(colorizeTerminalStream(event.data));
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
isConnected = false;
|
||
const btnLoadTask = document.getElementById('btn-load-task');
|
||
if (btnLoadTask) {
|
||
btnLoadTask.disabled = true;
|
||
btnLoadTask.style.backgroundColor = '#95a5a6';
|
||
btnLoadTask.style.cursor = 'not-allowed';
|
||
}
|
||
document.getElementById('wsStatus').textContent = '狀態:已斷線';
|
||
document.getElementById('wsStatus').style.color = '#c0392b';
|
||
document.getElementById('btnConnect').style.display = 'inline-block';
|
||
document.getElementById('btnDisconnect').style.display = 'none';
|
||
document.getElementById('cmtsHost').disabled = false;
|
||
document.getElementById('cmtsUser').disabled = false;
|
||
document.getElementById('cmtsPass').disabled = false;
|
||
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
|
||
|
||
if (resetCallback) resetCallback();
|
||
};
|
||
}
|
||
|
||
export function disconnectWebSocket() { if (ws) ws.close(); }
|
||
|
||
export function injectCommand() {
|
||
if (!ws || ws.readyState !== WebSocket.OPEN) return alert("請先點擊「連線至 CMTS」!");
|
||
const cmd = document.getElementById('fixedCmd').value;
|
||
ws.send(cmd + '\r');
|
||
term.focus();
|
||
}
|
||
|
||
export function getTerminalText() {
|
||
if (!term) return '';
|
||
try {
|
||
if (term.hasSelection()) return term.getSelection();
|
||
const buffer = term.buffer.active;
|
||
let text = '';
|
||
for (let i = 0; i < buffer.length; i++) {
|
||
const line = buffer.getLine(i);
|
||
if (line) text += line.translateToString(true) + '\n';
|
||
}
|
||
return text;
|
||
} catch (err) {
|
||
console.error("讀取終端機文字失敗:", err);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
export function downloadTerminal() {
|
||
const text = getTerminalText();
|
||
if (!text.trim()) return alert("沒有內容可下載!");
|
||
try {
|
||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||
a.download = `cmts_terminal_log_${timestamp}.txt`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
console.error("下載失敗:", err);
|
||
}
|
||
}
|
||
|
||
|
||
================================================================================
|
||
FILE: static/style.css
|
||
================================================================================
|
||
/* --- static/style.css --- */
|
||
|
||
/* =========================================
|
||
全域按鈕色彩系統 (Modern Enterprise Vibe)
|
||
========================================= */
|
||
:root {
|
||
/* 基礎設定 */
|
||
--btn-radius: 6px;
|
||
--btn-transition: all 0.2s ease-in-out;
|
||
|
||
/* 色彩定義 */
|
||
--color-emerald: #059669; /* 連線 */
|
||
--color-emerald-hover: #047857;
|
||
|
||
--color-slate: #64748B; /* 斷開/中性/取消 */
|
||
--color-slate-hover: #475569;
|
||
|
||
--color-blue: #2563EB; /* 載入/查詢 */
|
||
--color-blue-hover: #1D4ED8;
|
||
|
||
--color-indigo: #4F46E5; /* 儲存/套用 */
|
||
--color-indigo-hover: #4338CA;
|
||
|
||
--color-violet: #7C3AED; /* 掃描/自動化 */
|
||
--color-violet-hover: #6D28D9;
|
||
|
||
--color-rose: #DC2626; /* 清除/刪除 */
|
||
--color-rose-hover: #B91C1C;
|
||
|
||
--color-disabled-bg: #E2E8F0;
|
||
--color-disabled-text: #94A3B8;
|
||
--color-busy-bg: #F59E0B; /* 忙碌中(橘黃) */
|
||
}
|
||
|
||
/* 基礎按鈕共用樣式 */
|
||
.btn-modern {
|
||
padding: 8px 16px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
border: none;
|
||
border-radius: var(--btn-radius);
|
||
color: #FFFFFF;
|
||
cursor: pointer;
|
||
transition: var(--btn-transition);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
/* 禁用狀態 (所有按鈕共用) */
|
||
.btn-modern:disabled {
|
||
background-color: var(--color-disabled-bg) !important;
|
||
color: var(--color-disabled-text) !important;
|
||
cursor: not-allowed !important;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* 各別語意 Class */
|
||
.btn-connect { background-color: var(--color-emerald); }
|
||
.btn-connect:hover:not(:disabled) { background-color: var(--color-emerald-hover); }
|
||
|
||
.btn-disconnect { background-color: var(--color-slate); }
|
||
.btn-disconnect:hover:not(:disabled) { background-color: var(--color-slate-hover); }
|
||
|
||
.btn-load { background-color: var(--color-blue); }
|
||
.btn-load:hover:not(:disabled) { background-color: var(--color-blue-hover); }
|
||
|
||
.btn-save { background-color: var(--color-indigo); }
|
||
.btn-save:hover:not(:disabled) { background-color: var(--color-indigo-hover); }
|
||
|
||
.btn-scan { background-color: var(--color-violet); }
|
||
.btn-scan:hover:not(:disabled) { background-color: var(--color-violet-hover); }
|
||
|
||
.btn-clear { background-color: var(--color-rose); }
|
||
.btn-clear:hover:not(:disabled) { background-color: var(--color-rose-hover); }
|
||
|
||
/* 特殊忙碌狀態 (給掃描或載入時使用) */
|
||
.btn-modern.is-busy:disabled {
|
||
background-color: var(--color-busy-bg) !important;
|
||
color: #FFFFFF !important;
|
||
}
|
||
|
||
/* 1. 基礎滿版佈局 (全域 80% 縮放感) */
|
||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 15px 20px; box-sizing: border-box; background-color: #f5f7fa; height: 100vh; display: flex; flex-direction: column; font-size: 14px; }
|
||
h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-size: 24px; }
|
||
|
||
/* 2. 全域設備設定區塊 */
|
||
.global-settings { background-color: #e8ecef; padding: 8px 15px; border-radius: 6px; border: 1px solid #d1d8dd; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-shrink: 0; flex-wrap: wrap; font-size: 13px; }
|
||
|
||
/* 3. 頁籤樣式 (🌟 修復 Hover 狀態,確保字體清晰) */
|
||
.tabs { display: flex; margin-bottom: 12px; border-bottom: 2px solid #ddd; flex-shrink: 0; }
|
||
.tab-btn { padding: 8px 18px; cursor: pointer; background: transparent; border: none; font-size: 15px; font-weight: bold; color: #7f8c8d; transition: all 0.2s ease; border-radius: 6px 6px 0 0; }
|
||
.tab-btn:hover { color: #2c3e50; background-color: #e2e8f0; } /* 滑鼠移入時:深色字+淺灰底 */
|
||
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; background-color: transparent; }
|
||
|
||
/* 4. 內容區塊 */
|
||
.tab-content { display: none; background: white; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; box-sizing: border-box; max-width: 100%; overflow: hidden; }
|
||
.tab-content.active { display: flex; flex-direction: column; }
|
||
|
||
/* 5. 控制列樣式 (微縮元件) */
|
||
.control-group { margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
|
||
select, input[type="text"], input[type="password"] { padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
|
||
button { padding: 6px 14px; background-color: #2980b9; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 13px; transition: background-color 0.2s; }
|
||
button:hover { background-color: #3498db; }
|
||
|
||
/* 6. 終端機與輸出區塊 */
|
||
#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; min-height: 0; overflow: hidden; position: relative; }
|
||
.xterm { padding: 10px; height: 100%; box-sizing: border-box; }
|
||
|
||
/* 7. 專業級表單排版 (🌟 縮小間距,解決空白過多) */
|
||
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 12px; }
|
||
.task-form { display: none; background: #ffffff; padding: 18px 20px; border-radius: 8px; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px rgba(0,0,0,0.02); }
|
||
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
|
||
|
||
/* 網格系統:高空間利用率 */
|
||
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
|
||
.form-row { display: flex; flex-direction: column; gap: 4px; }
|
||
.form-row label { font-size: 12px; font-weight: bold; color: #34495e; }
|
||
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
|
||
.form-row select { padding-right: 30px; text-overflow: ellipsis; } /* 🌟 確保下拉箭頭有足夠空間,不會遮擋文字 */
|
||
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }
|
||
|
||
/* 表單底部操作區 */
|
||
.form-actions { display: flex; justify-content: flex-start; align-items: center; gap: 15px; padding-top: 12px; border-top: 1px solid #ecf0f1; }
|
||
.form-actions button { padding: 8px 20px; font-size: 14px; min-width: 120px; }
|
||
|
||
/* 8. 彈出式輸出視窗 (Modal) 樣式 */
|
||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; backdrop-filter: blur(3px); }
|
||
.modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; }
|
||
.modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; }
|
||
.modal-overlay.active .modal-container { transform: translateY(0); }
|
||
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
|
||
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
|
||
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
|
||
.modal-body {
|
||
flex-grow: 1;
|
||
padding: 0 !important; /* 🌟 關鍵:移除原本 20px 的留白 */
|
||
background-color: #1e1e1e;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden; /* 將捲軸交給內層的 terminal 處理 */
|
||
}
|
||
|
||
/* 2. 徹底移除終端機的邊框,並接管滿版空間 */
|
||
.readonly-terminal {
|
||
margin: 0 !important;
|
||
padding: 15px 20px !important; /* 保留適當的文字呼吸空間,不讓字完全貼死邊緣 */
|
||
color: #e0e0e0;
|
||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
background-color: transparent !important; /* 🌟 關鍵:背景透明,融入 Modal */
|
||
border: none !important; /* 🌟 關鍵:拔除所有細邊框 */
|
||
box-shadow: none !important;
|
||
border-radius: 0 !important;
|
||
flex-grow: 1;
|
||
overflow-y: auto; /* 垂直捲動 */
|
||
overflow-x: auto; /* 水平捲動 */
|
||
white-space: pre; /* 強制不折行以支援水平滑動,保護版面 */
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 樹狀圖節點的容器樣式 */
|
||
.tree-node-header {
|
||
display: flex;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
padding: 6px 8px;
|
||
user-select: none;
|
||
color: #2c3e50; /* 保持原本的深色文字 */
|
||
transition: background-color 0.2s ease;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* 單一設定項目的容器樣式與懸停高光 */
|
||
.tree-leaf-node {
|
||
transition: background-color 0.2s ease;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* 💡 輕量化懸停效果:改用極淺的灰藍色 */
|
||
.tree-node-header:hover {
|
||
background-color: #eef2f5; /* 非常柔和的背景色 */
|
||
/* 這裡不需要再改變文字顏色了,深色字在淺色背景上非常清晰 */
|
||
}
|
||
|
||
.tree-leaf-node:hover {
|
||
background-color: #eef2f5; /* 與目錄相同的淺灰藍色背景 */
|
||
}
|
||
|
||
/* 1. 旋轉小箭頭 (Chevron) */
|
||
.tree-chevron {
|
||
width: 16px;
|
||
height: 16px;
|
||
margin-right: 6px;
|
||
/* 內建灰色箭頭 SVG */
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2395a5a6'><path d='M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z'/></svg>");
|
||
background-size: contain;
|
||
background-repeat: no-repeat;
|
||
transition: transform 0.2s ease-in-out; /* 💡 旋轉動畫核心 */
|
||
}
|
||
|
||
/* 展開時:箭頭向下轉 90 度 */
|
||
.tree-node-header.is-open .tree-chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
/* 2. 資料夾圖示 (預設為關閉狀態) */
|
||
.tree-folder-icon {
|
||
width: 18px;
|
||
height: 18px;
|
||
margin-right: 8px;
|
||
/* 內建金色關閉資料夾 SVG */
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f1c40f'><path d='M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z'/></svg>");
|
||
background-size: contain;
|
||
background-repeat: no-repeat;
|
||
}
|
||
|
||
/* 展開時:切換為開啟的資料夾 */
|
||
.tree-node-header.is-open .tree-folder-icon {
|
||
/* 內建金色開啟資料夾 SVG */
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f1c40f'><path d='M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.56 8.59 6.51 8 7.46 8H20V6c0-1.1-.9-2-2-2h-8l-2-2H4z'/></svg>");
|
||
}
|
||
|
||
/* 隱藏原生 Checkbox,改為自訂的過濾器樣式 */
|
||
.filter-checkbox {
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid #bdc3c7;
|
||
border-radius: 4px;
|
||
background-color: #fff;
|
||
cursor: pointer;
|
||
position: relative;
|
||
display: inline-block;
|
||
vertical-align: middle;
|
||
margin-right: 8px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
/* 懸停時提示顏色 */
|
||
.filter-checkbox:hover {
|
||
border-color: #e74c3c;
|
||
}
|
||
|
||
/* 勾選時背景變紅,並顯示 ✖ */
|
||
.filter-checkbox:checked {
|
||
background-color: #e74c3c;
|
||
border-color: #e74c3c;
|
||
}
|
||
|
||
.filter-checkbox:checked::after {
|
||
content: '✖';
|
||
color: white;
|
||
font-size: 11px;
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* 針對 SweetAlert2 彈窗內的終端機強制去框 */
|
||
.swal2-html-container pre {
|
||
border: none !important;
|
||
background: transparent !important;
|
||
padding: 15px !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||
|
||
/* =========================================
|
||
🚨 密碼輸入錯誤震動動畫 (Shake Effect)
|
||
========================================= */
|
||
@keyframes shake {
|
||
0%, 100% { transform: translateX(0); }
|
||
10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
|
||
20%, 40%, 60%, 80% { transform: translateX(6px); }
|
||
}
|
||
|
||
.shake-animation {
|
||
animation: shake 0.4s ease-in-out;
|
||
border-color: #e74c3c !important; /* 震動時外框變紅 */
|
||
background-color: #fdedec !important;
|
||
}
|
||
|
||
|
||
================================================================================
|
||
FILE: static/edit-mode.js
|
||
================================================================================
|
||
// --- static/edit-mode.js ---
|
||
|
||
import {
|
||
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
|
||
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
|
||
apiSyncLeafOptionsStream
|
||
} from './api.js';
|
||
import { getGlobalConnectionInfo } from './utils.js';
|
||
|
||
// --- 安全跳脫 HTML 特殊字元,防止破版 ---
|
||
function escapeHTML(str) {
|
||
if (str === null || str === undefined) return "";
|
||
return String(str)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// ==========================================
|
||
// 1. 全域狀態與鎖定管理 (Lock Management)
|
||
// ==========================================
|
||
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
||
const ACTIVE_HEARTBEATS = {};
|
||
|
||
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
|
||
const activeEditSessions = new Set();
|
||
|
||
export let currentEditPath = null;
|
||
export let currentEditElementId = null;
|
||
export let currentEditIsSuccess = false;
|
||
export let currentEditType = null;
|
||
export let currentEditDiffs = [];
|
||
|
||
export async function releaseAllLocks() {
|
||
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
||
|
||
// 🌟 1. 檢查當前是否處於 God Mode 狀態
|
||
const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||
|
||
// 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限
|
||
sessionStorage.removeItem('godModeUnlocked');
|
||
|
||
if (keysToRelease.length === 0) {
|
||
// 如果沒有正在編輯的節點,但剛剛是 God Mode,依然要登出並重整
|
||
if (wasGodModeUnlocked) {
|
||
alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。");
|
||
location.reload();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const releasePromises = keysToRelease.map(key => {
|
||
const [host, path] = key.split('@@');
|
||
clearInterval(ACTIVE_HEARTBEATS[key]);
|
||
delete ACTIVE_HEARTBEATS[key];
|
||
return apiReleaseLock(path, SESSION_USER_ID, host)
|
||
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
|
||
});
|
||
|
||
await Promise.all(releasePromises);
|
||
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。");
|
||
location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態
|
||
}
|
||
|
||
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||
try {
|
||
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
|
||
if (!response.ok) {
|
||
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
|
||
if (response.status === 404) {
|
||
clearInterval(intervalId);
|
||
if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
|
||
delete ACTIVE_HEARTBEATS[lockKey];
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`心跳請求發生錯誤 (${path}):`, error);
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 2. 編輯模式啟動與取消 (Folder & Leaf)
|
||
// ==========================================
|
||
|
||
export async function startEditFolder(path, elementId) {
|
||
// 🚨 防護 1:防止連點或重複執行
|
||
if (activeEditSessions.has(elementId)) return;
|
||
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
const username = connInfo ? connInfo.user : 'admin';
|
||
const host = connInfo ? connInfo.host : '';
|
||
const lockKey = `${host}@@${path}`;
|
||
|
||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
|
||
// 立即讓按鈕呈現讀取中,防止使用者焦慮連點
|
||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'none';
|
||
editBtn.innerText = "⏳";
|
||
}
|
||
|
||
try {
|
||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
||
|
||
// 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消
|
||
if (!activeEditSessions.has(elementId)) {
|
||
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
||
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
|
||
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||
apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去
|
||
}
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.style.opacity = '0.3';
|
||
editBtn.title = "鎖定並編輯此項目";
|
||
editBtn.innerText = "✏️";
|
||
}
|
||
return; // 立即中斷,絕不觸碰 UI
|
||
}
|
||
|
||
if (result.status === 409) {
|
||
activeEditSessions.delete(elementId);
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.innerText = "✏️";
|
||
}
|
||
return alert(`⚠️ ${result.data.detail}`);
|
||
}
|
||
|
||
if (result.data.status === 'success') {
|
||
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||
let intervalId;
|
||
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
||
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
||
|
||
// 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
|
||
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
||
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
||
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
||
|
||
// 🌟 確保不在冷卻名單中 (防止手速過快)
|
||
if (window.recentlyReleasedLocks) {
|
||
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
||
}
|
||
|
||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
// 🌟 擴大選擇器:連同所有子節點一起秒級鎖定
|
||
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
|
||
if (btn.id !== `edit-btn-${elementId}`) {
|
||
btn.style.pointerEvents = 'none';
|
||
btn.style.opacity = '0.2';
|
||
|
||
const btnPath = btn.getAttribute('data-path');
|
||
if (btnPath === path) {
|
||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
||
} else {
|
||
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||
}
|
||
btn.innerText = "🔒";
|
||
}
|
||
});
|
||
|
||
if (editBtn) editBtn.style.display = 'none';
|
||
|
||
const actionBtn = document.getElementById(`actions-${elementId}`);
|
||
if (actionBtn) actionBtn.style.display = 'inline-block';
|
||
|
||
const detailsEl = document.getElementById(`details-${elementId}`);
|
||
if (detailsEl) detailsEl.open = true;
|
||
|
||
const contentDiv = document.getElementById(`content-${elementId}`);
|
||
if (!contentDiv) {
|
||
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
|
||
return;
|
||
}
|
||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||
|
||
let optData = {};
|
||
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
|
||
|
||
// 🚨 防護 3:如果等待 API 期間使用者按了取消,立刻中斷後續渲染
|
||
if (!activeEditSessions.has(elementId)) return;
|
||
|
||
const pathsToSync = [];
|
||
const containersArray = Array.from(leafContainers);
|
||
const CHUNK_SIZE = 50;
|
||
|
||
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
||
// 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷
|
||
if (!activeEditSessions.has(elementId)) return;
|
||
|
||
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
||
|
||
chunk.forEach(container => {
|
||
const origVal = container.getAttribute('data-original');
|
||
const rawPath = container.getAttribute('data-path');
|
||
const safeOrigVal = escapeHTML(origVal);
|
||
|
||
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||
let leafCache = optData[cacheKey];
|
||
|
||
if (!leafCache) pathsToSync.push(cacheKey);
|
||
|
||
let inputHtml = "";
|
||
let hintAttr = "";
|
||
let hintIcon = "";
|
||
|
||
if (leafCache && leafCache.hint) {
|
||
const safeHint = leafCache.hint.replace(/"/g, '"');
|
||
hintAttr = `title="${safeHint}"`;
|
||
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
|
||
}
|
||
|
||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||
let options = leafCache.options;
|
||
if (origVal && !options.includes(origVal)) options = [origVal, ...options];
|
||
|
||
inputHtml = `<select class="edit-input" data-path="${rawPath}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
|
||
options.forEach(opt => {
|
||
const safeOpt = escapeHTML(opt);
|
||
const isSelected = (opt === origVal) ? 'selected' : '';
|
||
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
||
});
|
||
inputHtml += `</select>${hintIcon}`;
|
||
} else {
|
||
inputHtml = `<input type="text" class="edit-input" data-path="${rawPath}" value="${safeOrigVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
|
||
}
|
||
container.innerHTML = inputHtml;
|
||
});
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
}
|
||
|
||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
||
}
|
||
} catch (error) {
|
||
activeEditSessions.delete(elementId);
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.innerText = "✏️";
|
||
}
|
||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||
}
|
||
}
|
||
|
||
export async function startEditLeaf(path, elementId) {
|
||
// 🚨 防護 1:防止連點或重複執行
|
||
if (activeEditSessions.has(elementId)) return;
|
||
activeEditSessions.add(elementId);
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
const username = connInfo ? connInfo.user : 'admin';
|
||
const host = connInfo ? connInfo.host : '';
|
||
const lockKey = `${host}@@${path}`;
|
||
|
||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
|
||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'none';
|
||
editBtn.innerText = "⏳";
|
||
}
|
||
|
||
try {
|
||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
||
|
||
// 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消
|
||
if (!activeEditSessions.has(elementId)) {
|
||
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
||
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
|
||
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||
apiReleaseLock(path, SESSION_USER_ID, host);
|
||
}
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.style.opacity = '0.3';
|
||
editBtn.title = "鎖定並編輯此項目";
|
||
editBtn.innerText = "✏️";
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (result.status === 409) {
|
||
activeEditSessions.delete(elementId);
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.innerText = "✏️";
|
||
}
|
||
return alert(`⚠️ ${result.data.detail}`);
|
||
}
|
||
|
||
if (result.data.status === 'success') {
|
||
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||
let intervalId;
|
||
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
||
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
||
|
||
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
||
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
||
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
||
|
||
if (window.recentlyReleasedLocks) {
|
||
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
||
}
|
||
|
||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
|
||
if (btn.id !== `edit-btn-${elementId}`) {
|
||
btn.style.pointerEvents = 'none';
|
||
btn.style.opacity = '0.2';
|
||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
||
btn.innerText = "🔒";
|
||
}
|
||
});
|
||
|
||
if (editBtn) editBtn.style.display = 'none';
|
||
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
|
||
|
||
const container = document.getElementById(`container-${elementId}`);
|
||
const origVal = container.getAttribute('data-original');
|
||
const safeOrigVal = escapeHTML(origVal);
|
||
|
||
// 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22)
|
||
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
|
||
|
||
try {
|
||
let optData = await apiGetLeafOptions(host, currentMode);
|
||
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||
let leafCache = optData[cacheKey];
|
||
|
||
if (!leafCache) {
|
||
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
||
let found = false;
|
||
for (let i = 0; i < 10; i++) {
|
||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
optData = await apiGetLeafOptions(host, currentMode);
|
||
leafCache = optData[cacheKey];
|
||
|
||
// 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來
|
||
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
|
||
found = true; break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||
|
||
let inputHtml = "";
|
||
let hintAttr = "";
|
||
let hintIcon = "";
|
||
|
||
if (leafCache && leafCache.hint) {
|
||
const safeHint = leafCache.hint.replace(/"/g, '"');
|
||
hintAttr = `title="${safeHint}"`;
|
||
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
|
||
}
|
||
|
||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||
let options = leafCache.options;
|
||
if (origVal && !options.includes(origVal)) options = [origVal, ...options];
|
||
|
||
inputHtml = `<select class="edit-input" data-path="${path}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
|
||
options.forEach(opt => {
|
||
const safeOpt = escapeHTML(opt);
|
||
const isSelected = (opt === origVal) ? 'selected' : '';
|
||
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
||
});
|
||
inputHtml += `</select>${hintIcon}`;
|
||
} else {
|
||
inputHtml = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
|
||
}
|
||
container.innerHTML = inputHtml;
|
||
} catch (e) {
|
||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
activeEditSessions.delete(elementId);
|
||
if (editBtn) {
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.innerText = "✏️";
|
||
}
|
||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||
}
|
||
}
|
||
|
||
export async function cancelEditFolder(path, elementId) {
|
||
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
const host = connInfo ? connInfo.host : '';
|
||
const lockKey = `${host}@@${path}`;
|
||
|
||
if (ACTIVE_HEARTBEATS[lockKey]) {
|
||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||
delete ACTIVE_HEARTBEATS[lockKey];
|
||
}
|
||
|
||
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
|
||
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||
|
||
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||
|
||
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
||
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
||
delete window.globalActiveLocks[host][path];
|
||
}
|
||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
// 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定
|
||
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
|
||
if (btn.id !== `edit-btn-${elementId}`) {
|
||
const btnPath = btn.getAttribute('data-path');
|
||
let stillLockedByOther = false;
|
||
|
||
// 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點
|
||
if (window.globalActiveLocks && window.globalActiveLocks[host]) {
|
||
const hostLocks = window.globalActiveLocks[host];
|
||
if (hostLocks[btnPath]) {
|
||
stillLockedByOther = true;
|
||
} else {
|
||
for (const lockedPath of Object.keys(hostLocks)) {
|
||
if (btnPath.startsWith(lockedPath + "::")) {
|
||
stillLockedByOther = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!stillLockedByOther) {
|
||
btn.style.pointerEvents = 'auto';
|
||
btn.style.opacity = '0.3';
|
||
btn.title = "鎖定並編輯此項目";
|
||
btn.innerText = "✏️";
|
||
}
|
||
}
|
||
});
|
||
|
||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||
if (editBtn) {
|
||
editBtn.style.display = 'inline-block';
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.style.opacity = '0.3';
|
||
editBtn.title = "鎖定並編輯此項目";
|
||
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
|
||
}
|
||
|
||
const actionBtns = document.getElementById(`actions-${elementId}`);
|
||
if (actionBtns) actionBtns.style.display = 'none';
|
||
|
||
const contentDiv = document.getElementById(`content-${elementId}`);
|
||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||
leafContainers.forEach(container => {
|
||
const origVal = container.getAttribute('data-original');
|
||
const safeOrigVal = escapeHTML(origVal);
|
||
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
||
});
|
||
|
||
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
|
||
if (currentEditElementId === elementId) {
|
||
document.querySelectorAll('.tree-view-instance').forEach(pane => {
|
||
pane.style.flex = '1';
|
||
pane.style.width = '100%';
|
||
pane.style.maxWidth = 'none';
|
||
});
|
||
const rightPane = document.getElementById('side-cli-preview');
|
||
if (rightPane) {
|
||
rightPane.style.flex = '1';
|
||
rightPane.style.maxWidth = '50%';
|
||
rightPane.style.width = '';
|
||
rightPane.style.display = 'none';
|
||
}
|
||
document.getElementById('drag-resizer').style.display = 'none';
|
||
|
||
currentEditPath = null;
|
||
currentEditElementId = null;
|
||
currentEditIsSuccess = false;
|
||
currentEditType = null;
|
||
}
|
||
}
|
||
|
||
export async function cancelEditLeaf(path, elementId) {
|
||
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||
|
||
const connInfo = getGlobalConnectionInfo();
|
||
const host = connInfo ? connInfo.host : '';
|
||
const lockKey = `${host}@@${path}`;
|
||
|
||
if (ACTIVE_HEARTBEATS[lockKey]) {
|
||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||
delete ACTIVE_HEARTBEATS[lockKey];
|
||
}
|
||
|
||
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
|
||
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||
|
||
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||
|
||
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
||
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
||
delete window.globalActiveLocks[host][path];
|
||
}
|
||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
|
||
if (btn.id !== `edit-btn-${elementId}`) {
|
||
btn.style.pointerEvents = 'auto';
|
||
btn.style.opacity = '0.3';
|
||
btn.title = "鎖定並編輯此項目";
|
||
btn.innerText = "✏️";
|
||
}
|
||
});
|
||
|
||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||
if (editBtn) {
|
||
editBtn.style.display = 'inline-block';
|
||
editBtn.style.pointerEvents = 'auto';
|
||
editBtn.style.opacity = '0.3';
|
||
editBtn.title = "鎖定並編輯此項目";
|
||
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
|
||
}
|
||
|
||
const actionBtns = document.getElementById(`actions-${elementId}`);
|
||
if (actionBtns) actionBtns.style.display = 'none';
|
||
|
||
const container = document.getElementById(`container-${elementId}`);
|
||
const origVal = container.getAttribute('data-original');
|
||
const safeOrigVal = escapeHTML(origVal);
|
||
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
||
|
||
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
|
||
if (currentEditElementId === elementId) {
|
||
document.querySelectorAll('.tree-view-instance').forEach(pane => {
|
||
pane.style.flex = '1';
|
||
pane.style.width = '100%';
|
||
pane.style.maxWidth = 'none';
|
||
});
|
||
const rightPane = document.getElementById('side-cli-preview');
|
||
if (rightPane) {
|
||
rightPane.style.flex = '1';
|
||
rightPane.style.maxWidth = '50%';
|
||
rightPane.style.width = '';
|
||
rightPane.style.display = 'none';
|
||
}
|
||
document.getElementById('drag-resizer').style.display = 'none';
|
||
|
||
currentEditPath = null;
|
||
currentEditElementId = null;
|
||
currentEditIsSuccess = false;
|
||
currentEditType = null;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 3. CLI 生成與預覽 (CLI Generation)
|
||
// ==========================================
|
||
function getInterfacesWithAdminState() {
|
||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
|
||
if (!activeContainer) return [];
|
||
|
||
const nodes = activeContainer.querySelectorAll('.leaf-container');
|
||
const interfaces = new Set();
|
||
nodes.forEach(node => {
|
||
const path = node.getAttribute('data-path');
|
||
if (path && path.endsWith('::admin-state')) {
|
||
const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
interfaces.add(interfacePath);
|
||
}
|
||
});
|
||
return Array.from(interfaces);
|
||
}
|
||
|
||
export async function previewFolderCLI(path, elementId) {
|
||
currentEditPath = path;
|
||
currentEditElementId = elementId;
|
||
currentEditIsSuccess = false;
|
||
currentEditType = 'folder';
|
||
|
||
const contentDiv = document.getElementById(`content-${elementId}`);
|
||
if (!contentDiv) return;
|
||
|
||
const inputs = contentDiv.querySelectorAll('.edit-input');
|
||
const diffs = [];
|
||
|
||
inputs.forEach(input => {
|
||
const container = input.closest('.leaf-container');
|
||
if (!container) return;
|
||
const originalVal = container.getAttribute('data-original') || "";
|
||
const newVal = input.value.trim();
|
||
const nodePath = container.getAttribute('data-path');
|
||
|
||
if (originalVal !== newVal && nodePath) {
|
||
diffs.push({ path: nodePath, old_val: originalVal, new_val: newVal });
|
||
}
|
||
});
|
||
|
||
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
|
||
currentEditDiffs = diffs;
|
||
|
||
try {
|
||
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||
if (result.status === 'success') {
|
||
showCliPreviewModal(result.data);
|
||
} else {
|
||
alert("CLI 生成失敗: " + result.message);
|
||
}
|
||
} catch (error) {
|
||
console.error("Error generating CLI:", error);
|
||
alert("網路連線錯誤,無法生成 CLI");
|
||
}
|
||
}
|
||
|
||
export async function previewLeafCLI(path, elementId) {
|
||
currentEditPath = path;
|
||
currentEditElementId = elementId;
|
||
currentEditIsSuccess = false;
|
||
currentEditType = 'leaf';
|
||
|
||
const container = document.getElementById(`container-${elementId}`);
|
||
if (!container) return;
|
||
|
||
const input = container.querySelector('.edit-input');
|
||
if (!input) return;
|
||
|
||
const originalVal = container.getAttribute('data-original') || "";
|
||
const newVal = input.value.trim();
|
||
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
|
||
|
||
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
|
||
currentEditDiffs = diffs;
|
||
|
||
try {
|
||
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||
if (result.status === 'success') {
|
||
showCliPreviewModal(result.data);
|
||
} else {
|
||
alert("CLI 生成失敗: " + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert("網路連線錯誤,無法生成 CLI");
|
||
}
|
||
}
|
||
|
||
function showCliPreviewModal(cliScript) {
|
||
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
||
const resizer = document.getElementById('drag-resizer');
|
||
const rightPane = document.getElementById('side-cli-preview');
|
||
|
||
document.getElementById('side-pane-title').innerHTML = '⚠️ 即將寫入的指令';
|
||
document.getElementById('side-pane-title').style.color = '#f1c40f';
|
||
document.getElementById('btn-side-cancel').style.display = 'inline-block';
|
||
document.getElementById('btn-side-confirm').style.display = 'inline-block';
|
||
document.getElementById('btn-side-close').style.display = 'none';
|
||
document.getElementById('btn-side-close').textContent = '關閉';
|
||
document.getElementById('side-cli-textarea').style.display = 'block';
|
||
document.getElementById('side-execution-result').style.display = 'none';
|
||
|
||
const textarea = document.getElementById('side-cli-textarea');
|
||
textarea.value = cliScript;
|
||
|
||
textarea.readOnly = false;
|
||
textarea.style.backgroundColor = '#1e1e1e';
|
||
textarea.style.color = '#ecf0f1';
|
||
|
||
// 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
|
||
leftPanes.forEach(pane => {
|
||
pane.style.flex = 'none';
|
||
pane.style.maxWidth = 'none';
|
||
pane.style.width = 'calc(50% - 11px)';
|
||
});
|
||
rightPane.style.flex = 'none';
|
||
rightPane.style.maxWidth = 'none';
|
||
rightPane.style.width = 'calc(50% - 11px)';
|
||
|
||
resizer.style.display = 'flex';
|
||
rightPane.style.display = 'block';
|
||
|
||
if (!window.isResizerInitialized) {
|
||
initResizer();
|
||
window.isResizerInitialized = true;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 4. 側邊欄與執行邏輯
|
||
// ==========================================
|
||
export async function hideSideCLI() {
|
||
// 🌟 完美修復:明確恢復 Flexbox 伸展能力與 100% 寬度
|
||
document.querySelectorAll('.tree-view-instance').forEach(pane => {
|
||
pane.style.flex = '1'; // 恢復 HTML 原本賦予的彈性伸展能力
|
||
pane.style.width = '100%'; // 強制撐滿整個父容器
|
||
pane.style.maxWidth = 'none';
|
||
});
|
||
|
||
const rightPane = document.getElementById('side-cli-preview');
|
||
if (rightPane) {
|
||
rightPane.style.flex = '1';
|
||
rightPane.style.maxWidth = '50%'; // 恢復 HTML 原本的 50% 限制
|
||
rightPane.style.width = '';
|
||
rightPane.style.display = 'none';
|
||
}
|
||
|
||
document.getElementById('drag-resizer').style.display = 'none';
|
||
|
||
// 以下為原本的取消編輯與還原邏輯,完全保持不變,確保功能安全
|
||
if (currentEditIsSuccess && currentEditElementId && currentEditPath) {
|
||
const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`);
|
||
if (wrapper) {
|
||
const container = document.getElementById(`container-${currentEditElementId}`);
|
||
const input = container ? container.querySelector('.edit-input') : null;
|
||
const newCommand = input ? input.value.trim() : (container ? container.getAttribute('data-original') : '');
|
||
|
||
if (currentEditType === 'folder') await cancelEditFolder(currentEditPath, currentEditElementId);
|
||
else await cancelEditLeaf(currentEditPath, currentEditElementId);
|
||
|
||
transformNoToActive(currentEditElementId, newCommand);
|
||
} else {
|
||
if (currentEditType === 'folder') await applyEditFolder(currentEditPath, currentEditElementId);
|
||
else if (currentEditType === 'leaf') await applyEditLeaf(currentEditPath, currentEditElementId);
|
||
}
|
||
}
|
||
|
||
currentEditPath = null;
|
||
currentEditElementId = null;
|
||
currentEditIsSuccess = false;
|
||
currentEditType = null;
|
||
}
|
||
|
||
async function applyEditFolder(path, elementId) {
|
||
const contentDiv = document.getElementById(`content-${elementId}`);
|
||
if (contentDiv) {
|
||
const inputs = contentDiv.querySelectorAll('.edit-input');
|
||
inputs.forEach(input => {
|
||
const container = input.closest('.leaf-container');
|
||
if (container) container.setAttribute('data-original', input.value.trim());
|
||
});
|
||
}
|
||
await cancelEditFolder(path, elementId);
|
||
}
|
||
|
||
async function applyEditLeaf(path, elementId) {
|
||
const container = document.getElementById(`container-${elementId}`);
|
||
if (container) {
|
||
const input = container.querySelector('.edit-input');
|
||
if (input) container.setAttribute('data-original', input.value.trim());
|
||
}
|
||
await cancelEditLeaf(path, elementId);
|
||
}
|
||
|
||
export function executeSideCLI() {
|
||
const finalScript = document.getElementById('side-cli-textarea').value;
|
||
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
|
||
document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
|
||
document.getElementById('btn-side-cancel').style.display = 'none';
|
||
document.getElementById('btn-side-confirm').style.display = 'none';
|
||
document.getElementById('btn-side-close').style.display = 'none';
|
||
document.getElementById('side-cli-textarea').style.display = 'none';
|
||
|
||
const resultBox = document.getElementById('side-execution-result');
|
||
resultBox.style.display = 'block';
|
||
resultBox.innerHTML = `<span style="color: #ecf0f1;">${finalScript}</span>`;
|
||
|
||
executeGeneratedCLI(finalScript);
|
||
}
|
||
|
||
async function executeGeneratedCLI(script) {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
const outputEl = document.getElementById('side-execution-result');
|
||
|
||
// 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格)
|
||
outputEl.innerHTML = `
|
||
<div id="edit-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; margin: 0; box-sizing: border-box;">
|
||
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入指令,請勿關閉視窗...</div>
|
||
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
|
||
</div>
|
||
`;
|
||
const logContainer = document.getElementById('edit-log-container');
|
||
|
||
// 🌟 修正 1: 捲動目標必須是真正有捲軸的容器
|
||
const scrollTarget = document.getElementById('side-execution-result');
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/cmts-config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
script: script,
|
||
host: connInfo.host,
|
||
username: connInfo.user,
|
||
password: connInfo.pass
|
||
})
|
||
});
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
let isSuccess = false;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
for (let line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const data = JSON.parse(line);
|
||
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
|
||
|
||
if (data.status === 'progress') {
|
||
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
|
||
} else if (data.status === 'success') {
|
||
isSuccess = true;
|
||
logContainer.innerHTML += `
|
||
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 14px;">[${timeStr}] ${data.message}</div>
|
||
`;
|
||
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在同步快取...';
|
||
document.getElementById('side-pane-title').style.color = '#f39c12';
|
||
} else if (data.status === 'error') {
|
||
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
|
||
if (data.output) {
|
||
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
|
||
}
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
|
||
document.getElementById('side-pane-title').style.color = '#ff6b6b';
|
||
}
|
||
|
||
// 自動捲動到最底部
|
||
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||
} catch (e) {
|
||
console.warn("解析串流 JSON 失敗:", line);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 串流結束後,執行快取同步
|
||
if (isSuccess) {
|
||
currentEditIsSuccess = true;
|
||
|
||
const pathsToSync = currentEditDiffs.map(d => {
|
||
let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
if (d.path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + d.old_val;
|
||
return cacheKey;
|
||
});
|
||
const uniquePaths = [...new Set(pathsToSync)];
|
||
|
||
try {
|
||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
let isSyncDone = false;
|
||
|
||
// 🌟 修正 2: 先啟動 SSE 監聽 (避免錯過 done 事件)
|
||
const streamPromise = apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
|
||
const titleEl = document.getElementById('side-pane-title');
|
||
|
||
if (data.event === 'done' || data.status === 'done') {
|
||
isSyncDone = true;
|
||
if (titleEl) {
|
||
titleEl.innerHTML = '✅ 寫入成功!快取同步完成';
|
||
titleEl.style.color = '#2ecc71';
|
||
}
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
logContainer.innerHTML += `<br><span style="color: #2ecc71; font-weight: bold;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
|
||
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||
|
||
} else if (data.event === 'error' || data.status === 'error') {
|
||
isSyncDone = true;
|
||
if (titleEl) {
|
||
titleEl.innerHTML = '⚠️ 寫入成功 (但同步快取失敗)';
|
||
titleEl.style.color = '#e74c3c';
|
||
}
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
|
||
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||
}
|
||
}, currentMode);
|
||
|
||
// 🌟 修正 3: 再觸發後端同步任務
|
||
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);
|
||
|
||
// 🌟 修正 4: 加入 8 秒防呆機制,如果後端默默做完沒通知,自動放行
|
||
setTimeout(() => {
|
||
if (!isSyncDone) {
|
||
const titleEl = document.getElementById('side-pane-title');
|
||
if (titleEl) {
|
||
titleEl.innerHTML = '✅ 寫入成功!(快取同步已於背景完成)';
|
||
titleEl.style.color = '#2ecc71';
|
||
}
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
logContainer.innerHTML += `<br><span style="color: #f39c12; font-weight: bold;">[系統] 快取同步已於背景執行,您可以關閉此面板。</span>`;
|
||
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||
}
|
||
}, 8000);
|
||
|
||
await streamPromise;
|
||
|
||
} catch (syncErr) {
|
||
console.error("同步設定失敗:", syncErr);
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
}
|
||
} else {
|
||
currentEditIsSuccess = false;
|
||
}
|
||
|
||
} catch (error) {
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤';
|
||
document.getElementById('side-pane-title').style.color = '#ff6b6b';
|
||
logContainer.innerHTML += `<div style="color: #ff6b6b; font-weight: bold; margin-top: 10px;">連線或伺服器錯誤: ${error}</div>`;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 5. 輔助 UI 函數
|
||
// ==========================================
|
||
function initResizer() {
|
||
const resizer = document.getElementById('drag-resizer');
|
||
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
||
const rightPane = document.getElementById('side-cli-preview');
|
||
const container = document.getElementById('split-container');
|
||
let isResizing = false;
|
||
|
||
resizer.addEventListener('mousedown', (e) => {
|
||
isResizing = true;
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isResizing) return;
|
||
const containerRect = container.getBoundingClientRect();
|
||
let newLeftWidth = e.clientX - containerRect.left;
|
||
const minWidth = containerRect.width * 0.2;
|
||
const maxWidth = containerRect.width * 0.8;
|
||
if (newLeftWidth < minWidth) newLeftWidth = minWidth;
|
||
if (newLeftWidth > maxWidth) newLeftWidth = maxWidth;
|
||
|
||
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
|
||
const rightPercentage = 100 - leftPercentage;
|
||
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
|
||
rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (isResizing) {
|
||
isResizing = false;
|
||
document.body.style.cursor = 'default';
|
||
document.body.style.userSelect = 'auto';
|
||
}
|
||
});
|
||
}
|
||
|
||
function highlightTerminalOutput(text) {
|
||
if (!text) return '';
|
||
const lines = text.split('\n');
|
||
const formattedLines = lines.map(line => {
|
||
if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) {
|
||
return `<span style="color: #ff6b6b; font-weight: bold;">${line}</span>`;
|
||
}
|
||
return line;
|
||
});
|
||
return formattedLines.join('\n');
|
||
}
|
||
|
||
function transformNoToActive(elementId, newCommand) {
|
||
const wrapper = document.getElementById(`display-wrapper-${elementId}`);
|
||
if (!wrapper) return;
|
||
|
||
const parts = newCommand.trim().split(' ');
|
||
const firstWord = parts[0];
|
||
const restCommand = parts.slice(1).join(' ');
|
||
|
||
const safeRestCommand = escapeHTML(restCommand);
|
||
|
||
const svgEnabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#27ae60"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zM8.29 11.59L7 12.88l4 4 8-8-1.29-1.29L11 14.18z"/></svg>`;
|
||
const iconHtml = `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${svgEnabled}</span>`;
|
||
|
||
wrapper.innerHTML = `
|
||
${iconHtml}
|
||
<b style="color: #2c3e50;">${firstWord}</b>
|
||
<span class="leaf-container" style="display: inline-flex; align-items: center; min-height: 24px;">
|
||
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeRestCommand}</span>
|
||
<span style="font-size: 0.8em; color: #f39c12; margin-left: 10px; border: 1px solid #f39c12; padding: 2px 6px; border-radius: 4px; background-color: #fffdf7;">(已啟用,重整後歸檔)</span>
|
||
</span>
|
||
`;
|
||
|
||
const actionBtns = document.getElementById(`actions-${elementId}`);
|
||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||
if (actionBtns) actionBtns.style.display = 'none';
|
||
if (editBtn) editBtn.style.display = 'none';
|
||
}
|
||
|
||
// ============================================================================
|
||
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
|
||
// ============================================================================
|
||
export async function previewNodeCLI(btnElement, rootPath) {
|
||
let container = btnElement.closest('details');
|
||
if (container) {
|
||
const elementId = container.id.replace('details-', '');
|
||
if (container.dataset.loaded !== 'true') {
|
||
await window.lazyLoadFolder(elementId, false);
|
||
}
|
||
}
|
||
const targetNode = container || btnElement.closest('.tree-leaf-node');
|
||
if (!targetNode) return;
|
||
|
||
const leafNodes = targetNode.querySelectorAll('.leaf-container');
|
||
const interfaceGroups = {};
|
||
|
||
leafNodes.forEach(node => {
|
||
const path = node.getAttribute('data-path') || "";
|
||
const val = node.getAttribute('data-original');
|
||
if (!path) return;
|
||
|
||
const parts = path.split('::');
|
||
const paramName = parts.pop();
|
||
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
|
||
|
||
if (!interfaceGroups[interfacePath]) {
|
||
interfaceGroups[interfacePath] = [];
|
||
}
|
||
interfaceGroups[interfacePath].push({ param: paramName, val: val });
|
||
});
|
||
|
||
if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
|
||
const singleNode = targetNode.querySelector('.leaf-container');
|
||
if (singleNode) {
|
||
const path = singleNode.getAttribute('data-path');
|
||
const val = singleNode.getAttribute('data-original');
|
||
const parts = path.split('::');
|
||
const paramName = parts.pop();
|
||
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
|
||
interfaceGroups[interfacePath] = [{ param: paramName, val: val }];
|
||
}
|
||
}
|
||
|
||
if (Object.keys(interfaceGroups).length === 0) {
|
||
return alert("此節點下沒有任何數值可供預覽!");
|
||
}
|
||
|
||
let cliLines = [];
|
||
cliLines.push("! --- Read-Only Configuration Preview ---");
|
||
for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
|
||
cliLines.push(`! Node: ${interfacePath || 'Global'}`);
|
||
params.forEach(({param, val}) => {
|
||
if (/^\[\d+\]$/.test(param)) {
|
||
if (val) {
|
||
cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
|
||
}
|
||
} else if (param === 'no') {
|
||
cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
|
||
} else {
|
||
let line = interfacePath ? `${interfacePath} ${param}` : param;
|
||
if (val) line += ` ${val}`;
|
||
cliLines.push(line);
|
||
}
|
||
});
|
||
cliLines.push("!");
|
||
}
|
||
const finalScript = cliLines.join('\n');
|
||
|
||
showReadOnlyCliPreviewModal(finalScript, rootPath);
|
||
}
|
||
|
||
function showReadOnlyCliPreviewModal(cliScript, rootPath) {
|
||
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
||
const resizer = document.getElementById('drag-resizer');
|
||
const rightPane = document.getElementById('side-cli-preview');
|
||
|
||
document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
|
||
document.getElementById('side-pane-title').style.color = '#3498db';
|
||
|
||
document.getElementById('btn-side-confirm').style.display = 'none';
|
||
document.getElementById('btn-side-cancel').style.display = 'none';
|
||
const closeBtn = document.getElementById('btn-side-close');
|
||
closeBtn.style.display = 'inline-block';
|
||
closeBtn.textContent = '關閉預覽';
|
||
|
||
const textarea = document.getElementById('side-cli-textarea');
|
||
textarea.style.display = 'block';
|
||
textarea.value = cliScript;
|
||
textarea.readOnly = true;
|
||
textarea.style.backgroundColor = '#2c3e50';
|
||
|
||
document.getElementById('side-execution-result').style.display = 'none';
|
||
|
||
// 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
|
||
leftPanes.forEach(pane => {
|
||
pane.style.flex = 'none';
|
||
pane.style.maxWidth = 'none';
|
||
pane.style.width = 'calc(50% - 11px)';
|
||
});
|
||
if (rightPane) {
|
||
rightPane.style.flex = 'none';
|
||
rightPane.style.maxWidth = 'none';
|
||
rightPane.style.width = 'calc(50% - 11px)';
|
||
rightPane.style.display = 'block';
|
||
}
|
||
|
||
if (resizer) resizer.style.display = 'flex';
|
||
|
||
if (!window.isResizerInitialized && typeof initResizer === 'function') {
|
||
initResizer();
|
||
window.isResizerInitialized = true;
|
||
}
|
||
}
|
||
|
||
// --- EOF (檔案結束) ---
|
||
|
||
|
||
================================================================================
|
||
FILE: static/mac-domain.js
|
||
================================================================================
|
||
// --- static/mac-domain.js ---
|
||
|
||
import { apiGetMacDomainList, apiGetMacDomainConfig, apiExecuteConfig } from './api.js';
|
||
import { getGlobalConnectionInfo } from './utils.js';
|
||
|
||
// 🌟 將原本在 app.js 的全域變數移到這裡封裝
|
||
let currentMacDomainData = null;
|
||
|
||
export function hasMacDomainData() {
|
||
return currentMacDomainData !== null;
|
||
}
|
||
|
||
export function resetMacDomainData() {
|
||
currentMacDomainData = null;
|
||
}
|
||
|
||
export async function loadMacDomainList() {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
const mdInput = document.getElementById('cfgMacDomain');
|
||
const mdDatalist = document.getElementById('mac-domain-list');
|
||
const statusSpan = document.getElementById('fetchStatus');
|
||
|
||
// 載入前:鎖定輸入框並更改提示
|
||
mdInput.disabled = true;
|
||
mdInput.value = '';
|
||
mdInput.placeholder = '⏳ 查詢設備中...';
|
||
if (mdDatalist) mdDatalist.innerHTML = '';
|
||
|
||
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
|
||
statusSpan.style.color = "#f39c12";
|
||
|
||
try {
|
||
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
|
||
if (result.status === 'success' && result.data.length > 0) {
|
||
// 載入成功:將資料塞入 datalist
|
||
if (mdDatalist) {
|
||
result.data.forEach(md => {
|
||
const option = document.createElement('option');
|
||
option.value = md;
|
||
mdDatalist.appendChild(option);
|
||
});
|
||
}
|
||
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
|
||
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
|
||
statusSpan.style.color = "#27ae60";
|
||
} else {
|
||
mdInput.placeholder = '❌ 找不到資料';
|
||
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
|
||
statusSpan.style.color = "#e74c3c";
|
||
}
|
||
} catch (error) {
|
||
mdInput.placeholder = '❌ 連線錯誤';
|
||
statusSpan.textContent = "連線失敗:" + error;
|
||
statusSpan.style.color = "#e74c3c";
|
||
} finally {
|
||
// 載入結束:解鎖輸入框
|
||
mdInput.disabled = false;
|
||
}
|
||
}
|
||
|
||
export async function fetchMacDomainConfig() {
|
||
const target = document.getElementById('cfgMacDomain').value.trim();
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo || !target) return alert("請確認設備連線資訊與 MAC Domain!");
|
||
|
||
document.getElementById('fetchStatus').textContent = "⏳ 正在讀取並解析設定中...";
|
||
document.getElementById('fetchStatus').style.color = "#f39c12";
|
||
|
||
try {
|
||
const result = await apiGetMacDomainConfig(target, connInfo.host, connInfo.user, connInfo.pass);
|
||
if (result.status === 'success') {
|
||
currentMacDomainData = result.data;
|
||
populateFormFromData();
|
||
document.getElementById('macDomainConfigArea').style.display = 'block';
|
||
document.getElementById('fetchStatus').textContent = "✅ 讀取成功!";
|
||
document.getElementById('fetchStatus').style.color = "#27ae60";
|
||
} else {
|
||
alert("讀取失敗:" + result.detail);
|
||
document.getElementById('fetchStatus').textContent = "❌ 讀取失敗";
|
||
}
|
||
} catch (error) {
|
||
alert("連線錯誤:" + error);
|
||
}
|
||
}
|
||
|
||
export function populateFormFromData() {
|
||
const d = currentMacDomainData;
|
||
if (!d) return;
|
||
|
||
document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack';
|
||
document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled';
|
||
document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled';
|
||
document.getElementById('g_bat30').value = d.common_settings['cm-battery-mode-30-support'] || 'disabled';
|
||
document.getElementById('g_docsis40').value = d.common_settings['docsis40'] || 'disabled';
|
||
document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled';
|
||
document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled';
|
||
|
||
document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down';
|
||
document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || '';
|
||
document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || '';
|
||
document.getElementById('b_us_phy').value = d.basic_channel_sets['us-phy-channel-set'] || '';
|
||
document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || '';
|
||
document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || '';
|
||
|
||
const dsSelect = document.getElementById('select_ds_group');
|
||
dsSelect.innerHTML = '<option value="_new_">[➕ 新增 DS Group]</option>';
|
||
Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
|
||
if (Object.keys(d.static_ds_bonding_groups).length > 0) dsSelect.value = Object.keys(d.static_ds_bonding_groups)[0];
|
||
|
||
const usSelect = document.getElementById('select_us_group');
|
||
usSelect.innerHTML = '<option value="_new_">[➕ 新增 US Group]</option>';
|
||
Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
|
||
if (Object.keys(d.static_us_bonding_groups).length > 0) usSelect.value = Object.keys(d.static_us_bonding_groups)[0];
|
||
|
||
toggleGroupVisibility();
|
||
loadDsGroupData();
|
||
loadUsGroupData();
|
||
}
|
||
|
||
export function revertFormChanges() {
|
||
if (!currentMacDomainData) return;
|
||
if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) return;
|
||
|
||
populateFormFromData();
|
||
|
||
const previewEl = document.getElementById('cliPreviewContainer');
|
||
if (previewEl) {
|
||
previewEl.textContent = '';
|
||
previewEl.style.display = 'none';
|
||
}
|
||
|
||
const deployBtn = document.getElementById('btnDeployConfig');
|
||
if (deployBtn) {
|
||
deployBtn.style.display = 'none';
|
||
deployBtn.disabled = true;
|
||
}
|
||
|
||
const fetchStatus = document.getElementById('fetchStatus');
|
||
fetchStatus.textContent = "🔄 已還原為原始設定!";
|
||
fetchStatus.style.color = "#2980b9";
|
||
|
||
setTimeout(() => {
|
||
fetchStatus.textContent = "✅ 讀取成功!";
|
||
fetchStatus.style.color = "#27ae60";
|
||
}, 3000);
|
||
}
|
||
|
||
export function toggleGroupVisibility() {
|
||
const dsDyn = document.getElementById('g_ds_dyn').value;
|
||
const usDyn = document.getElementById('g_us_dyn').value;
|
||
document.getElementById('section_group_ds').style.display = (dsDyn === 'disabled') ? 'block' : 'none';
|
||
document.getElementById('section_group_us').style.display = (usDyn === 'disabled') ? 'block' : 'none';
|
||
}
|
||
|
||
export function loadDsGroupData() {
|
||
const grp = document.getElementById('select_ds_group').value;
|
||
const newGrpInput = document.getElementById('input_new_ds_group');
|
||
if (grp === '_new_') {
|
||
newGrpInput.style.display = 'inline-block';
|
||
document.getElementById('ds_g_admin').value = 'down';
|
||
document.getElementById('ds_g_down').value = '';
|
||
document.getElementById('ds_g_ofdm').value = '';
|
||
document.getElementById('ds_g_fdx').value = '';
|
||
} else {
|
||
newGrpInput.style.display = 'none';
|
||
const data = currentMacDomainData.static_ds_bonding_groups[grp];
|
||
document.getElementById('ds_g_admin').value = data['admin-state'] || 'down';
|
||
document.getElementById('ds_g_down').value = data['down-channel-set'] || '';
|
||
document.getElementById('ds_g_ofdm').value = data['ofdm-channel-set'] || '';
|
||
document.getElementById('ds_g_fdx').value = data['fdx-ofdm-channel-set'] || '';
|
||
}
|
||
}
|
||
|
||
export function loadUsGroupData() {
|
||
const grp = document.getElementById('select_us_group').value;
|
||
const newGrpInput = document.getElementById('input_new_us_group');
|
||
if (grp === '_new_') {
|
||
newGrpInput.style.display = 'inline-block';
|
||
document.getElementById('us_g_admin').value = 'down';
|
||
document.getElementById('us_g_us').value = '';
|
||
document.getElementById('us_g_ofdma').value = '';
|
||
document.getElementById('us_g_fdx').value = '';
|
||
} else {
|
||
newGrpInput.style.display = 'none';
|
||
const data = currentMacDomainData.static_us_bonding_groups[grp];
|
||
document.getElementById('us_g_admin').value = data['admin-state'] || 'down';
|
||
document.getElementById('us_g_us').value = data['us-channel-set'] || '';
|
||
document.getElementById('us_g_ofdma').value = data['ofdma-channel-set'] || '';
|
||
document.getElementById('us_g_fdx').value = data['fdx-ofdma-channel-set'] || '';
|
||
}
|
||
}
|
||
|
||
export function generateMacDomainCLI() {
|
||
const md = document.getElementById('cfgMacDomain').value.trim();
|
||
let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`;
|
||
|
||
cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\n`;
|
||
cli += `cable mac-domain ${md} admin-state down\ncommit\n`;
|
||
|
||
const baseCmds = [
|
||
`ip-provisioning-mode ${document.getElementById('g_ip_prov').value}`,
|
||
`diplexer-band-edge control ${document.getElementById('g_diplexer').value}`,
|
||
`cm-battery-mode-31-support ${document.getElementById('g_bat31').value}`,
|
||
`cm-battery-mode-30-support ${document.getElementById('g_bat30').value}`,
|
||
`docsis40 ${document.getElementById('g_docsis40').value}`,
|
||
`ds-dynamic-bonding-group ${document.getElementById('g_ds_dyn').value}`,
|
||
`us-dynamic-bonding-group ${document.getElementById('g_us_dyn').value}`,
|
||
`ds-primary-set ${document.getElementById('b_ds_pri').value}`,
|
||
`ds-non-primary-set ${document.getElementById('b_ds_non_pri').value}`,
|
||
`us-phy-channel-set ${document.getElementById('b_us_phy').value}`,
|
||
`ds-ofdm-set ${document.getElementById('b_ds_ofdm').value}`,
|
||
`us-ofdma-set ${document.getElementById('b_us_ofdma').value}`
|
||
];
|
||
baseCmds.forEach(cmd => {
|
||
if (!cmd.endsWith(" ")) cli += `cable mac-domain ${md} ${cmd}\n`;
|
||
});
|
||
cli += `cable mac-domain ${md} admin-state ${document.getElementById('b_admin').value}\ncommit\n`;
|
||
|
||
if (document.getElementById('g_ds_dyn').value === 'disabled') {
|
||
const isNewDs = document.getElementById('select_ds_group').value === '_new_';
|
||
let dsName = isNewDs ? document.getElementById('input_new_ds_group').value.trim() : document.getElementById('select_ds_group').value;
|
||
|
||
if (dsName) {
|
||
cli += `\n! 2. [區塊] DS Bonding Group [${dsName}] 設定\n`;
|
||
if (!isNewDs) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state down\ncommit\n`;
|
||
|
||
if (document.getElementById('ds_g_down').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} down-channel-set ${document.getElementById('ds_g_down').value}\n`;
|
||
if (document.getElementById('ds_g_ofdm').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} ofdm-channel-set ${document.getElementById('ds_g_ofdm').value}\n`;
|
||
if (document.getElementById('ds_g_fdx').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} fdx-ofdm-channel-set ${document.getElementById('ds_g_fdx').value}\n`;
|
||
|
||
cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state ${document.getElementById('ds_g_admin').value}\ncommit\n`;
|
||
}
|
||
}
|
||
|
||
if (document.getElementById('g_us_dyn').value === 'disabled') {
|
||
const isNewUs = document.getElementById('select_us_group').value === '_new_';
|
||
let usName = isNewUs ? document.getElementById('input_new_us_group').value.trim() : document.getElementById('select_us_group').value;
|
||
|
||
if (usName) {
|
||
cli += `\n! 3. [區塊] US Bonding Group [${usName}] 設定\n`;
|
||
if (!isNewUs) cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state down\ncommit\n`;
|
||
|
||
if (document.getElementById('us_g_us').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} us-channel-set ${document.getElementById('us_g_us').value}\n`;
|
||
if (document.getElementById('us_g_ofdma').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} ofdma-channel-set ${document.getElementById('us_g_ofdma').value}\n`;
|
||
if (document.getElementById('us_g_fdx').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} fdx-ofdma-channel-set ${document.getElementById('us_g_fdx').value}\n`;
|
||
|
||
cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state ${document.getElementById('us_g_admin').value}\ncommit\n`;
|
||
}
|
||
}
|
||
|
||
const previewEl = document.getElementById('cliPreviewContainer');
|
||
previewEl.textContent = cli;
|
||
previewEl.style.display = 'block';
|
||
|
||
const deployBtn = document.getElementById('btnDeployConfig');
|
||
deployBtn.style.display = 'inline-block';
|
||
deployBtn.disabled = false;
|
||
}
|
||
|
||
export async function executeBondingConfig() {
|
||
if (!confirm("⚠️ 警告:此操作將導致 MAC Domain 重新啟動,影響用戶服務。確定要執行嗎?")) return;
|
||
|
||
const cliScript = document.getElementById('cliPreviewContainer').textContent;
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
// 開啟 Modal 顯示執行進度
|
||
document.getElementById('outputModal').classList.add('active');
|
||
document.getElementById('modalTargetInfo').textContent = connInfo.host ? `(${connInfo.host})` : '';
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
const outputEl = document.getElementById('modalOutput');
|
||
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`;
|
||
|
||
// 鎖定關閉按鈕 (Execution Lock)
|
||
const closeBtn = document.getElementById('btn-modal-close');
|
||
if (closeBtn) {
|
||
closeBtn.disabled = true;
|
||
closeBtn.style.opacity = '0.5';
|
||
closeBtn.style.cursor = 'not-allowed';
|
||
closeBtn.style.pointerEvents = 'none';
|
||
}
|
||
|
||
try {
|
||
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
|
||
if (result.status === 'success') {
|
||
outputEl.style.color = "#e0e0e0";
|
||
outputEl.textContent = `✅ 設定執行完成!\n==================================================\n${result.data}`;
|
||
} else {
|
||
outputEl.style.color = "#e74c3c";
|
||
outputEl.textContent = `❌ 設定執行失敗:\n${result.message}`;
|
||
}
|
||
} catch (error) {
|
||
outputEl.style.color = "#e74c3c";
|
||
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
|
||
} finally {
|
||
// 恢復關閉按鈕
|
||
if (closeBtn) {
|
||
closeBtn.disabled = false;
|
||
closeBtn.style.opacity = '1';
|
||
closeBtn.style.cursor = 'pointer';
|
||
closeBtn.style.pointerEvents = 'auto';
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
================================================================================
|
||
FILE: static/utils.js
|
||
================================================================================
|
||
// --- static/utils.js ---
|
||
|
||
// 加上 export,讓其他檔案可以使用這個函數
|
||
export function getGlobalConnectionInfo() {
|
||
const host = document.getElementById('cmtsHost').value.trim();
|
||
const user = document.getElementById('cmtsUser').value.trim();
|
||
const pass = document.getElementById('cmtsPass').value.trim();
|
||
|
||
if (!host || !user || !pass) {
|
||
alert("請在畫面上方完整輸入目標設備的 IP、帳號與密碼!");
|
||
return null;
|
||
}
|
||
return { host, user, pass };
|
||
}
|
||
|
||
|
||
================================================================================
|
||
FILE: static/tree-ui.js
|
||
================================================================================
|
||
// --- static/tree-ui.js ---
|
||
|
||
import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
|
||
|
||
// ==========================================
|
||
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
|
||
// ==========================================
|
||
export const folderDataCache = {};
|
||
export const filterFolderCache = {};
|
||
|
||
// 🧹 [修復 Leak] 徹底清空樹狀圖快取,釋放記憶體
|
||
export function clearTreeCache() {
|
||
for (let key in folderDataCache) delete folderDataCache[key];
|
||
for (let key in filterFolderCache) delete filterFolderCache[key];
|
||
}
|
||
|
||
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
|
||
window.lazyLoadFolder = function(elementId, isFilter = false) {
|
||
const detailsEl = document.getElementById(`details-${elementId}`);
|
||
// 如果已經載入過,就不重複渲染
|
||
if (!detailsEl || detailsEl.dataset.loaded === 'true') return;
|
||
|
||
// 從 dataset 讀取當初存下來的屬性
|
||
const currentPath = detailsEl.dataset.path;
|
||
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
|
||
const mode = detailsEl.dataset.mode;
|
||
|
||
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
|
||
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
|
||
|
||
if (contentDiv && cache) {
|
||
if (isFilter) {
|
||
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
|
||
} else {
|
||
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
|
||
}
|
||
detailsEl.dataset.loaded = 'true'; // 標記為已載入
|
||
}
|
||
};
|
||
|
||
// ==========================================
|
||
// 🌟 輔助函數:全部展開與全部收合 (支援延遲渲染)
|
||
// ==========================================
|
||
export function expandAll(elementId) {
|
||
const details = document.getElementById(`details-${elementId}`);
|
||
if (details) {
|
||
// 如果還沒載入,先強制載入它
|
||
if (!details.dataset.loaded) {
|
||
const isFilter = details.id.includes('filter-');
|
||
window.lazyLoadFolder(elementId, isFilter);
|
||
}
|
||
details.open = true;
|
||
|
||
// 找出剛剛渲染出來的第一層子資料夾,遞迴展開
|
||
const contentDiv = details.querySelector(':scope > div');
|
||
if (contentDiv) {
|
||
const childDetails = contentDiv.querySelectorAll(':scope > details');
|
||
childDetails.forEach(d => {
|
||
const subId = d.id.replace('details-', '');
|
||
expandAll(subId); // 遞迴展開
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
export function collapseAll(elementId) {
|
||
const details = document.getElementById(`details-${elementId}`);
|
||
if (details) {
|
||
const subDetails = details.querySelectorAll('details');
|
||
subDetails.forEach(d => d.open = false);
|
||
details.open = false;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
|
||
// ==========================================
|
||
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
|
||
const htmlParts = [];
|
||
if (!node || typeof node !== 'object') return htmlParts.join('');
|
||
|
||
for (const [key, value] of Object.entries(node)) {
|
||
const currentPath = path ? `${path}::${key}` : key;
|
||
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
|
||
if (typeof value === 'object' && value !== null) {
|
||
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
||
const elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
|
||
|
||
// 🌟 將資料存入快取,供延遲渲染使用
|
||
folderDataCache[elementId] = value;
|
||
|
||
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||
|
||
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
||
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
||
|
||
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
||
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
||
|
||
const expandCollapseBtns = `
|
||
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
|
||
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
|
||
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
|
||
onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
|
||
${expandAllIcon}
|
||
</span>
|
||
<span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')"
|
||
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
|
||
onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
|
||
${collapseAllIcon}
|
||
</span>
|
||
</span>
|
||
`;
|
||
|
||
// 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
|
||
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
||
let isLocked = false;
|
||
let lockTitle = "鎖定並編輯此群組";
|
||
let lockText = "✏️";
|
||
let lockOpacity = "0.3";
|
||
let lockPointer = "auto";
|
||
|
||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||
const hostLocks = window.globalActiveLocks[currentHost];
|
||
|
||
// 1. 檢查自己是否被鎖定
|
||
if (hostLocks[currentPath]) {
|
||
const lockInfo = hostLocks[currentPath];
|
||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||
isLocked = true;
|
||
lockText = "🔒";
|
||
lockOpacity = "0.2";
|
||
lockPointer = "none";
|
||
lockTitle = lockInfo.user_id !== SESSION_USER_ID
|
||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
||
: `🔒 您正在另一個視圖編輯此項目`;
|
||
}
|
||
} else {
|
||
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
|
||
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
||
if (currentPath.startsWith(lockedPath + "::")) {
|
||
isLocked = true;
|
||
lockText = "🔒";
|
||
lockOpacity = "0.2";
|
||
lockPointer = "none";
|
||
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
htmlParts.push(`
|
||
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
||
<summary class="tree-folder-header"
|
||
title="${cliCommand}"
|
||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||
onmouseout="this.style.backgroundColor='transparent'"
|
||
style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
|
||
|
||
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
||
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
|
||
<span class="icon-open" style="display: none;">${iconOpen}</span>
|
||
</span>
|
||
<b style="color: #2c3e50;">${key}</b>${folderSuffix}
|
||
${expandCollapseBtns}
|
||
|
||
<div style="margin-left: auto; display: flex; align-items: center;">
|
||
<span class="debug-preview-btn admin-only"
|
||
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
|
||
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
|
||
title="[開發者] 預覽此節點下所有指令"
|
||
onmouseover="this.style.transform='scale(1.2)'"
|
||
onmouseout="this.style.transform='scale(1)'">
|
||
🔍
|
||
</span>
|
||
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
|
||
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
|
||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
|
||
${lockText}
|
||
</span>
|
||
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
||
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
||
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
|
||
</span>
|
||
</div>
|
||
</summary>
|
||
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
|
||
<!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 -->
|
||
</div>
|
||
</details>
|
||
`);
|
||
} else {
|
||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||
const isNoCommand = (key === 'no');
|
||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||
const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||
|
||
if (isNoCommand) {
|
||
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
|
||
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
||
}
|
||
|
||
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||
|
||
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
|
||
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
|
||
|
||
// 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
|
||
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
||
let isLocked = false;
|
||
let lockTitle = "鎖定並編輯此項目";
|
||
let lockText = "✏️";
|
||
let lockOpacity = "0.3";
|
||
let lockPointer = "auto";
|
||
|
||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||
const hostLocks = window.globalActiveLocks[currentHost];
|
||
|
||
// 1. 檢查自己是否被鎖定
|
||
if (hostLocks[currentPath]) {
|
||
const lockInfo = hostLocks[currentPath];
|
||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||
isLocked = true;
|
||
lockText = "🔒";
|
||
lockOpacity = "0.2";
|
||
lockPointer = "none";
|
||
lockTitle = lockInfo.user_id !== SESSION_USER_ID
|
||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
||
: `🔒 您正在另一個視圖編輯此項目`;
|
||
}
|
||
} else {
|
||
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
|
||
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
||
if (currentPath.startsWith(lockedPath + "::")) {
|
||
isLocked = true;
|
||
lockText = "🔒";
|
||
lockOpacity = "0.2";
|
||
lockPointer = "none";
|
||
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let displayContent = '';
|
||
|
||
if (isVirtualIndex) {
|
||
displayContent = `
|
||
${iconHtml(svgLeaf)}
|
||
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
|
||
<span class="leaf-value" style="${valueStyle}">${safeValue}</span>
|
||
</span>
|
||
`;
|
||
} else if (isNoCommand) {
|
||
displayContent = `
|
||
<span id="display-wrapper-${elementId}" style="display: inline-flex; align-items: center;">
|
||
${iconHtml(svgDisabled)}
|
||
<b style="color: #e74c3c;">no:</b>
|
||
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
|
||
<span class="leaf-value" style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>
|
||
</span>
|
||
</span>
|
||
`;
|
||
} else {
|
||
displayContent = `
|
||
${iconHtml(svgLeaf)} <b>${key}</b>:
|
||
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
|
||
<span class="leaf-value" style="${valueStyle}">${safeValue}</span>
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
htmlParts.push(`
|
||
<div class="tree-leaf-node"
|
||
title="${cliCommand}"
|
||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||
onmouseout="this.style.backgroundColor='transparent'"
|
||
style="padding: 4px 8px; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; display: flex; align-items: center; border-radius: 4px; transition: background-color 0.2s;">
|
||
${displayContent}
|
||
|
||
<div style="margin-left: auto; display: flex; align-items: center;">
|
||
<span class="debug-preview-btn admin-only"
|
||
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
|
||
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
|
||
title="[開發者] 預覽此節點下所有指令"
|
||
onmouseover="this.style.transform='scale(1.2)'"
|
||
onmouseout="this.style.transform='scale(1)'">
|
||
🔍
|
||
</span>
|
||
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
|
||
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
|
||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
|
||
${lockText}
|
||
</span>
|
||
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
||
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
||
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditLeaf('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
return htmlParts.join('');
|
||
}
|
||
|
||
// ==========================================
|
||
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
|
||
// ==========================================
|
||
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
|
||
if (typeof data !== 'object' || data === null) return '';
|
||
|
||
const htmlParts = [];
|
||
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
|
||
|
||
for (const key in data) {
|
||
const value = data[key];
|
||
const currentPath = parentPath ? `${parentPath}::${key}` : key;
|
||
const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
|
||
|
||
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||
|
||
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||
|
||
const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
|
||
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
|
||
const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;
|
||
|
||
if (isFolder) {
|
||
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
||
const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||
|
||
// 🌟 存入過濾器專用快取
|
||
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
|
||
|
||
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
||
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
||
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
|
||
|
||
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
||
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
||
|
||
const expandCollapseBtns = `
|
||
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
|
||
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
|
||
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
|
||
onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
|
||
${expandAllIcon}
|
||
</span>
|
||
<span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')"
|
||
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
|
||
onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
|
||
${collapseAllIcon}
|
||
</span>
|
||
</span>
|
||
`;
|
||
|
||
// 🌟 加入 lazyLoadFolder 觸發
|
||
htmlParts.push(`
|
||
<details id="details-${elementId}" class="tree-folder"
|
||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
||
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
|
||
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
|
||
${checkboxHtml}
|
||
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
||
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
|
||
<span class="icon-open" style="display: none;">${iconOpen}</span>
|
||
</span>
|
||
<span>${key}${folderSuffix}</span>
|
||
${expandCollapseBtns}
|
||
</summary>
|
||
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
|
||
<!-- 🌟 延遲渲染 -->
|
||
</div>
|
||
</details>
|
||
`);
|
||
} else {
|
||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||
|
||
const isNoCommand = (key === 'no');
|
||
|
||
let valueHtml = `<span style="color: #16a085; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
|
||
let colonHtml = `:`;
|
||
let currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||
|
||
if (isNoCommand) {
|
||
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
|
||
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
||
|
||
displayName = `<b style="color: #e74c3c;">no</b>`;
|
||
colonHtml = `:`;
|
||
valueHtml = `<span style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
|
||
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||
}
|
||
|
||
htmlParts.push(`
|
||
<div class="tree-leaf-node"
|
||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||
onmouseout="this.style.backgroundColor='transparent'"
|
||
style="padding: 2px 0; color: #34495e; display: flex; align-items: center; margin-top: 2px; border-radius: 4px; transition: background-color 0.2s;"
|
||
title="${cliCommand}">
|
||
${checkboxHtml}
|
||
<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0;">
|
||
${currentIcon}
|
||
</span>
|
||
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
||
${valueHtml}
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
htmlParts.push('</div>');
|
||
return htmlParts.join('');
|
||
}
|
||
|
||
================================================================================
|
||
FILE: routers/backup.py
|
||
================================================================================
|
||
# --- routers/backup.py ---
|
||
import asyncssh
|
||
import asyncio
|
||
import re
|
||
import json
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel
|
||
from typing import Optional, List
|
||
from logger import get_logger
|
||
|
||
# 引入 DB 函數
|
||
from database import (
|
||
insert_config_backup,
|
||
get_config_backup_list,
|
||
get_config_backup_detail,
|
||
delete_config_backup
|
||
)
|
||
|
||
from cmts_scraper import fetch_raw_config
|
||
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
||
from shared import parse_cli_to_tree, cmts_config_locks
|
||
|
||
logger = get_logger("app.backup")
|
||
|
||
router = APIRouter(
|
||
prefix="/backups",
|
||
tags=["Backups"]
|
||
)
|
||
|
||
# ==========================================
|
||
# 📦 Pydantic Models
|
||
# ==========================================
|
||
class SnapshotRequest(BaseModel):
|
||
host: str
|
||
username: str
|
||
password: str
|
||
snapshot_name: str
|
||
description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述
|
||
config_type: str = "running"
|
||
|
||
class RestoreRequest(BaseModel):
|
||
host: str
|
||
username: str
|
||
password: str
|
||
|
||
class ExecuteRestoreRequest(BaseModel):
|
||
host: str
|
||
username: str
|
||
password: str
|
||
commands: List[str] # 接收前端確認後的差異指令清單
|
||
|
||
# ==========================================
|
||
# 🛠️ 核心演算法:深度差異比對 (Deep Diff)
|
||
# ==========================================
|
||
# 🌟 新增小工具:拔除 [0], [1] 虛擬後綴,還原真實 CLI
|
||
def clean_virtual_key(key: str) -> str:
|
||
return re.sub(r'\s*\[\d+\]$', '', key)
|
||
|
||
def build_add_commands(node: dict, current_path: str) -> list:
|
||
"""遞迴展開所有需要新增的絕對路徑指令"""
|
||
commands = []
|
||
if not node:
|
||
commands.append(current_path)
|
||
return commands
|
||
|
||
for key, val in node.items():
|
||
if key.startswith('_'):
|
||
continue
|
||
|
||
# 🌟 使用乾淨的 key 來組合路徑
|
||
real_key = clean_virtual_key(key)
|
||
path = f"{current_path} {real_key}".strip()
|
||
|
||
if isinstance(val, dict):
|
||
commands.extend(build_add_commands(val, path))
|
||
else:
|
||
val_str = f" {val}" if val else ""
|
||
commands.append(f"{path}{val_str}")
|
||
return commands
|
||
|
||
def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list:
|
||
"""比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
|
||
commands = []
|
||
|
||
for key, curr_val in current_node.items():
|
||
if key.startswith('_'):
|
||
continue
|
||
|
||
# 🌟 使用乾淨的 key
|
||
real_key = clean_virtual_key(key)
|
||
path = f"{current_path} {real_key}".strip()
|
||
|
||
if key not in snapshot_node:
|
||
commands.append(f"no {path}")
|
||
else:
|
||
snap_val = snapshot_node[key]
|
||
if isinstance(curr_val, dict) and isinstance(snap_val, dict):
|
||
commands.extend(generate_diff_commands(curr_val, snap_val, path))
|
||
elif isinstance(curr_val, dict) or isinstance(snap_val, dict):
|
||
commands.append(f"no {path}")
|
||
if isinstance(snap_val, dict):
|
||
commands.extend(build_add_commands(snap_val, path))
|
||
else:
|
||
val_str = f" {snap_val}" if snap_val else ""
|
||
commands.append(f"{path}{val_str}")
|
||
elif curr_val != snap_val:
|
||
val_str = f" {snap_val}" if snap_val else ""
|
||
commands.append(f"{path}{val_str}")
|
||
|
||
for key, snap_val in snapshot_node.items():
|
||
if key.startswith('_'):
|
||
continue
|
||
|
||
# 🌟 使用乾淨的 key
|
||
real_key = clean_virtual_key(key)
|
||
path = f"{current_path} {real_key}".strip()
|
||
|
||
if key not in current_node:
|
||
if isinstance(snap_val, dict):
|
||
commands.extend(build_add_commands(snap_val, path))
|
||
else:
|
||
val_str = f" {snap_val}" if snap_val else ""
|
||
commands.append(f"{path}{val_str}")
|
||
|
||
return commands
|
||
|
||
# ==========================================
|
||
# 🚀 API 路由
|
||
# ==========================================
|
||
@router.get("/", summary="取得歷史快照列表")
|
||
async def list_backups(host: str, config_type: str = "running"):
|
||
records = await get_config_backup_list(host, config_type)
|
||
if records is None:
|
||
return {"status": "error", "message": "資料庫查詢失敗"}
|
||
return {"status": "success", "data": records}
|
||
|
||
@router.get("/{backup_id}", summary="取得特定快照詳細內容")
|
||
async def get_backup_detail(backup_id: str):
|
||
record = await get_config_backup_detail(backup_id)
|
||
if not record:
|
||
return {"status": "error", "message": "找不到該筆備份資料"}
|
||
return {"status": "success", "data": record}
|
||
|
||
@router.delete("/{backup_id}", summary="刪除特定快照")
|
||
async def delete_backup(backup_id: str):
|
||
success = await delete_config_backup(backup_id)
|
||
if not success:
|
||
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
||
return {"status": "success", "message": "快照已成功刪除"}
|
||
|
||
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
|
||
async def create_snapshot(req: SnapshotRequest):
|
||
async def backup_streamer():
|
||
try:
|
||
# 1. 廣播:正在連線
|
||
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
||
await asyncio.sleep(0.1)
|
||
|
||
# 2. 廣播:正在下載
|
||
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
||
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
||
|
||
# 3. 廣播:正在解析
|
||
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
||
|
||
# 4. 廣播:寫入資料庫
|
||
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
||
backup_id = await insert_config_backup(
|
||
host=req.host,
|
||
config_type=req.config_type,
|
||
raw_cli=raw_cli,
|
||
parsed_tree=parsed_tree,
|
||
snapshot_name=req.snapshot_name,
|
||
description=req.description,
|
||
is_auto=False
|
||
)
|
||
|
||
if not backup_id:
|
||
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
||
return
|
||
|
||
# 5. 廣播:成功
|
||
yield json.dumps({
|
||
"status": "success",
|
||
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
||
"backup_id": backup_id,
|
||
"host": req.host
|
||
}) + "\n"
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ 建立快照失敗: {e}")
|
||
yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n"
|
||
|
||
return StreamingResponse(backup_streamer(), media_type="application/x-ndjson")
|
||
|
||
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
|
||
async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
|
||
try:
|
||
backup_record = await get_config_backup_detail(backup_id)
|
||
if not backup_record:
|
||
return {"status": "error", "message": "找不到指定的備份紀錄"}
|
||
|
||
snapshot_tree = backup_record.get("parsed_tree")
|
||
if not snapshot_tree:
|
||
return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
|
||
|
||
# 🌟 關鍵修正:動態取得該快照的 config_type,避免蘋果比橘子
|
||
snapshot_config_type = backup_record.get("config_type", "running")
|
||
|
||
# 🌟 關鍵修正:將硬編碼的 "running" 改為 snapshot_config_type
|
||
raw_current = await fetch_raw_config(req.host, req.username, req.password, snapshot_config_type)
|
||
|
||
if not raw_current:
|
||
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
|
||
|
||
current_tree = parse_cli_to_tree(raw_current)
|
||
diff_commands = generate_diff_commands(current_tree, snapshot_tree)
|
||
|
||
return {
|
||
"status": "success",
|
||
"data": {
|
||
"commands": diff_commands
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"差異分析失敗: {str(e)}")
|
||
return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"}
|
||
|
||
|
||
|
||
@router.post("/{backup_id}/restore", summary="執行差異還原 (串流即時回報)")
|
||
async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||
commands = req.commands
|
||
|
||
if not commands:
|
||
async def empty_success():
|
||
yield json.dumps({"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}) + "\n"
|
||
return StreamingResponse(empty_success(), media_type="application/x-ndjson")
|
||
|
||
async def restore_generator():
|
||
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
||
host_lock = cmts_config_locks[req.host]
|
||
|
||
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
|
||
if host_lock.locked():
|
||
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
|
||
return
|
||
|
||
async with host_lock:
|
||
try:
|
||
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
|
||
|
||
# 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
|
||
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
|
||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||
|
||
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||
output = ""
|
||
while True:
|
||
try:
|
||
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||
if not chunk: break
|
||
output += chunk
|
||
if "--More--" in chunk or "More" in chunk:
|
||
process.stdin.write(" ")
|
||
await process.stdin.drain()
|
||
# 🌟 精準 Prompt 偵測
|
||
if prompt_pattern and re.search(prompt_pattern, output):
|
||
break
|
||
except asyncio.TimeoutError:
|
||
break
|
||
return output
|
||
|
||
# 2. 進入設定模式
|
||
process.stdin.write("config\n")
|
||
await process.stdin.drain()
|
||
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
|
||
|
||
# 3. 逐行寫入並進行 Fail-safe 檢查
|
||
for cmd in commands:
|
||
process.stdin.write(f"{cmd}\n")
|
||
await process.stdin.drain()
|
||
out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
|
||
|
||
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
|
||
|
||
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
|
||
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
|
||
# 1. 先通知前端正在撤銷
|
||
yield json.dumps({
|
||
"status": "progress",
|
||
"message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
|
||
}) + "\n"
|
||
|
||
# 2. 對設備下達 abort 指令
|
||
process.stdin.write("abort\n")
|
||
await process.stdin.drain()
|
||
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
|
||
|
||
# 3. 回報最終錯誤並關閉連線
|
||
yield json.dumps({
|
||
"status": "error",
|
||
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
|
||
"output": clean_out
|
||
}) + "\n"
|
||
return
|
||
|
||
yield json.dumps({
|
||
"status": "progress",
|
||
"message": f"執行: {cmd}",
|
||
"output": clean_out
|
||
}) + "\n"
|
||
|
||
# 稍微讓出控制權,確保串流順暢
|
||
await asyncio.sleep(0.05)
|
||
|
||
# 4. 提交變更 (Commit)
|
||
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
|
||
process.stdin.write("commit\n")
|
||
await process.stdin.drain()
|
||
commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
|
||
|
||
# 清理 Commit 回傳的雜訊
|
||
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
|
||
|
||
# 5. 退出並關閉連線
|
||
process.stdin.write("exit\n")
|
||
await process.stdin.drain()
|
||
# 🌟 移除手動 conn.close(),交由 async with 處理
|
||
|
||
yield json.dumps({
|
||
"status": "success",
|
||
"message": "✅ 所有差異還原指令已成功送出並 Commit!",
|
||
"output": clean_commit
|
||
}) + "\n"
|
||
|
||
except Exception as e:
|
||
logger.error(f"還原執行失敗: {str(e)}")
|
||
yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n"
|
||
|
||
return StreamingResponse(restore_generator(), media_type="application/x-ndjson")
|
||
|
||
|
||
================================================================================
|
||
FILE: routers/terminal.py
|
||
================================================================================
|
||
import asyncio
|
||
import asyncssh
|
||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||
from logger import get_logger
|
||
|
||
logger = get_logger("app.ssh")
|
||
router = APIRouter()
|
||
|
||
@router.websocket("/ws/terminal")
|
||
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
|
||
await websocket.accept()
|
||
conn = None
|
||
process = None
|
||
ws_task = None
|
||
ssh_task = None
|
||
try:
|
||
# 建立連線
|
||
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
||
|
||
# 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height)
|
||
process = await conn.create_process(
|
||
term_type='xterm-256color',
|
||
term_size=(80, 24), # 正確的 asyncssh 參數格式
|
||
encoding=None
|
||
)
|
||
|
||
async def forward_to_ws():
|
||
try:
|
||
while True:
|
||
data_bytes = await process.stdout.read(8192)
|
||
if not data_bytes:
|
||
logger.debug("設備端主動關閉了 stdout 通道")
|
||
break
|
||
|
||
safe_text = data_bytes.decode('utf-8', errors='replace')
|
||
await websocket.send_text(safe_text)
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception as e:
|
||
logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True)
|
||
|
||
async def forward_to_ssh():
|
||
try:
|
||
while True:
|
||
data_text = await websocket.receive_text()
|
||
data_bytes = data_text.encode('utf-8')
|
||
|
||
# 攔截 xterm.js 預設的 DEL (\x7f),轉換為 BS (\x08)
|
||
if b'\x7f' in data_bytes:
|
||
data_bytes = data_bytes.replace(b'\x7f', b'\x08')
|
||
|
||
process.stdin.write(data_bytes)
|
||
await process.stdin.drain()
|
||
except WebSocketDisconnect:
|
||
# 主動拋出,讓外層捕捉以進行資源回收
|
||
raise
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception as e:
|
||
logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True)
|
||
|
||
ws_task = asyncio.create_task(forward_to_ws())
|
||
ssh_task = asyncio.create_task(forward_to_ssh())
|
||
|
||
done, pending = await asyncio.wait(
|
||
[ws_task, ssh_task],
|
||
return_when=asyncio.FIRST_COMPLETED
|
||
)
|
||
|
||
for task in pending:
|
||
task.cancel()
|
||
|
||
except WebSocketDisconnect:
|
||
logger.debug("WebSocket 正常斷線,正在終止背景任務...")
|
||
if ws_task and not ws_task.done():
|
||
ws_task.cancel()
|
||
if ssh_task and not ssh_task.done():
|
||
ssh_task.cancel()
|
||
except Exception as e:
|
||
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
|
||
try:
|
||
await websocket.send_text(error_msg)
|
||
except:
|
||
pass
|
||
logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
|
||
finally:
|
||
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
||
if process:
|
||
process.close()
|
||
if conn:
|
||
conn.close()
|
||
try:
|
||
await websocket.close()
|
||
except:
|
||
pass
|
||
|
||
|
||
|
||
================================================================================
|
||
FILE: routers/lock.py
|
||
================================================================================
|
||
# --- routers/lock.py ---
|
||
import time
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from typing import Dict, Optional
|
||
|
||
router = APIRouter(prefix="/locks", tags=["Lock Management"])
|
||
|
||
# ==========================================
|
||
# 💡 全域鎖定表 (In-Memory Lock Table)
|
||
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
|
||
# ⚠️ 嚴格規則:Lock Key 絕對不包含 config_type,確保 running 與 full 視圖共用同一把鎖!
|
||
# ==========================================
|
||
ACTIVE_LOCKS: Dict[str, dict] = {}
|
||
LOCK_TIMEOUT = 120
|
||
|
||
# 🌟 請求模型嚴格排除 config_type
|
||
class LockAcquireReq(BaseModel):
|
||
host: str
|
||
path: str
|
||
user_id: str
|
||
username: str
|
||
|
||
class LockActionReq(BaseModel):
|
||
host: str
|
||
path: str
|
||
user_id: str
|
||
|
||
def clean_expired_locks():
|
||
current_time = time.time()
|
||
expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
|
||
for k in expired_keys:
|
||
del ACTIVE_LOCKS[k]
|
||
|
||
def is_path_locked_by_others(target_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
|
||
clean_expired_locks()
|
||
prefix = f"{target_host}@@"
|
||
|
||
for locked_key, lock_info in ACTIVE_LOCKS.items():
|
||
# 🌟 只檢查同一台設備 (IP) 的鎖定
|
||
if not locked_key.startswith(prefix):
|
||
continue
|
||
|
||
locked_path = locked_key.split("@@", 1)[1]
|
||
if lock_info["user_id"] == user_id:
|
||
continue
|
||
|
||
if target_path == locked_path:
|
||
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
|
||
|
||
if target_path.startswith(locked_path + "::"):
|
||
return lock_info, f"父層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法編輯子區塊"
|
||
|
||
if locked_path.startswith(target_path + "::"):
|
||
return lock_info, f"子層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法鎖定整個父區塊"
|
||
|
||
return None, ""
|
||
|
||
@router.post("/acquire")
|
||
async def acquire_lock(req: LockAcquireReq):
|
||
conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
|
||
|
||
if conflict_lock:
|
||
raise HTTPException(status_code=409, detail=error_msg)
|
||
|
||
# ⚠️ 嚴格綁定 host 與 path,跨視圖共用
|
||
lock_key = f"{req.host}@@{req.path}"
|
||
ACTIVE_LOCKS[lock_key] = {
|
||
"user_id": req.user_id,
|
||
"username": req.username,
|
||
"expires_at": time.time() + LOCK_TIMEOUT
|
||
}
|
||
return {"status": "success", "message": f"成功鎖定路徑: {req.path}"}
|
||
|
||
@router.post("/heartbeat")
|
||
async def heartbeat_lock(req: LockActionReq):
|
||
clean_expired_locks()
|
||
lock_key = f"{req.host}@@{req.path}"
|
||
|
||
if lock_key not in ACTIVE_LOCKS:
|
||
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
|
||
|
||
if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
|
||
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
|
||
|
||
ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
|
||
return {"status": "success", "message": "心跳更新成功"}
|
||
|
||
@router.post("/release")
|
||
async def release_lock(req: LockActionReq):
|
||
lock_key = f"{req.host}@@{req.path}"
|
||
if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
|
||
del ACTIVE_LOCKS[lock_key]
|
||
return {"status": "success", "message": "鎖定已釋放"}
|
||
|
||
@router.get("/status")
|
||
async def get_lock_status(host: str = Query(None)):
|
||
"""獲取特定設備的鎖定狀態"""
|
||
clean_expired_locks()
|
||
if host:
|
||
prefix = f"{host}@@"
|
||
# 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 path,UI 不用改!
|
||
filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
|
||
return {"status": "success", "data": filtered_locks}
|
||
|
||
return {"status": "success", "data": {}}
|
||
|
||
|
||
================================================================================
|
||
FILE: routers/diagnostics.py
|
||
================================================================================
|
||
# --- routers/diagnostics.py ---
|
||
import re
|
||
import asyncssh
|
||
from logger import get_logger
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from typing import Dict, Any
|
||
|
||
logger = get_logger("app.diagnostics")
|
||
router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"])
|
||
|
||
@router.get("/")
|
||
async def get_cm_diagnostics(
|
||
host: str = Query(...),
|
||
username: str = Query(...),
|
||
password: str = Query(...),
|
||
mac: str = Query(..., description="Cable Modem MAC Address")
|
||
) -> Dict[str, Any]:
|
||
|
||
mac = mac.strip().lower()
|
||
if not mac:
|
||
raise HTTPException(status_code=400, detail="MAC Address is required")
|
||
|
||
result_data = {
|
||
"mac": mac,
|
||
"ip": "N/A",
|
||
"state": "N/A",
|
||
"cpe_count": 0,
|
||
"phy": {
|
||
"tx_power": None,
|
||
"rx_power": None
|
||
},
|
||
"upstreams": [],
|
||
"mer_channels": {}
|
||
}
|
||
|
||
try:
|
||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||
logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")
|
||
|
||
# ==========================================
|
||
# 1. 查詢基本狀態
|
||
# ==========================================
|
||
cmd_base = "show cable modem " + mac + " | nomore"
|
||
res_base = await conn.run(cmd_base, check=False)
|
||
if res_base.exit_status == 0 and res_base.stdout:
|
||
for line in res_base.stdout.splitlines():
|
||
if mac in line.lower():
|
||
tokens = line.split()
|
||
for t in tokens:
|
||
if re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$", t):
|
||
result_data["ip"] = t
|
||
elif re.match(r"^[a-z]-online|online|offline|init.*|reject", t, re.IGNORECASE):
|
||
result_data["state"] = t
|
||
nums = [t for t in tokens if t.isdigit()]
|
||
if nums:
|
||
result_data["cpe_count"] = int(nums[-1])
|
||
break
|
||
|
||
# ==========================================
|
||
# 2. 查詢 PHY 狀態 (💡 修復:移除寫死的 us/oad 判斷,改用通用特徵)
|
||
# ==========================================
|
||
cmd_phy = "show cable modem " + mac + " phy | nomore"
|
||
res_phy = await conn.run(cmd_phy, check=False)
|
||
if res_phy.exit_status == 0 and res_phy.stdout:
|
||
tx_list = []
|
||
rx_list = []
|
||
for line in res_phy.stdout.splitlines():
|
||
line_lower = line.lower()
|
||
if mac in line_lower:
|
||
tokens = line.split()
|
||
if len(tokens) >= 6:
|
||
ch_name = tokens[1]
|
||
# 💡 只要名稱包含 ":" 和 "/",就認定是合法的通道 (例如 Oa32, Oad32, Us32)
|
||
if ":" in ch_name and "/" in ch_name:
|
||
try:
|
||
snr_val = float(tokens[4])
|
||
result_data["upstreams"].append({"channel": ch_name, "snr": snr_val})
|
||
except:
|
||
pass
|
||
try:
|
||
tx_val = float(tokens[3].split('/')[0])
|
||
rx_list_val = float(tokens[5].split('/')[0])
|
||
tx_list.append(tx_val)
|
||
rx_list.append(rx_list_val)
|
||
except:
|
||
pass
|
||
|
||
if tx_list: result_data["phy"]["tx_power"] = round(sum(tx_list)/len(tx_list), 2)
|
||
if rx_list: result_data["phy"]["rx_power"] = round(sum(rx_list)/len(rx_list), 2)
|
||
|
||
# ==========================================
|
||
# 3. 尋找所有 OFDM 通道
|
||
# ==========================================
|
||
cmd_help = "show cable modem " + mac + " ofdm-channel ?"
|
||
res_help = await conn.run(cmd_help, check=False)
|
||
text_to_search = (res_help.stdout or "") + "\n" + (res_help.stderr or "")
|
||
|
||
cmd_verb = "show cable modem " + mac + " verbose | nomore"
|
||
res_verb = await conn.run(cmd_verb, check=False)
|
||
text_to_search += "\n" + (res_verb.stdout or "")
|
||
|
||
matches = re.findall(r"\b(Of(?:dm)?\d*[\d/:]+)\b", text_to_search, re.IGNORECASE)
|
||
ofdm_channels = []
|
||
for m in matches:
|
||
clean_m = m.lower()
|
||
if clean_m.startswith("of"):
|
||
clean_m = "Of" + clean_m[2:]
|
||
if clean_m not in ofdm_channels:
|
||
ofdm_channels.append(clean_m)
|
||
ofdm_channels.sort()
|
||
|
||
# ==========================================
|
||
# 4. 對每一個 OFDM 通道查詢 MER
|
||
# ==========================================
|
||
for ch in ofdm_channels:
|
||
cmd_mer = "show cable modem " + mac + " ofdm-channel " + ch + " mer | nomore"
|
||
res_mer = await conn.run(cmd_mer, check=False)
|
||
|
||
histogram = {}
|
||
min_mer = 999
|
||
|
||
if res_mer.exit_status == 0 and res_mer.stdout:
|
||
mer_lines = re.finditer(r"(\d+)\s*(?:db|dB)?\s*\|[^|]*\|?\s*(\**)", res_mer.stdout, re.IGNORECASE)
|
||
for match in mer_lines:
|
||
db_val = match.group(1)
|
||
stars = match.group(2)
|
||
if stars:
|
||
histogram[db_val] = len(stars) * 100
|
||
db_int = int(db_val)
|
||
if db_int < min_mer:
|
||
min_mer = db_int
|
||
|
||
health = "unknown"
|
||
if histogram and min_mer != 999:
|
||
if min_mer >= 41: health = "good"
|
||
elif min_mer >= 38: health = "warning"
|
||
else: health = "critical"
|
||
|
||
result_data["mer_channels"][ch] = {
|
||
"histogram": histogram,
|
||
"health": health,
|
||
"min_mer": min_mer if min_mer != 999 else "N/A"
|
||
}
|
||
|
||
logger.debug(f"✅ [Diagnostics] 解析結果: {result_data}")
|
||
return {"status": "success", "data": result_data}
|
||
|
||
except Exception as e:
|
||
logger.error("CM Diagnostics Error: " + str(e))
|
||
raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e))
|
||
|
||
================================================================================
|
||
FILE: routers/query.py
|
||
================================================================================
|
||
# --- routers/query.py ---
|
||
import re
|
||
import asyncssh
|
||
import asyncio
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from shared import CMTS_DEVICE
|
||
|
||
router = APIRouter()
|
||
|
||
# ==========================================
|
||
# 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko)
|
||
# ==========================================
|
||
|
||
async def execute_single_command(host, username, password, command, timeout=15.0):
|
||
"""
|
||
執行單一查詢指令 (適用於 90% 的標準 show 指令)
|
||
自動防呆:確保指令帶有取消分頁的後綴,防止 Event Loop 卡死
|
||
"""
|
||
try:
|
||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||
# 確保指令包含取消分頁的參數
|
||
if "| nomore" not in command.lower():
|
||
command += " | nomore"
|
||
|
||
result = await conn.run(command, check=False, timeout=timeout)
|
||
return result.stdout or ""
|
||
|
||
except asyncssh.Error as e:
|
||
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
|
||
except asyncio.TimeoutError:
|
||
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
|
||
except Exception as e:
|
||
raise Exception(f"SSH Unexpected Error: {str(e)}")
|
||
|
||
async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
|
||
"""
|
||
執行互動式指令 (適用於需要進入 config 模式,或使用 '?' 觸發補全的指令)
|
||
動態處理終端機的 --More-- 提示
|
||
"""
|
||
try:
|
||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
|
||
|
||
async def read_until_quiet(wait_time):
|
||
output = ""
|
||
while True:
|
||
try:
|
||
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=wait_time)
|
||
if not chunk: break
|
||
output += chunk
|
||
# 動態處理分頁符號
|
||
if "--More--" in chunk or "More" in chunk:
|
||
process.stdin.write(" ")
|
||
await process.stdin.drain()
|
||
except asyncio.TimeoutError:
|
||
break
|
||
return output
|
||
|
||
# 1. 清除登入 MOTD
|
||
await read_until_quiet(1.5)
|
||
|
||
# 2. 執行取消分頁指令 (雙重保險)
|
||
process.stdin.write("terminal length 0\n")
|
||
await process.stdin.drain()
|
||
await read_until_quiet(0.5)
|
||
|
||
# 3. 執行目標指令
|
||
final_output = ""
|
||
for cmd in commands:
|
||
process.stdin.write(cmd)
|
||
await process.stdin.drain()
|
||
final_output += await read_until_quiet(timeout)
|
||
|
||
# 4. 退出 session
|
||
process.stdin.write("exit\n")
|
||
await process.stdin.drain()
|
||
|
||
# 清理 ANSI 控制碼與退格鍵雜訊
|
||
clean_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', final_output)
|
||
return clean_output
|
||
|
||
except asyncssh.Error as e:
|
||
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
|
||
except asyncio.TimeoutError:
|
||
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
|
||
except Exception as e:
|
||
raise Exception(f"SSH Unexpected Error: {str(e)}")
|
||
|
||
# ==========================================
|
||
# 🚀 API 路由
|
||
# ==========================================
|
||
|
||
@router.get("/cmts-query")
|
||
async def get_cmts_query(
|
||
query_type: str = Query(..., description="查詢動作類型"),
|
||
target: str = Query("", description="MAC Address 或 VC:VS (選填)"),
|
||
host: str = Query(...),
|
||
username: str = Query(...),
|
||
password: str = Query(...)
|
||
):
|
||
try:
|
||
target_str = f" {target.strip()}" if target.strip() else ""
|
||
|
||
commands = {
|
||
"base": f"show cable modem{target_str}",
|
||
"cpe": f"show cable modem{target_str} cpe",
|
||
"cpe_dhcp": f"show cable modem{target_str} cpe dhcp",
|
||
"cpe_ipv6": f"show cable modem{target_str} cpe ipv6",
|
||
"bonding": f"show cable modem{target_str} bonding",
|
||
"bonding_ds": f"show cable modem{target_str} bonding downstream",
|
||
"bonding_us": f"show cable modem{target_str} bonding upstream",
|
||
"phy": f"show cable modem{target_str} phy",
|
||
"verbose": f"show cable modem{target_str} verbose",
|
||
"ofdm_profile": f"show cable modem{target_str} ofdm-profile",
|
||
"ofdma_profile": f"show cable modem{target_str} ofdma-profile",
|
||
"dhcp_verbose": f"show cable modem{target_str} dhcp verbose",
|
||
"service_flow": f"show cable modem{target_str} service-flow",
|
||
"service_flow_verbose": f"show cable modem{target_str} service-flow verbose",
|
||
"qos": f"show cable modem{target_str} qos",
|
||
"uptime": f"show cable modem{target_str} uptime",
|
||
"ugs": f"show cable modem{target_str} ugs",
|
||
"cm_status": f"show cable modem{target_str} cm-status",
|
||
"partial_mode": "show cable modem partial-mode",
|
||
"hop": "show cable hop",
|
||
"flap_list": "show cable flap-list",
|
||
"flap_sum": "show cable flap-sum",
|
||
"rpd_base": f"show cable rpd{target_str}",
|
||
"rpd_verbose": f"show cable rpd{target_str} verbose",
|
||
"rpd_ptp_time": f"show cable rpd{target_str} ptp time-property",
|
||
"rpd_ptp_verbose": f"show cable rpd{target_str} ptp verbose",
|
||
"rpd_counters_map": f"show cable rpd{target_str} counters map",
|
||
"rpd_capabilities": f"show cable rpd{target_str} capabilities",
|
||
"rpd_video_counters": f"show cable rpd{target_str} video-channel counters",
|
||
"rpd_env_temp": f"show cable rpd{target_str} environment temperature",
|
||
"rpd_env_volt": f"show cable rpd{target_str} environment voltage",
|
||
"rpd_session": f"show cable rpd{target_str} session",
|
||
"rpd_reset_history": f"show cable rpd{target_str} reset-history",
|
||
"rpd_port_transceiver": f"show cable rpd{target_str} port-transceiver",
|
||
}
|
||
|
||
if query_type not in commands:
|
||
raise ValueError(f"未知的查詢類型: {query_type}")
|
||
|
||
cli_command = commands[query_type] + " | nomore"
|
||
raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
|
||
|
||
return {"status": "success", "command": cli_command, "data": raw_output}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}")
|
||
|
||
@router.get("/cmts-mac-domain-config")
|
||
async def get_mac_domain_config(target: str, host: str, username: str, password: str):
|
||
try:
|
||
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
|
||
raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
|
||
|
||
# 💡 正名工程:更新字典的 Key 為一致性的命名
|
||
config = {
|
||
"common_settings": { "ip-provisioning-mode": "dual-stack", "diplexer-band-edge": "enabled", "cm-battery-mode-31-support": "disabled", "cm-battery-mode-30-support": "disabled", "docsis40": "disabled", "ds-dynamic-bonding-group": "disabled", "us-dynamic-bonding-group": "disabled" },
|
||
"basic_channel_sets": { "admin-state": "down", "ds-primary-set": "", "ds-non-primary-set": "", "us-phy-channel-set": "", "ds-ofdm-set": "", "us-ofdma-set": "" },
|
||
"static_ds_bonding_groups": {},
|
||
"static_us_bonding_groups": {}
|
||
}
|
||
|
||
current_group_type = None
|
||
current_group_name = None
|
||
|
||
for line in raw_output.splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("cable mac-domain"): continue
|
||
|
||
if line.startswith("ds-bonding-group"):
|
||
current_group_type = "static_ds_bonding_groups"
|
||
current_group_name = line.split()[1]
|
||
config["static_ds_bonding_groups"][current_group_name] = {"admin-state": "down"}
|
||
continue
|
||
elif line.startswith("us-bonding-group"):
|
||
current_group_type = "static_us_bonding_groups"
|
||
current_group_name = line.split()[1]
|
||
config["static_us_bonding_groups"][current_group_name] = {"admin-state": "down"}
|
||
continue
|
||
elif line == "!":
|
||
current_group_type = None
|
||
current_group_name = None
|
||
continue
|
||
|
||
if current_group_type and current_group_name:
|
||
parts = line.split(maxsplit=1)
|
||
if len(parts) == 2: config[current_group_type][current_group_name][parts[0]] = parts[1]
|
||
else:
|
||
# 💡 正名工程:將解析結果存入對應的新 Key 中
|
||
if line.startswith("ip-provisioning-mode"): config["common_settings"]["ip-provisioning-mode"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("diplexer-band-edge control"): config["common_settings"]["diplexer-band-edge"] = line.split(maxsplit=2)[2]
|
||
elif line.startswith("cm-battery-mode-31-support"): config["common_settings"]["cm-battery-mode-31-support"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("cm-battery-mode-30-support"): config["common_settings"]["cm-battery-mode-30-support"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("docsis40"): config["common_settings"]["docsis40"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("ds-dynamic-bonding-group"): config["common_settings"]["ds-dynamic-bonding-group"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("us-dynamic-bonding-group"): config["common_settings"]["us-dynamic-bonding-group"] = line.split(maxsplit=1)[1]
|
||
|
||
# 💡 正名工程:將原本的 dynamic_bonding_groups 改為 basic_channel_sets
|
||
elif line.startswith("admin-state"): config["basic_channel_sets"]["admin-state"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("ds-primary-set"): config["basic_channel_sets"]["ds-primary-set"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("ds-non-primary-set"): config["basic_channel_sets"]["ds-non-primary-set"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("us-phy-channel-set"): config["basic_channel_sets"]["us-phy-channel-set"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("ds-ofdm-set"): config["basic_channel_sets"]["ds-ofdm-set"] = line.split(maxsplit=1)[1]
|
||
elif line.startswith("us-ofdma-set"): config["basic_channel_sets"]["us-ofdma-set"] = line.split(maxsplit=1)[1]
|
||
|
||
return {"status": "success", "data": config}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}")
|
||
|
||
@router.get("/cmts-mac-domain-list")
|
||
async def get_cmts_mac_domain_list(
|
||
host: str = Query(...),
|
||
username: str = Query(...),
|
||
password: str = Query(...)
|
||
):
|
||
try:
|
||
# 這裡使用 '?' 觸發補全,必須使用互動式引擎
|
||
commands = ["show running-config cable mac-domain ?"]
|
||
raw_output = await execute_interactive_command(host, username, password, commands, timeout=5.0)
|
||
|
||
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
|
||
mac_domains = list(dict.fromkeys(matches))
|
||
|
||
return {"status": "success", "data": mac_domains}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|
||
|
||
@router.get("/cmts-version")
|
||
async def get_cmts_version(
|
||
host: str = Query(...),
|
||
username: str = Query(...),
|
||
password: str = Query(...)
|
||
):
|
||
try:
|
||
raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0)
|
||
|
||
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
|
||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
|
||
|
||
if match:
|
||
return {"status": "success", "version": match.group(1)}
|
||
else:
|
||
return {"status": "error", "message": "無法解析版本號", "raw_output": raw_output}
|
||
|
||
except Exception as e:
|
||
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
|
||
|
||
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
|
||
async def list_cms(host: str, username: str, password: str):
|
||
try:
|
||
raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
|
||
|
||
if not raw_output:
|
||
return {"cms": []}
|
||
|
||
# 嚴謹的 Regex:匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
|
||
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
|
||
macs = []
|
||
|
||
for line in raw_output.splitlines():
|
||
match = mac_pattern.search(line)
|
||
if match:
|
||
macs.append(match.group(1).lower())
|
||
|
||
# 去重複並排序
|
||
return {"cms": sorted(list(set(macs)))}
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
|
||
return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列
|
||
|
||
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
|
||
async def list_rpds(host: str, username: str, password: str):
|
||
try:
|
||
raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
|
||
|
||
if not raw_output:
|
||
return {"rpds": []}
|
||
|
||
# 嚴謹的 Regex:匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
|
||
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
|
||
rpds = []
|
||
|
||
for line in raw_output.splitlines():
|
||
match = vcvs_pattern.search(line)
|
||
if match:
|
||
rpds.append(match.group(1))
|
||
|
||
return {"rpds": sorted(list(set(rpds)))}
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
|
||
return {"rpds": []}
|
||
|
||
|
||
================================================================================
|
||
FILE: routers/auth.py
|
||
================================================================================
|
||
import os
|
||
import secrets
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
from logger import get_logger
|
||
|
||
logger = get_logger("app.auth")
|
||
router = APIRouter(tags=["Auth"])
|
||
|
||
# 定義請求本體格式
|
||
class GodModeRequest(BaseModel):
|
||
password: str
|
||
|
||
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
|
||
async def verify_god_mode(req: GodModeRequest):
|
||
# 從環境變數讀取正確密碼,預設為 "admin999"
|
||
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
|
||
|
||
# 使用 compare_digest 防禦計時攻擊
|
||
if secrets.compare_digest(req.password, expected_password):
|
||
logger.info("🔓 上帝模式解鎖成功!")
|
||
return {"status": "success"}
|
||
|
||
logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
|
||
raise HTTPException(status_code=401, detail="Invalid authorization code")
|
||
|
||
|
||
================================================================================
|
||
FILE: routers/config.py
|
||
================================================================================
|
||
# --- routers/config.py ---
|
||
import re
|
||
import json
|
||
import os
|
||
import database
|
||
import asyncio
|
||
import asyncssh
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import List
|
||
from netmiko import ConnectHandler
|
||
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
||
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
|
||
from collections import defaultdict
|
||
from fastapi.responses import StreamingResponse
|
||
from logger import get_all_log_levels, set_log_level
|
||
|
||
router = APIRouter()
|
||
|
||
# ==========================================
|
||
# 🌟 雙軌過濾器檔案讀寫輔助函數 (加入高可用性 Fallback)
|
||
# ==========================================
|
||
def get_filter_file_path(config_type: str) -> str:
|
||
# 確保檔名安全,只允許 'running' 或 'full'
|
||
safe_type = "full" if config_type == "full" else "running"
|
||
return f"filters_{safe_type}.json"
|
||
|
||
async def load_tree_filters(config_type: str) -> list:
|
||
if USE_DB:
|
||
try:
|
||
db_filters = await database.get_tree_filters(config_type)
|
||
if db_filters is not None:
|
||
return db_filters
|
||
else:
|
||
print("⚠️ [Fallback] 資料庫無法取得過濾器,自動切換至 JSON 快取讀取...")
|
||
except Exception as e:
|
||
print(f"⚠️ [Fallback] 資料庫讀取過濾器發生例外: {e},自動切換至 JSON 快取讀取...")
|
||
|
||
file_path = get_filter_file_path(config_type)
|
||
if os.path.exists(file_path):
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
return data.get("hidden_keys", [])
|
||
except Exception as e:
|
||
print(f"讀取過濾器檔案失敗: {e}")
|
||
return []
|
||
return []
|
||
|
||
async def save_tree_filters(config_type: str, hidden_keys: list):
|
||
db_success = False
|
||
if USE_DB:
|
||
try:
|
||
if await database.upsert_tree_filters(config_type, hidden_keys):
|
||
db_success = True
|
||
else:
|
||
print("⚠️ [Fallback] 資料庫儲存過濾器失敗,自動切換至 JSON 快取寫入...")
|
||
except Exception as e:
|
||
print(f"⚠️ [Fallback] 資料庫寫入過濾器發生例外: {e},自動切換至 JSON 快取寫入...")
|
||
|
||
if not db_success:
|
||
file_path = get_filter_file_path(config_type)
|
||
try:
|
||
with open(file_path, 'w', encoding='utf-8') as f:
|
||
json.dump({"hidden_keys": hidden_keys}, f, ensure_ascii=False, indent=4)
|
||
except Exception as e:
|
||
print(f"儲存過濾器檔案失敗: {e}")
|
||
|
||
# ==========================================
|
||
|
||
class ConfigRequest(BaseModel):
|
||
script: str
|
||
host: str
|
||
username: str
|
||
password: str
|
||
|
||
# 定義前端傳來的 Diff 資料結構
|
||
class DiffItem(BaseModel):
|
||
path: str # 例如: "cable.ds-rf-port.1:9/0.down-channel.0.frequency"
|
||
old_val: str
|
||
new_val: str
|
||
|
||
class GenerateCliRequest(BaseModel):
|
||
diffs: List[DiffItem]
|
||
interfaces_with_admin_state: List[str] = []
|
||
|
||
@router.post("/cmts-config")
|
||
async def execute_cmts_config(req: ConfigRequest):
|
||
# 過濾掉空行與註解 (! 開頭的行)
|
||
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
|
||
|
||
async def config_streamer():
|
||
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
||
host_lock = cmts_config_locks[req.host]
|
||
async with host_lock:
|
||
try:
|
||
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
|
||
|
||
# 使用 asyncssh 建立非同步連線
|
||
async with asyncssh.connect(
|
||
req.host,
|
||
username=req.username,
|
||
password=req.password,
|
||
known_hosts=None
|
||
) as conn:
|
||
async with conn.create_process() as process:
|
||
|
||
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
||
async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
|
||
out_data = ""
|
||
while True:
|
||
try:
|
||
# 利用 wait_for 設定超時,時間內沒資料就當作設備吐完了
|
||
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||
if not chunk:
|
||
break
|
||
out_data += str(chunk)
|
||
# 🌟 精準 Prompt 偵測
|
||
if prompt_pattern and re.search(prompt_pattern, out_data):
|
||
break
|
||
except asyncio.TimeoutError:
|
||
break
|
||
return out_data
|
||
|
||
# 1. 進入設定模式
|
||
process.stdin.write("config\n")
|
||
await process.stdin.drain()
|
||
await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
|
||
|
||
# 2. 逐行送出指令並回報進度
|
||
for cmd in commands:
|
||
yield json.dumps({"status": "progress", "message": f"▶️ 執行: {cmd}"}) + "\n"
|
||
process.stdin.write(cmd + "\n")
|
||
await process.stdin.drain()
|
||
|
||
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
|
||
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
|
||
output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
|
||
|
||
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
|
||
lower_output = output.lower()
|
||
if "% invalid" in lower_output or "% incomplete" in lower_output or "error" in lower_output or "aborted" in lower_output:
|
||
yield json.dumps({
|
||
"status": "error",
|
||
"message": f"❌ 設備拒絕指令: {cmd}",
|
||
"output": output.strip()
|
||
}) + "\n"
|
||
|
||
process.stdin.write("abort\n")
|
||
await process.stdin.drain()
|
||
return # 發生錯誤,提早結束串流
|
||
|
||
# 3. 退出設定模式
|
||
process.stdin.write("exit\n")
|
||
await process.stdin.drain()
|
||
final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
|
||
|
||
yield json.dumps({
|
||
"status": "success",
|
||
"message": "✅ 所有配置已成功寫入並 Commit!",
|
||
"output": final_output.strip()
|
||
}) + "\n"
|
||
|
||
except Exception as e:
|
||
yield json.dumps({"status": "error", "message": f"SSH 執行發生例外錯誤: {str(e)}"}) + "\n"
|
||
|
||
# 回傳 NDJSON 串流格式
|
||
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
|
||
|
||
@router.post("/cmts-generate-cli")
|
||
async def generate_cli(req: GenerateCliRequest):
|
||
"""
|
||
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
|
||
並自動處理 admin-state 的安全生命週期 (先 down 後 up)
|
||
"""
|
||
interface_groups = defaultdict(list)
|
||
interface_admin_states = {}
|
||
|
||
for diff in req.diffs:
|
||
parts = diff.path.split('::')
|
||
|
||
param_name = parts[-1]
|
||
interface_path = " ".join(parts[:-1])
|
||
|
||
# 全域清除路徑中間可能夾帶的虛擬資料夾索引
|
||
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
|
||
|
||
if param_name == "admin-state":
|
||
interface_admin_states[interface_path] = diff.new_val
|
||
else:
|
||
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
|
||
|
||
cli_lines = []
|
||
cli_lines.append("! --- Auto-Generated Configuration Script ---")
|
||
|
||
for interface, changes in interface_groups.items():
|
||
cli_lines.append(f"! Configuring: {interface}")
|
||
|
||
supports_admin_state = interface in req.interfaces_with_admin_state
|
||
|
||
if supports_admin_state:
|
||
cli_lines.append(f"{interface} admin-state down")
|
||
|
||
for param, old_val, new_val in changes:
|
||
if re.match(r"^\[\d+\]$", param):
|
||
if new_val == "":
|
||
if old_val:
|
||
cli_lines.append(f"no {interface} {old_val}")
|
||
else:
|
||
if old_val and old_val != new_val:
|
||
cli_lines.append(f"no {interface} {old_val}")
|
||
cli_lines.append(f"{interface} {new_val}")
|
||
else:
|
||
if new_val == "":
|
||
cli_lines.append(f"no {interface} {param}")
|
||
else:
|
||
cli_lines.append(f"{interface} {param} {new_val}")
|
||
|
||
if supports_admin_state:
|
||
final_state = interface_admin_states.get(interface, "up")
|
||
cli_lines.append(f"{interface} admin-state {final_state}")
|
||
|
||
cli_lines.append("commit")
|
||
cli_lines.append("!")
|
||
|
||
for interface, state in interface_admin_states.items():
|
||
if interface not in interface_groups:
|
||
cli_lines.append(f"! Configuring state only: {interface}")
|
||
cli_lines.append(f"{interface} admin-state {state}")
|
||
cli_lines.append("commit")
|
||
cli_lines.append("!")
|
||
|
||
final_script = "\n".join(cli_lines)
|
||
return {"status": "success", "data": final_script}
|
||
|
||
# ==========================================
|
||
# DS RF Port 動態樹狀查詢 API
|
||
# ==========================================
|
||
|
||
@router.get("/cmts-ds-rf-port-list")
|
||
async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
|
||
"""獲取設備上所有的 DS RF Port 清單"""
|
||
net_connect = None
|
||
try:
|
||
device = CMTS_DEVICE.copy()
|
||
device.update({'host': host, 'username': username, 'password': password})
|
||
|
||
net_connect = ConnectHandler(**device)
|
||
command = 'show running-config | include "cable ds-rf-port"'
|
||
raw_output = str(net_connect.send_command(command, read_timeout=60))
|
||
|
||
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
|
||
ports = re.findall(pattern, raw_output)
|
||
unique_ports = sorted(list(set(ports)))
|
||
|
||
return {
|
||
"status": "success",
|
||
"data": unique_ports,
|
||
"raw_output": raw_output
|
||
}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|
||
finally:
|
||
if net_connect:
|
||
net_connect.disconnect()
|
||
|
||
|
||
@router.get("/cmts-ds-rf-port-config")
|
||
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
|
||
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
|
||
net_connect = None
|
||
try:
|
||
device = CMTS_DEVICE.copy()
|
||
device.update({'host': host, 'username': username, 'password': password})
|
||
|
||
net_connect = ConnectHandler(**device)
|
||
command = f"show running-config interface cable ds-rf-port {target} | nomore"
|
||
raw_output = str(net_connect.send_command(command, read_timeout=60))
|
||
|
||
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
||
base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
|
||
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
||
|
||
return {
|
||
"status": "success",
|
||
"data": perfect_tree,
|
||
"raw_cli": raw_output.strip()
|
||
}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|
||
finally:
|
||
if net_connect:
|
||
net_connect.disconnect()
|
||
|
||
|
||
@router.get("/cmts-full-config")
|
||
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
|
||
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
|
||
net_connect = None
|
||
try:
|
||
device = CMTS_DEVICE.copy()
|
||
device.update({'host': host, 'username': username, 'password': password})
|
||
|
||
net_connect = ConnectHandler(**device)
|
||
|
||
if config_type == "full":
|
||
net_connect.send_command_timing("config")
|
||
command = "show full-configuration | nomore"
|
||
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
||
net_connect.send_command_timing("exit")
|
||
else:
|
||
command = "show running-config | nomore"
|
||
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
||
|
||
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
|
||
clean_output = clean_output.lstrip()
|
||
|
||
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
||
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
|
||
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
||
|
||
# ==========================================
|
||
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
|
||
# ==========================================
|
||
if not skip_filter:
|
||
hidden_keys = await load_tree_filters(config_type)
|
||
|
||
for path in hidden_keys:
|
||
keys = path.split('::')
|
||
current = perfect_tree
|
||
# 走到倒數第二層
|
||
for k in keys[:-1]:
|
||
if isinstance(current, dict) and k in current:
|
||
current = current[k]
|
||
else:
|
||
current = None
|
||
break
|
||
# 刪除最後一層的目標節點
|
||
if isinstance(current, dict) and keys[-1] in current:
|
||
current.pop(keys[-1], None)
|
||
# ==========================================
|
||
|
||
return {"status": "success", "data": perfect_tree}
|
||
except Exception as e:
|
||
return {"status": "error", "message": str(e)}
|
||
finally:
|
||
if net_connect:
|
||
net_connect.disconnect()
|
||
|
||
@router.get("/cmts-full-config/stream")
|
||
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
|
||
"""獲取整台 CMTS 的完整配置 (串流進度回報版)"""
|
||
|
||
async def config_streamer():
|
||
net_connect = None
|
||
try:
|
||
# 1. 廣播:正在連線
|
||
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
|
||
await asyncio.sleep(0.1) # 讓前端有時間渲染
|
||
|
||
device = CMTS_DEVICE.copy()
|
||
device.update({'host': host, 'username': username, 'password': password})
|
||
|
||
# 使用 asyncio.to_thread 將阻塞的 netmiko 連線放到背景執行,避免卡住其他非同步任務
|
||
net_connect = await asyncio.to_thread(ConnectHandler, **device)
|
||
|
||
# 2. 廣播:正在下載
|
||
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
||
|
||
if config_type == "full":
|
||
await asyncio.to_thread(net_connect.send_command_timing, "config")
|
||
command = "show full-configuration | nomore"
|
||
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
||
await asyncio.to_thread(net_connect.send_command_timing, "exit")
|
||
else:
|
||
command = "show running-config | nomore"
|
||
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
||
|
||
# 3. 廣播:正在解析
|
||
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||
await asyncio.sleep(0.1)
|
||
|
||
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
|
||
clean_output = clean_output.lstrip()
|
||
|
||
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
||
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
|
||
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
||
|
||
# ==========================================
|
||
# 🌟 深度過濾攔截器 (維持原樣)
|
||
# ==========================================
|
||
if not skip_filter:
|
||
yield json.dumps({"status": "progress", "message": "🔍 正在套用系統過濾器規則..."}) + "\n"
|
||
await asyncio.sleep(0.1)
|
||
|
||
hidden_keys = await load_tree_filters(config_type)
|
||
|
||
for path in hidden_keys:
|
||
keys = path.split('::')
|
||
current = perfect_tree
|
||
for k in keys[:-1]:
|
||
if isinstance(current, dict) and k in current:
|
||
current = current[k]
|
||
else:
|
||
current = None
|
||
break
|
||
if isinstance(current, dict) and keys[-1] in current:
|
||
current.pop(keys[-1], None)
|
||
# ==========================================
|
||
|
||
# 4. 廣播:完成並傳送最終資料
|
||
yield json.dumps({"status": "success", "data": perfect_tree}) + "\n"
|
||
|
||
except Exception as e:
|
||
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
|
||
finally:
|
||
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
|
||
if net_connect:
|
||
await asyncio.to_thread(net_connect.disconnect)
|
||
|
||
# 回傳 NDJSON 串流格式
|
||
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
|
||
|
||
# ==========================================
|
||
# 🌟 系統設定 API (System Settings)
|
||
# ==========================================
|
||
class SettingsRequest(BaseModel):
|
||
hidden_keys: list[str]
|
||
config_type: str = "running" # 🌟 加入 config_type 參數
|
||
|
||
@router.get("/settings/tree-filters")
|
||
async def get_tree_filters(config_type: str = "running"):
|
||
"""獲取目前的樹狀圖隱藏名單"""
|
||
# 🌟 根據 config_type 讀取對應的 JSON
|
||
hidden_keys = await load_tree_filters(config_type)
|
||
return {"status": "success", "data": hidden_keys}
|
||
|
||
@router.post("/settings/tree-filters")
|
||
async def update_tree_filters(req: SettingsRequest):
|
||
"""更新樹狀圖隱藏名單並存檔"""
|
||
# 🌟 根據 config_type 寫入對應的 JSON
|
||
await save_tree_filters(req.config_type, req.hidden_keys)
|
||
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
|
||
|
||
# ==========================================
|
||
# 🌟 伺服器日誌管控 API (Server Log Management)
|
||
# ==========================================
|
||
class LogLevelRequest(BaseModel):
|
||
module: str
|
||
level: str
|
||
|
||
@router.get("/settings/logs")
|
||
async def api_get_log_levels():
|
||
"""獲取目前所有模組的日誌等級"""
|
||
return {"status": "success", "data": get_all_log_levels()}
|
||
|
||
@router.post("/settings/logs")
|
||
async def api_set_log_level(req: LogLevelRequest):
|
||
"""動態修改特定模組的日誌等級"""
|
||
success = set_log_level(req.module, req.level)
|
||
if success:
|
||
return {"status": "success", "message": f"已將 {req.module} 的日誌等級設為 {req.level}"}
|
||
raise HTTPException(status_code=400, detail="未知的模組名稱")
|
||
|
||
================================================================================
|
||
FILE: routers/leaf_options.py
|
||
================================================================================
|
||
# --- routers/leaf_options.py ---
|
||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel
|
||
from typing import List
|
||
import json, os, asyncio, re
|
||
import database
|
||
from cmts_scraper import sync_cmts_leaves_async
|
||
from shared import CMTS_DEVICE, USE_DB
|
||
|
||
router = APIRouter(tags=["Options"])
|
||
|
||
# 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
|
||
def get_cache_file(host: str, config_type: str):
|
||
safe_host = host.replace(".", "_")
|
||
return f"{safe_host}_{config_type}_cache.json"
|
||
|
||
# ==========================================
|
||
# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
|
||
# ==========================================
|
||
# 將狀態改為空字典,動態根據 host_configType 建立
|
||
SCAN_STATUS = {}
|
||
active_clients = {}
|
||
|
||
async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
|
||
"""將訊息推播給特定頻道的連線前端"""
|
||
channel_key = f"{host}_{config_type}"
|
||
dead_clients = set()
|
||
|
||
# SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
|
||
message_str = f"data: {json.dumps(message_dict)}\n\n"
|
||
|
||
for client_queue in active_clients.get(channel_key, set()):
|
||
try:
|
||
await client_queue.put(message_str)
|
||
except Exception:
|
||
dead_clients.add(client_queue)
|
||
for dead in dead_clients:
|
||
active_clients[channel_key].discard(dead)
|
||
|
||
@router.get("/cmts-leaf-options/stream")
|
||
async def sse_stream(request: Request, host: str, config_type: str = "running"):
|
||
"""前端一載入就會連上這個路由,持續監聽特定頻道的廣播"""
|
||
channel_key = f"{host}_{config_type}"
|
||
client_queue = asyncio.Queue()
|
||
|
||
if channel_key not in active_clients:
|
||
active_clients[channel_key] = set()
|
||
active_clients[channel_key].add(client_queue)
|
||
|
||
async def event_generator():
|
||
try:
|
||
while True:
|
||
# 主動檢查斷線,避免死鎖
|
||
if await request.is_disconnected():
|
||
break
|
||
try:
|
||
# [修復 Leak] 延長 timeout 降低輪詢負擔
|
||
message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
|
||
yield message
|
||
except asyncio.TimeoutError:
|
||
yield ": keepalive\n\n"
|
||
|
||
except asyncio.CancelledError:
|
||
pass
|
||
finally:
|
||
# [修復 Leak] 確保斷線時 Queue 絕對會被移出 active_clients 釋放記憶體
|
||
if channel_key in active_clients:
|
||
active_clients[channel_key].discard(client_queue)
|
||
|
||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||
|
||
@router.get("/scan-status")
|
||
async def get_scan_status(host: str, config_type: str = "running"):
|
||
channel_key = f"{host}_{config_type}"
|
||
return {"is_scanning": SCAN_STATUS.get(channel_key, False)}
|
||
|
||
# ==========================================
|
||
# 🌟 3. 快取讀取與背景掃描任務
|
||
# ==========================================
|
||
class SyncOptionsRequest(BaseModel):
|
||
host: str # 🌟 新增 host 參數
|
||
username: str = "" # 🌟 新增 username 參數
|
||
password: str = "" # 🌟 新增 password 參數
|
||
leaf_paths: List[str]
|
||
config_type: str = "running"
|
||
|
||
@router.get("/cmts-leaf-options")
|
||
async def get_leaf_options(host: str, config_type: str = "running"):
|
||
if USE_DB:
|
||
try:
|
||
db_options = await database.get_all_leaf_options(host, config_type)
|
||
if db_options is not None:
|
||
# 取得 metadata
|
||
metadata = await database.get_device_status(host, config_type)
|
||
if metadata:
|
||
db_options["__metadata__"] = metadata
|
||
return db_options
|
||
else:
|
||
# 若回傳 None 表示 DB 連線異常,執行 Fallback
|
||
print("⚠️ [Fallback] 資料庫無法取得資料,自動切換至 JSON 快取讀取...")
|
||
except Exception as e:
|
||
print(f"⚠️ [Fallback] 資料庫讀取發生例外: {e},自動切換至 JSON 快取讀取...")
|
||
|
||
cache_file = get_cache_file(host, config_type)
|
||
if not os.path.exists(cache_file): return {}
|
||
|
||
# 🌟 優化 3:將讀取動作丟到背景執行緒
|
||
def read_json_from_file(filepath):
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
return await asyncio.to_thread(read_json_from_file, cache_file)
|
||
|
||
async def run_scan_task(host: str, username: str, password: str, paths: List[str], config_type: str):
|
||
"""在背景執行的爬蟲任務,並將進度廣播出去"""
|
||
global SCAN_STATUS
|
||
channel_key = f"{host}_{config_type}"
|
||
try:
|
||
await broadcast_message({"event": "scan_start"}, host, config_type)
|
||
|
||
# 呼叫爬蟲 (帶入設備連線資訊與 config_type)
|
||
async for chunk in sync_cmts_leaves_async(
|
||
host=host,
|
||
username=username,
|
||
password=password,
|
||
leaf_paths=paths,
|
||
config_type=config_type
|
||
):
|
||
if isinstance(chunk, str):
|
||
await broadcast_message(json.loads(chunk), host, config_type)
|
||
else:
|
||
await broadcast_message(chunk, host, config_type)
|
||
|
||
await broadcast_message({"event": "done"}, host, config_type)
|
||
except Exception as e:
|
||
await broadcast_message({"event": "error", "message": str(e)}, host, config_type)
|
||
finally:
|
||
# 解除特定頻道的鎖定
|
||
SCAN_STATUS[channel_key] = False
|
||
|
||
@router.post("/cmts-leaf-options/sync")
|
||
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
|
||
global SCAN_STATUS
|
||
if not request.leaf_paths:
|
||
raise HTTPException(status_code=400)
|
||
|
||
channel_key = f"{request.host}_{request.config_type}"
|
||
|
||
if SCAN_STATUS.get(channel_key, False):
|
||
return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"}
|
||
|
||
SCAN_STATUS[channel_key] = True
|
||
|
||
# 保留您原本完美的路徑清理邏輯
|
||
cmts_query_paths = []
|
||
for p in request.leaf_paths:
|
||
clean_p = p.replace("::", " ")
|
||
clean_p = re.sub(r"\s*\[\d+\]", "", clean_p)
|
||
cmts_query_paths.append(clean_p)
|
||
|
||
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
|
||
|
||
# 決定帳密 (優先使用 request 傳來的,否則 fallback 到 shared.CMTS_DEVICE)
|
||
# 🌟 加上 or "" 確保最終結果絕對是字串,消除 Pylance 警告
|
||
user = request.username or CMTS_DEVICE.get("username") or ""
|
||
pwd = request.password or CMTS_DEVICE.get("password") or ""
|
||
|
||
background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths, request.config_type)
|
||
return {"status": "started"}
|
||
|
||
# ==========================================
|
||
# 🌟 4. 清除快取 API
|
||
# ==========================================
|
||
@router.post("/clear_cache")
|
||
async def clear_specific_cache(host: str, config_type: str = "running", paths_to_clear: list = Body(...)):
|
||
cleared_count = 0
|
||
|
||
# 1. 嘗試清除 DB
|
||
if USE_DB:
|
||
try:
|
||
db_cleared = await database.delete_leaf_options(host, config_type, paths_to_clear)
|
||
if db_cleared >= 0:
|
||
cleared_count = db_cleared
|
||
else:
|
||
print("⚠️ [Fallback] 資料庫清除失敗,自動切換至 JSON 快取清除...")
|
||
except Exception as e:
|
||
print(f"⚠️ [Fallback] 資料庫清除發生例外: {e},自動切換至 JSON 快取清除...")
|
||
|
||
# 2. 清除 JSON (如果 DB 沒清掉,或 USE_DB=False,或者是連同舊檔一起清確保乾淨)
|
||
cache_file = get_cache_file(host, config_type)
|
||
|
||
if not os.path.exists(cache_file):
|
||
# 如果是走 DB,且有清掉,回傳 DB 的結果
|
||
if USE_DB and cleared_count > 0:
|
||
return {"status": "success", "cleared_count": cleared_count}
|
||
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
|
||
|
||
try:
|
||
with open(cache_file, "r", encoding="utf-8") as f:
|
||
cache_data = json.load(f)
|
||
|
||
json_cleared_count = 0
|
||
for path in paths_to_clear:
|
||
if path in cache_data:
|
||
del cache_data[path]
|
||
json_cleared_count += 1
|
||
|
||
if json_cleared_count > 0:
|
||
with open(cache_file, "w", encoding="utf-8") as f:
|
||
json.dump(cache_data, f, ensure_ascii=False, indent=4)
|
||
|
||
# 回傳較大的那個數值
|
||
final_count = max(cleared_count, json_cleared_count)
|
||
return {"status": "success", "cleared_count": final_count}
|
||
|
||
except Exception as e:
|
||
# 確保回傳標準 JSON 格式
|
||
return {"status": "error", "message": f"資料庫連線與快取存取皆失敗: {str(e)}"}
|
||
|