================================================================================
PROJECT SOURCE CODE EXPORT (FULL)
================================================================================
================================================================================
FILE: .env.example
================================================================================
# --- .env.example ---
# Database Configuration (請填入你的本地端或正式機設定)
DB_NAME=cmts_nms
DB_USER=
DB_PASS=
DB_HOST=127.0.0.1
DB_PORT=5432
# God Mode Secret
GOD_MODE_SECRET=
# Default CMTS Device
DEFAULT_CMTS_HOST=
DEFAULT_CMTS_USER=
DEFAULT_CMTS_PASS=
================================================================================
FILE: .env
================================================================================
# --- .env ---
# Database Configuration
DB_NAME=cmts_nms
DB_USER=swpa
DB_PASS=swpa4920
DB_HOST=127.0.0.1
DB_PORT=5432
# God Mode Secret
GOD_MODE_SECRET=swpa@Serc0mm
# Default CMTS Device (本地開發用,正式環境可留空)
DEFAULT_CMTS_HOST=10.14.110.4
DEFAULT_CMTS_USER=admin
DEFAULT_CMTS_PASS=nsgadmin
================================================================================
FILE: database.py
================================================================================
import asyncpg
import json
import uuid
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
from typing import Dict, List, Optional, Any
from logger import get_logger
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
# ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版)
# ==========================================
logger = get_logger("app.database")
# 🌟 2. 同步改用 os.getenv 讀取環境變數
DB_CONFIG = {
"database": os.getenv("DB_NAME", "cmts_nms"),
"user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": os.getenv("DB_HOST", "127.0.0.1"),
"port": os.getenv("DB_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, path: str, data: dict) -> bool:
pool = await get_pool()
if not pool: return False
query = """
INSERT INTO cmts_options (host, path, data)
VALUES ($1, $2, $3)
ON CONFLICT (host, path)
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, path, json.dumps(data))
return True
except Exception as e:
logger.error(f"❌ DB Error (upsert_leaf_option): {e}")
return False
async def get_all_leaf_options(host: str) -> Optional[Dict[str, Any]]:
pool = await get_pool()
if not pool: return None
query = "SELECT path, data FROM cmts_options WHERE host = $1;"
try:
async with pool.acquire() as conn:
records = await conn.fetch(query, host)
result = {}
for record in records:
data_val = record['data']
result[record['path']] = json.loads(data_val) if isinstance(data_val, str) else data_val
return result
except Exception as e:
logger.error(f"❌ DB Error (get_all_leaf_options): {e}")
return None
async def delete_leaf_options(host: 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 path = ANY($2);"
try:
async with pool.acquire() as conn:
status = await conn.execute(query, host, paths)
return int(status.split()[-1])
except Exception as e:
logger.error(f"❌ DB Error (delete_leaf_options): {e}")
return -1
# ------------------------------------------
# CRUD Functions for device_status
# ------------------------------------------
async def upsert_device_status(host: str, metadata: dict) -> bool:
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:
if cmts_version and cmts_version != "unknown":
query = """
INSERT INTO device_status (host, cmts_version, last_scanned)
VALUES ($1, $2, $3)
ON CONFLICT (host)
DO UPDATE SET cmts_version = EXCLUDED.cmts_version, last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, cmts_version, last_scanned)
else:
query = """
INSERT INTO device_status (host, last_scanned)
VALUES ($1, $2)
ON CONFLICT (host)
DO UPDATE SET last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, 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) -> Optional[Dict[str, Any]]:
pool = await get_pool()
if not pool: return None
query = "SELECT cmts_version, last_scanned FROM device_status WHERE host = $1;"
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, host)
if record:
return {"cmts_version": record["cmts_version"], "last_scanned": record["last_scanned"]}
return None
except Exception as e:
logger.error(f"❌ DB 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
def debug_print(msg: str):
if DEBUG_MODE:
print(msg)
# 🌟 2. 淨化預設的 CMTS 連線樣板
CMTS_DEVICE = {
'device_type': 'cisco_ios',
'host': os.getenv("DEFAULT_CMTS_HOST", ""),
'username': os.getenv("DEFAULT_CMTS_USER", ""),
'password': os.getenv("DEFAULT_CMTS_PASS", ""),
'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
================================================================================
Harmonic CMTS Manager
🎙️ Harmonic CMTS Admin
⚙️ 全域系統設定 (God Mode Filters)
請勾選您希望在「完整設備配置樹狀圖」中 隱藏 的設定項目。
💡 點擊下方按鈕載入設備實時配置,您可以深入展開並勾選任何層級的節點進行過濾。
⏳ 正在抓取配置,請稍候...
✅ 設定已成功儲存!
🎛️ 伺服器日誌管控 (Server Log Management)
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,無需重啟伺服器。
💡 建議平時保持在 INFO 或 ERROR,僅在需要排查問題時開啟 DEBUG。
⏳ 正在載入日誌設定...
| 時間 |
快照名稱 |
描述 |
類型 |
操作 |
| 請點擊「重新整理」載入歷史紀錄... |
================================================================================
FILE: init_db.py
================================================================================
import asyncio
import asyncpg
import logging
import sys
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 🌟 2. 改用 os.getenv 讀取環境變數,並保留原本的設定作為安全預設值
DB_CONFIG = {
"database": os.getenv("DB_NAME", "cmts_nms"),
"user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": os.getenv("DB_HOST", "127.0.0.1"),
"port": os.getenv("DB_PORT", "5432")
}
async def init_database(force_reset: bool = False):
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"])
)
# ==========================================
# 💣 核彈模式:清除舊有資料表
# ==========================================
if force_reset:
logger.warning("⚠️ 警告:啟動強制重建模式,正在刪除現有資料表...")
await conn.execute("DROP TABLE IF EXISTS cmts_options;")
await conn.execute("DROP TABLE IF EXISTS device_status;")
await conn.execute("DROP TABLE IF EXISTS system_filters;")
# 💡 備份表通常極度重要,即使 reset 也不建議輕易 DROP,除非你確定要連備份一起砍
# await conn.execute("DROP TABLE IF EXISTS config_backups;")
logger.info("🗑️ 舊資料表已清除完畢。")
# ==========================================
# 🏗️ 安全建置模式:建立資料表 (IF NOT EXISTS)
# ==========================================
# 1. 建立選項快取表 cmts_options (無 config_type)
logger.info("🛠️ 正在檢查/建立 cmts_options 資料表...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS cmts_options (
host VARCHAR(255) NOT NULL,
path VARCHAR(500) NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (host, path)
);
""")
# 2. 建立設備狀態表 device_status (無 config_type)
logger.info("🛠️ 正在檢查/建立 device_status 資料表...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS device_status (
host VARCHAR(255) NOT NULL PRIMARY KEY,
cmts_version VARCHAR(100) DEFAULT 'unknown',
last_scanned VARCHAR(100),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 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
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),
description TEXT DEFAULT '',
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__":
# 偵測命令列參數是否包含 --reset
is_reset = "--reset" in sys.argv
if is_reset:
print("\n" + "="*50)
print("🚨 你正在執行資料庫重置 (--reset) 🚨")
print("這將會清空所有的選項快取與系統設定!")
print("="*50 + "\n")
confirm = input("確定要繼續嗎?(輸入 yes 繼續): ")
if confirm.lower() != "yes":
print("已取消操作。")
sys.exit(0)
asyncio.run(init_database(force_reset=is_reset))
================================================================================
FILE: cmts_scraper.py
================================================================================
# --- cmts_scraper.py ---
import asyncio
import asyncssh
import re
import json
import os
import time
import database
from logger import get_logger
logger = get_logger("app.scraper")
def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,無差別掃描支援所有標題排列組合"""
hint_lines = []
options = []
current_value = None
format_desc = None
is_format_only = False
has_bracket_value = False
if "dhcp-relay" in output.lower():
is_format_only = True
format_desc = "IP address or options"
for line in output.splitlines():
line = line.strip()
# ==========================================
# 🛡️ 1. 終極雜訊與 Echo 過濾器
# ==========================================
if not line or line.startswith('%') or line.startswith('^'):
continue
# 攔截 Echo 的問號指令 (例如 "logging buffered ?")
if line.endswith('?'):
continue
# 攔截終端機 Prompt (例如 "admin@SERCOMM-COS-02(config)# ..." 或 "admin@SERCOMM-COS-02>")
if re.search(r"[a-zA-Z0-9_.@-]+\(.*\)#", line) or re.search(r"^[a-zA-Z0-9_.@-]+>", line):
continue
# ==========================================
# 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看)
hint_lines.append(line)
# 3. 略過純標題行,不進行選項解析
if line.startswith('Description:') or line.startswith('Possible completions:'):
continue
# 4. 解析格式與選項 (無差別掃描每一行)
# 格式 A: <格式說明>[當前值] (例如: [Cold Start])
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
# 格式 B: 型態, 最小值 .. 最大值 (例如: unsignedInt, 1 .. 3000)
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
# 格式 C: 純文字關鍵字
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
# 格式 D: 選項列表 [現值] 選項1 選項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()
# 分離選項與說明 (完美支援水平列表,如 severity size-mb)
parts = re.split(r'\s{2,}', clean_line)
valid_options = []
for p in parts:
p = p.strip()
if not p: continue
# 如果這個片段包含空白,代表它是說明文字 (Description),停止解析後續片段
if ' ' in p:
break
valid_options.append(p)
for p in valid_options:
if p not in ["|", ".."]:
options.append(p)
# 子命令防呆:如果抓到一堆單字,但沒有 [現值],且不是已知格式,很可能是子命令列表
if 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)
return {
"hint": "\n".join(hint_lines).strip(),
"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:
# 完美支援 () (Cold Start): 格式
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()}
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
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 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()
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError:
break
return output
# 🌟 關鍵修復 1:等待登入歡迎詞 (MOTD) 結束,確保設備準備好接收指令
await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
if cmts_version == "unknown":
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|CableOS)\s+([\w\.\-]+)", version_output, re.IGNORECASE)
if match:
cmts_version = match.group(1)
else:
cmts_version = "parse_failed" # 🌟 避免正則失敗導致每批次都重查
# 🌟 關鍵修復 2:確保成功進入 config 模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config.*\)#")
for path in batch:
await asyncio.sleep(0.01)
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
output_question = await read_until_quiet(timeout=0.8)
q_data = parse_question_mark_output(output_question)
# 🌟 頂級優雅解法:使用 Ctrl+U (\x15) 瞬間清空整行輸入緩衝區
process.stdin.write("\x15")
await process.stdin.drain()
await read_until_quiet(timeout=0.2)
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")
}
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=0.8)
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":
# 如果進入了互動式輸入 (例如 prompt 變成 (val): ),按 Enter 接受預設值並退出
process.stdin.write("\n")
await process.stdin.drain()
else:
# 如果只是印出錯誤,我們不需要做什麼
pass
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
processed_count += 1
yield json.dumps({
"event": "progress",
"current": processed_count,
"total": total_paths,
"current_path": path
}) + "\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
try:
current_ts = int(time.time())
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
# 寫入資料庫
if cmts_version not in ["unknown", "parse_failed"]:
await database.upsert_device_status(host, {"cmts_version": cmts_version, "last_scanned": formatted_time})
else:
await database.upsert_device_status(host, {"last_scanned": formatted_time})
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
await database.upsert_leaf_option(host, p, result_data[p])
logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已寫入資料庫!")
except Exception as e:
logger.error(f"⚠️ 資料庫寫入失敗: {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 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()
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError:
break
return output
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
if config_type == "full":
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
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\)#")
process.stdin.write("exit\n")
await process.stdin.drain()
else:
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()
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
lines = cleaned_output.splitlines()
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)}")
def parse_config_to_tree(raw_cli: str) -> dict:
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
from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入
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)
# 🌟 新增:全域安全與快取 Middleware
class SecurityAndCacheMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
# 1. 補上安全性標頭 (圖片 7 的警告)
response.headers["X-Content-Type-Options"] = "nosniff"
# 2. 針對靜態檔案補上 Cache-Control (圖片 2 的警告)
# 讓 JS/CSS 快取 1 小時 (3600秒),提升前端載入效能
if request.url.path.startswith("/static/"):
response.headers["Cache-Control"] = "public, max-age=3600"
else:
# API 請求不快取,確保拿到最新資料
response.headers["Cache-Control"] = "no-store"
return response
app.add_middleware(SecurityAndCacheMiddleware) # 🌟 掛載 Middleware
# 掛載靜態檔案目錄 (對應 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 高可用性架構升級
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
### Phase 2: 設備配置備份與快照機制
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表。
- [x] **前端 UI 優化與防禦性編程**:實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
### Phase 3: 智慧差異還原與歷史預覽
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應),並具備 Fail-safe `abort` 撤銷機制。
### Phase 3.5: 企業級前端效能重構 (Extreme Performance Optimization)
- [x] **記憶體遞迴渲染 (In-Memory Recursive Rendering)**:徹底重構 `tree-ui.js`,將數千次 DOM 寫入壓縮為單次 `innerHTML` 寫入,解決「展開全部」導致瀏覽器卡死的問題。
- [x] **非同步 UI 保護機制**:在所有大型渲染場景 (初始載入、單點展開、全部展開) 導入 `setTimeout` 讓出主執行緒,並搭配沙漏游標與橘色讀取提示,確保 UI 絕對滑順。
- [x] **CSS 渲染隔離**:導入 `content-visibility: auto`,讓不在可視範圍內的 DOM 節點暫停渲染計算。
- [x] **精準 DOM 查詢**:將鎖定狀態輪詢 (`startLockStatusPolling`) 的搜尋範圍限縮於當前啟用的視圖內,消除全域搜尋造成的卡頓 (Jank)。
---
## 🚀 即將到來的里程碑 (Upcoming Milestones Summary)
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
- [x] **智能視覺診斷 (Visual Diagnostics)**:
- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**:
- 重新 Review `generate_diff_commands` 演算法。
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)**:
- 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。
- 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。
- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)**:
- **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
- [ ] **RPD 樣板克隆引擎 (Template Cloning)**:
- 於「MAC Domain 狀態感知配置精靈」中新增 RPD 擴容模式。允許使用者選擇現有設備上已配置完成的 RPD (支援 FDX, FDD, D3.1+) 作為基準樣板。
- [ ] **Tree 節點複製與參數替換 (Node Duplication & Modification)**:
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
- [ ] **安全寫入與校驗 (Safe Provisioning)**:
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
---
## 🐛 已知問題與技術債 (Known Issues & Tech Debt)
- 目前系統運行極度穩定,前端效能瓶頸已徹底消除,各項併發鎖定與 UI 狀態連動皆已完善。準備進入 Phase 4 的 Diff 引擎強化開發。
================================================================================
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();
}
export async function apiGetLeafOptions(host) {
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}`);
return response.json();
}
export async function apiSyncLeafOptions(host, paths) {
return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths })
});
}
export async function apiClearCache(host, paths) {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}`, {
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) {
try {
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}`);
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 可以即時更新)
// ==========================================
export async function apiSyncLeafOptionsStream(host, paths, onProgress) {
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths })
});
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 });
const lines = chunk.split("\n").filter(line => line.trim() !== "");
for (const line of lines) {
try {
const data = JSON.parse(line);
onProgress(data);
} catch (e) {}
}
}
}
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,
apiGetLeafOptions, // 🌟 補上這個
apiSyncLeafOptions // 🌟 補上這個
} 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';
// ============================================================================
// 🌟 全域記憶體 (Dual-Track Memory)
// ============================================================================
window.treeDataStore = { running: null, full: null };
// ============================================================================
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
// ============================================================================
let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
// 初始化全域 SSE 監聽器 (設備級別)
function initGlobalSSE() {
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
if (!isConnected) {
if (globalSSE) {
globalSSE.close();
globalSSE = null;
}
return;
}
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
if (globalSSE) globalSSE.close();
// 🌟 拔除 config_type,純粹監聽該設備的廣播
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}`);
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
const statusEl = document.getElementById('scan-status');
if (data.event === 'scan_start' || data.event === 'progress') {
setAdminButtonsState(true, "⏳ 系統更新中...");
}
if (data.event === 'scan_start') {
if (statusEl) {
statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'progress') {
if (statusEl) {
statusEl.innerHTML = `⏳ 背景掃描進度:${data.current} / ${data.total} (正在處理: ${data.current_path})`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'done') {
if (statusEl) {
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
}
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
else if (data.event === 'error') {
if (statusEl) {
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c";
}
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
};
}
// 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線
window.addEventListener('beforeunload', () => {
if (globalSSE) {
globalSSE.close();
globalSSE = null;
}
});
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 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 () => {
if (!isConnected) return; // 🌟 新增防線:未連線時不進行輪詢
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}`;
// 🛡️ 防閃爍機制
if (window.recentlyReleasedLocks[lockKey]) {
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
continue;
} else {
delete window.recentlyReleasedLocks[lockKey];
}
}
newLockState.add(lockKey);
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 效能優化:只在當前啟用的視圖中搜尋,避免掃描隱藏的龐大 DOM
const currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (activeContainer) {
const targetBtns = activeContainer.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) {
btn.title = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
} 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 currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (activeContainer) {
const targetBtns = activeContainer.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 = ""; // 恢復預設背景色
}
}
// 🌟 新增:將陣列轉換為