Compare commits

..

10 Commits

Author SHA1 Message Date
swpa fd36ca2fad chore: update .gitignore and ignore cache 2026-05-21 14:41:22 +08:00
swpa 5851166112 feat: 1.PostgreSQL 高可用性架構升級, 2.導入continue.dev與Roo Code偕同開發模式。 2026-05-20 17:19:40 +08:00
swpa 9585a4c958 feat: finalize JSON cache architecture and prepare for PostgreSQL migration
- 穩定並完善現有基於 JSON 檔案的 running/full 雙軌快取機制
- 確認 __metadata__ (版本與掃描時間) 的讀寫邏輯運作正常
- 確立下一階段 PostgreSQL + asyncpg 的升級藍圖與 Schema 設計
- 準備建立 feature/postgres-migration 分支進行資料庫無痛轉移
2026-05-19 18:25:10 +08:00
swpa 7b5337383b fix: To resolve SSE (Server-Sent Events) issue "Waiting for connections to close." 2026-05-18 15:40:19 +08:00
swpa df90aa50fe Merge branch 'enhancement/ux' 2026-05-18 15:12:20 +08:00
swpa d7d6e63a7c enha: 修改進度條為全廣播機制 2026-05-18 15:10:38 +08:00
swpa 0afac3831c fix: 修正深度過濾攔截器辨識符號 2026-05-18 11:51:23 +08:00
swpa f86a449642 enha: debug tool in god-mode 2026-05-15 17:25:09 +08:00
swpa bee63b78ec feat: 1.重構快取掃描邏輯,新增局部與全域操作分離, 2.GUI按鈕格規格統一化。 2026-05-15 15:17:28 +08:00
swpa 40a8227b45 feat: 編輯流程優化 2026-05-14 16:30:10 +08:00
27 changed files with 151592 additions and 34846 deletions

16
.clineignore Normal file
View File

@ -0,0 +1,16 @@
# 1. Python 虛擬環境與快取 (吃錢最大凶手,絕對不要讓 AI 讀!)
cmts_api_env/
__pycache__/
*.pyc
# 2. 其他 AI 工具的隱藏資料夾
.continue/
# 3. 動態生成的 JSON 快取資料 (非常耗 Token且對 AI 寫程式幫助不大)
*_cache.json
filters_*.json
*.bk
# 4. 版控與系統隱藏檔 (如果有這兩個的話)
.git/
.vscode/

41
.clinerules Normal file
View File

@ -0,0 +1,41 @@
# Harmonic CMTS Manager - AI 開發守則與架構白皮書
## 1. 專案架構概覽與技術棧
- **定位**: 專為有線電視網路終端設備 (CMTS) 設計的企業級 Web 管理系統。
- **後端**: Python 3.10+, FastAPI (Async-first), AsyncSSH, Netmiko, asyncpg.
- **前端**: 原生 Vanilla JS (ES Modules), HTML5, CSS3, Xterm.js.
- **資料庫**: PostgreSQL (主要), JSON File Cache (高可用性降級備援).
### 📂 目錄與檔案結構
- **進入點**: `main.py`
- **路由管理**: API 路由統一放置於 `routers/` 目錄。
- **前端介面**: `index.html` 與 `static/` 目錄。
- **核心邏輯**:
- `cmts_scraper.py`: 負責底層爬蟲與資料處理。
- `shared.py`: 放置共用函式 (如兩階段解析法)。
- `config.py`: 配置轉譯器 (CLI Generator)。
- **資料庫**: 連線與 ORM 邏輯在 `database.py`,初始化腳本為 `init_db.py`。
- **併發控制**: `lock.py` (路徑階層鎖), `leaf_options.py` (SSE 頻道分流)。
## 2. 核心架構與業務邏輯 (Architecture & Logic)
- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。
- **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `BATCH_SIZE = 30` 搭配 `asyncio.sleep()` 進行非同步批次處理。
- **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。
- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。
## 3. 🚨 AI 開發絕對約束 (Directives for AI)
### ⚙️ 系統與環境規範
1. **套件管理**: 若需安裝新套件,請提醒我手動在 `cmts_api_env` 中安裝,並更新 `requirements.txt`。
2. **資料讀取限制**: 請勿隨意讀取 `*_cache.json` 檔案的內容,若需了解資料結構,請參考 `cmts_scraper.py` 中的定義。
3. **狀態同步**: 完成重大修改或一個 Phase 後,必須主動更新 `PROJECT_STATE.md` 記錄最新進度與待辦事項。
### 💻 程式碼風格與後端規範
4. **語言與風格**: 註解與對話請一律使用**繁體中文**。Python 程式碼請遵循 PEP8 規範,並加上適當的 Type Hints (型別提示)。
5. **Async-First (非同步絕對優先)**: 嚴禁使用阻塞的同步 I/O。同步函數必須封裝進 `await asyncio.to_thread()`。
6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。
### 🎨 前端規範
9. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。

View File

@ -0,0 +1,41 @@
# Harmonic CMTS Manager - AI 開發守則與架構白皮書
## 1. 專案架構概覽與技術棧
- **定位**: 專為有線電視網路終端設備 (CMTS) 設計的企業級 Web 管理系統。
- **後端**: Python 3.10+, FastAPI (Async-first), AsyncSSH, Netmiko, asyncpg.
- **前端**: 原生 Vanilla JS (ES Modules), HTML5, CSS3, Xterm.js.
- **資料庫**: PostgreSQL (主要), JSON File Cache (高可用性降級備援).
### 📂 目錄與檔案結構
- **進入點**: `main.py`
- **路由管理**: API 路由統一放置於 `routers/` 目錄。
- **前端介面**: `index.html``static/` 目錄。
- **核心邏輯**:
- `cmts_scraper.py`: 負責底層爬蟲與資料處理。
- `shared.py`: 放置共用函式 (如兩階段解析法)。
- `config.py`: 配置轉譯器 (CLI Generator)。
- **資料庫**: 連線與 ORM 邏輯在 `database.py`,初始化腳本為 `init_db.py`
- **併發控制**: `lock.py` (路徑階層鎖), `leaf_options.py` (SSE 頻道分流)。
## 2. 核心架構與業務邏輯 (Architecture & Logic)
- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。
- **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `BATCH_SIZE = 30` 搭配 `asyncio.sleep()` 進行非同步批次處理。
- **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。
- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。
## 3. 🚨 AI 開發絕對約束 (Directives for AI)
### ⚙️ 系統與環境規範
1. **套件管理**: 若需安裝新套件,請提醒我手動在 `cmts_api_env` 中安裝,並更新 `requirements.txt`
2. **資料讀取限制**: 請勿隨意讀取 `*_cache.json` 檔案的內容,若需了解資料結構,請參考 `cmts_scraper.py` 中的定義。
3. **狀態同步**: 完成重大修改或一個 Phase 後,必須主動更新 `PROJECT_STATE.md` 記錄最新進度與待辦事項。
### 💻 程式碼風格與後端規範
4. **語言與風格**: 註解與對話請一律使用**繁體中文**。Python 程式碼請遵循 PEP8 規範,並加上適當的 Type Hints (型別提示)。
5. **Async-First (非同步絕對優先)**: 嚴禁使用阻塞的同步 I/O。同步函數必須封裝進 `await asyncio.to_thread()`
6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。
### 🎨 前端規範
9. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
__pycache__/ __pycache__/
*.pyc *.pyc
cmts_api_env/ cmts_api_env/
*_cache.json
filters_*.json
*.bk

28
PROJECT_STATE.md Normal file
View File

@ -0,0 +1,28 @@
# 📖 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 檔案讀寫,防止 FastAPI Crash。
- [x] 調整 `init_db.py` 為非同步啟動腳本。
---
## 🚧 目前開發階段 (Current Phase)
### Phase 2: 設備配置備份與快照機制 (Configuration Backup & Snapshots)
- [ ] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (核心欄位需包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`)。
- [ ] **後端 API 實作**:新增「手動建立快照 (Manual Snapshot)」與「取得歷史快照列表」的 RESTful API 路由。
- [ ] **自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。
---
## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
- **[未來計畫] Phase 3: 歷史預覽 (History Preview)**:前端 UI 支援點擊歷史快照,將 `parsed_tree` 載入 `tree-ui.js` 並強制標示為「唯讀模式 (Read-only)」。
- **[未來計畫] Phase 4: 智慧還原 (Smart Recovery)**:實作 Diff-based Rollback 演算法。比對歷史備份與當前樹狀結構,自動產生反向 CLI 指令 (`no [指令]`),並在推送到設備前提供 Dry-Run 預覽確認視窗。

File diff suppressed because it is too large Load Diff

111987
cmts_leaf_options_cache.json.bk Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,8 @@ import re
import json import json
import os import os
import time import time
import database
from shared import USE_DB
def parse_question_mark_output(output: str) -> dict: def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位""" """解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
@ -142,7 +144,8 @@ def parse_device_response(output: str) -> dict:
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()} return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list): # 🌟 1. 函數簽名加上 config_type 參數,預設為 "running"
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
try: try:
total_paths = len(leaf_paths) total_paths = len(leaf_paths)
processed_count = 0 processed_count = 0
@ -150,6 +153,8 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
BATCH_SIZE = 30 BATCH_SIZE = 30
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)] 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): for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
conn = None conn = None
@ -172,11 +177,23 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
break break
return output return output
# 🌟 新增:在第一批次連線時,先抓取設備版本
if cmts_version == "unknown":
process.stdin.write("show version\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5)
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match:
cmts_version = match.group(1)
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5) await read_until_quiet(timeout=1.5)
for path in batch: for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 --- # --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?" command_to_send = f"{path} ?"
process.stdin.write(command_to_send) process.stdin.write(command_to_send)
@ -198,14 +215,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
} }
# --- 步驟 2判斷是否需要發送 Enter --- # --- 步驟 2判斷是否需要發送 Enter ---
# 🌟 配合新規則:如果 '?' 已經明確告訴我們這是 <格式> 欄位,直接跳過 Enter
if q_data.get("is_format_only"): if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number" parsed_data["type"] = "string_or_number"
# if q_data.get("format_desc"):
# parsed_data["hint"] += f"\nFormat: {q_data['format_desc']}"
elif not q_data["options"]: elif not q_data["options"]:
# 原本的 Enter 邏輯
process.stdin.write(f"{path}\n") process.stdin.write(f"{path}\n")
await process.stdin.drain() await process.stdin.drain()
@ -216,9 +229,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
parsed_data["options"] = enter_data["options"] parsed_data["options"] = enter_data["options"]
parsed_data["current_value"] = enter_data["current_value"] parsed_data["current_value"] = enter_data["current_value"]
# if "format_desc" in enter_data:
# parsed_data["hint"] += f"\nFormat: {enter_data['format_desc']}"
if enter_data["type"] != "unknown": if enter_data["type"] != "unknown":
process.stdin.write("\x03") process.stdin.write("\x03")
await process.stdin.drain() await process.stdin.drain()
@ -231,7 +241,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
result_data[path] = parsed_data result_data[path] = parsed_data
# 🌟 核心修改:每處理完一個路徑,就推播一次進度!
processed_count += 1 processed_count += 1
event_data = { event_data = {
"event": "progress", "event": "progress",
@ -251,24 +260,75 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
finally: finally:
if conn: conn.close() if conn: conn.close()
# --- 步驟 3寫入快取 (DB or JSON) ---
try: try:
cache_file = "cmts_leaf_options_cache.json" db_success = False
cache_data = {}
if os.path.exists(cache_file):
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
current_ts = int(time.time()) current_ts = int(time.time())
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
# 1. 嘗試寫入資料庫
if USE_DB:
try:
# 寫入 metadata
if cmts_version != "unknown":
await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
else:
await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time})
# 寫入 options
db_write_count = 0
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
if success:
db_write_count += 1
if db_write_count > 0:
db_success = True
print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
else:
print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
# 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON
if not db_success:
# 🌟 動態決定快取檔名 (加入 IP 隔離)
safe_host = host.replace(".", "_")
cache_file = f"{safe_host}_{config_type}_cache.json"
def read_json_from_file(filepath):
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
return {}
cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
# 🌟 新增:寫入 Metadata (版本與掃描時間)
if "__metadata__" not in cache_data:
cache_data["__metadata__"] = {}
if cmts_version != "unknown":
cache_data["__metadata__"]["cmts_version"] = cmts_version
cache_data["__metadata__"]["last_scanned"] = formatted_time
for p in batch: for p in batch:
if p in result_data: if p in result_data:
result_data[p]["updated_at"] = current_ts result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p] cache_data[p] = result_data[p]
with open(cache_file, "w", encoding="utf-8") as f: def write_json_to_file(filepath, data):
json.dump(cache_data, f, indent=4, ensure_ascii=False) with open(filepath, "w", encoding="utf-8") as f:
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已提早寫入快取檔!") json.dump(data, f, indent=4, ensure_ascii=False)
await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
print(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
except Exception as e: except Exception as e:
print(f"⚠️ 提早寫入快取失敗: {e}") print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
if batch_idx < len(batches) - 1: if batch_idx < len(batches) - 1:
await asyncio.sleep(2) await asyncio.sleep(2)
@ -277,3 +337,4 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
except Exception as e: except Exception as e:
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n" yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"

243
database.py Normal file
View File

@ -0,0 +1,243 @@
import asyncpg
import json
import logging
from typing import Dict, List, Optional, Any
# ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版)
# ==========================================
logger = logging.getLogger(__name__)
# DB 設定 (從您的 init_db.py 中提取)
DB_CONFIG = {
"database": "cmts_nms",
"user": "swpa",
"password": "swpa4920",
"host": "127.0.0.1",
"port": "5432"
}
_pool: Optional[asyncpg.Pool] = None
async def init_db_pool():
"""初始化非同步連線池"""
global _pool
try:
if _pool is None:
_pool = await asyncpg.create_pool(
database=DB_CONFIG["database"],
user=DB_CONFIG["user"],
password=DB_CONFIG["password"],
host=DB_CONFIG["host"],
port=int(DB_CONFIG["port"]),
min_size=1,
max_size=10
)
logger.info("✅ PostgreSQL Connection Pool Initialized.")
except Exception as e:
logger.error(f"❌ Failed to initialize DB Pool: {e}")
async def close_db_pool():
"""關閉非同步連線池"""
global _pool
if _pool:
await _pool.close()
_pool = None
logger.info("🔌 PostgreSQL Connection Pool Closed.")
async def get_pool() -> Optional[asyncpg.Pool]:
if _pool is None:
await init_db_pool()
return _pool
# ------------------------------------------
# CRUD Functions for cmts_options
# ------------------------------------------
async def upsert_leaf_option(host: str, config_type: str, path: str, data: dict) -> bool:
"""將選項寫入或更新至資料庫"""
pool = await get_pool()
if not pool:
return False
query = """
INSERT INTO cmts_options (host, config_type, path, data)
VALUES ($1, $2, $3, $4)
ON CONFLICT (host, config_type, path)
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, config_type, path, json.dumps(data))
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_leaf_option): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_leaf_option): {e}")
return False
async def get_all_leaf_options(host: str, config_type: str) -> Optional[Dict[str, Any]]:
"""取得特定 host 與 config_type 的所有選項"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT path, data FROM cmts_options
WHERE host = $1 AND config_type = $2;
"""
try:
async with pool.acquire() as conn:
records = await conn.fetch(query, host, config_type)
result = {}
for record in records:
# asyncpg returns strings for JSON if not explicitly configured with type mapping,
# but usually it's fine to just json.loads it.
data_val = record['data']
if isinstance(data_val, str):
result[record['path']] = json.loads(data_val)
else:
result[record['path']] = data_val
return result
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_all_leaf_options): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_all_leaf_options): {e}")
return None
async def delete_leaf_options(host: str, config_type: str, paths: List[str]) -> int:
"""刪除特定路徑的選項快取"""
pool = await get_pool()
if not pool or not paths:
return -1
query = """
DELETE FROM cmts_options
WHERE host = $1 AND config_type = $2 AND path = ANY($3);
"""
try:
async with pool.acquire() as conn:
# Execute returns the command tag, e.g., 'DELETE 5'
status = await conn.execute(query, host, config_type, paths)
deleted_count = int(status.split()[-1])
return deleted_count
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (delete_leaf_options): {e}")
return -1
except Exception as e:
logger.error(f"❌ Unknown Error (delete_leaf_options): {e}")
return -1
# ------------------------------------------
# CRUD Functions for device_status
# ------------------------------------------
async def upsert_device_status(host: str, config_type: str, metadata: dict) -> bool:
"""更新設備的 metadata"""
pool = await get_pool()
if not pool:
return False
# Extract known fields
cmts_version = metadata.get("cmts_version", "unknown")
last_scanned = metadata.get("last_scanned", None)
query = """
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
VALUES ($1, $2, $3, $4)
ON CONFLICT (host, config_type)
DO UPDATE SET
cmts_version = EXCLUDED.cmts_version,
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, config_type, cmts_version, last_scanned)
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
return False
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
"""獲取設備 metadata"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT cmts_version, last_scanned FROM device_status
WHERE host = $1 AND config_type = $2;
"""
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, host, config_type)
if record:
return {
"cmts_version": record["cmts_version"],
"last_scanned": record["last_scanned"]
}
return None
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_device_status): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_device_status): {e}")
return None
# ------------------------------------------
# CRUD Functions for system_filters (Tree Filters)
# ------------------------------------------
async def upsert_tree_filters(config_type: str, hidden_keys: List[str]) -> bool:
"""寫入全域的樹狀圖隱藏節點名單"""
pool = await get_pool()
if not pool:
return False
# Use 'global' as a dummy host to keep schema simple if needed,
# but a dedicated system_filters table is better.
query = """
INSERT INTO system_filters (config_type, hidden_keys)
VALUES ($1, $2)
ON CONFLICT (config_type)
DO UPDATE SET hidden_keys = EXCLUDED.hidden_keys, updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, config_type, hidden_keys)
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_tree_filters): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_tree_filters): {e}")
return False
async def get_tree_filters(config_type: str) -> Optional[List[str]]:
"""讀取全域的樹狀圖隱藏節點名單"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT hidden_keys FROM system_filters
WHERE config_type = $1;
"""
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, config_type)
if record:
return record["hidden_keys"]
return []
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_tree_filters): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_tree_filters): {e}")
return None

3
filters_full.json Normal file
View File

@ -0,0 +1,3 @@
{
"hidden_keys": []
}

3
filters_running.json Normal file
View File

@ -0,0 +1,3 @@
{
"hidden_keys": []
}

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Harmonic CMTS Manager</title> <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" /> <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@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/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
@ -23,8 +24,8 @@
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" 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;"> <div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
<button onclick="connectWebSocket()" id="btnConnect" style="background-color: #27ae60;">連線至 CMTS</button> <button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
<button onclick="disconnectWebSocket()" id="btnDisconnect" style="background-color: #c0392b; display: none;">斷開連線</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> <span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
</div> </div>
</div> </div>
@ -42,14 +43,14 @@
<div class="control-group"> <div class="control-group">
<label><strong>快捷指令:</strong></label> <label><strong>快捷指令:</strong></label>
<select id="fixedCmd"> <select id="fixedCmd">
<option value="show cable modem">數據機狀態 (show cable modem)</option> <option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
<option value="show cable rpd">RPD 狀態 (show cable rpd)</option> <option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option> <option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
</select> </select>
<button onclick="injectCommand()" style="background-color: #8e44ad;">送出指令</button> <button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
<div style="margin-left: auto; display: flex; gap: 8px;"> <div style="margin-left: auto; display: flex; gap: 8px;">
<button onclick="downloadTerminal()" style="background-color: #7f8c8d;">💾 下載紀錄</button> <button onclick="downloadTerminal()" class="btn-modern btn-slate">💾 下載紀錄</button>
</div> </div>
</div> </div>
@ -109,7 +110,7 @@
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button onclick="executeQuery('Cm')">執行查詢</button> <button onclick="executeQuery('Cm')" class="btn-modern btn-load">執行查詢</button>
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;"> <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> <label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
@ -144,7 +145,7 @@
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button onclick="executeQuery('Rpd')" style="background-color: #e67e22;">執行查詢</button> <button onclick="executeQuery('Rpd')" class="btn-modern btn-load">執行查詢</button>
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;"> <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> <label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
@ -161,10 +162,12 @@
<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;"> <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> <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;"> <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> <option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
<option value="form-full-config">🌳 完整設備配置樹狀圖</option>
</select> </select>
<button id="btn-load-task" onclick="startConfigTask()" style="background-color: #95a5a6; color: white; margin-left: 10px; cursor: not-allowed;" disabled>🚀 載入任務</button> <button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
</div> </div>
<!-- 表單 3MAC Domain 配置精靈 --> <!-- 表單 3MAC Domain 配置精靈 -->
@ -176,7 +179,7 @@
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;"> <select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<option value="">請先點擊上方載入任務...</option> <option value="">請先點擊上方載入任務...</option>
</select> </select>
<button onclick="fetchMacDomainConfig()" style="background-color: #d35400;">🔍 讀取現有配置</button> <button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span> <span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div> </div>
@ -240,30 +243,34 @@
<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 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"> <div class="form-actions">
<button id="btnRevert" onclick="revertFormChanges()" style="background-color: #7f8c8d; color: white; margin-right: 15px;"> <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> <button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
<button onclick="generateMacDomainCLI()" style="background-color: #f39c12; color: white;">⚙️ 產生絕對路徑指令預覽</button>
<button id="btnDeployConfig" onclick="executeBondingConfig()" style="background-color: #c0392b; color: white; display: none;" disabled>🚀 正式套用設定</button>
</div> </div>
</div> </div>
</div> </div>
<!-- 表單 4完整設備配置樹狀圖 --> <!-- 表單 4設備配置樹狀圖 (共用容器) -->
<div id="form-full-config" class="task-form" style="display: none;"> <!-- 🌟 修正 1將 ID 改為 form-tree-config -->
<div id="form-tree-config" class="task-form" style="display: none;">
<h3 style="display: flex; align-items: center; margin-top: 0; margin-bottom: 10px; font-size: 16px; color: #2c3e50;"> <h3 style="display: flex; align-items: center; margin-top: 0; margin-bottom: 10px; font-size: 16px; color: #2c3e50;">
🌳 完整設備配置樹狀圖
<!-- 🌟 掃描按鈕 --> <!-- 🌟 修正 2加上 span 與 ID用來動態切換文字 -->
<button id="global-scan-btn" onclick="scanAllMissingOptions()" disabled <span id="tree-form-title">🌳 設備配置樹狀圖</span>
style="display: none; padding: 6px 12px; background-color: #8e44ad; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; margin-left: 20px; transition: all 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
🔍 掃描畫面缺失選項 <!-- 🌟 掃描與清除按鈕群組 -->
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none; margin-left: 20px;">
🔍 掃描局部缺失選項
</button>
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none; margin-left: 10px;">
🗑️ 清除局部選項快取
</button> </button>
<!-- 🌟 請在這裡加入這段全新的按鈕 --> <button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none; margin-left: 10px;">
<button id="clearCacheBtn" onclick="clearVisibleCache()" 🌍 掃描全域缺失選項
style="display: none; padding: 6px 12px; background-color: #f39c12; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; margin-left: 10px; transition: all 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"> </button>
🔄 清除畫面選項快取 <button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none; margin-left: 10px;">
🔥 清除全域快取
</button> </button>
<!-- 🌟 新增:掃描進度提示 --> <!-- 🌟 新增:掃描進度提示 -->
@ -277,14 +284,13 @@
<!-- 🌟 左右分屏容器 --> <!-- 🌟 左右分屏容器 -->
<div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;"> <div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;">
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) --> <!-- 左側:樹狀圖 (雙容器實體隔離) -->
<!-- <div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; box-sizing: border-box; overflow-x: auto;"> <div id="tree-container-running" class="tree-view-instance" style="width: 100%; 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;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span> <span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
</div> --> </div>
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) --> <div id="tree-container-full" class="tree-view-instance" style="display: none; width: 100%; 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;">
<div id="tree-container" style="width: 100%; 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>
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
</div> </div>
<!-- 🖱️ 拖曳分隔線 (預設隱藏) --> <!-- 🖱️ 拖曳分隔線 (預設隱藏) -->
@ -301,11 +307,11 @@
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">
<!-- 預覽模式按鈕 --> <!-- 預覽模式按鈕 -->
<button id="btn-side-cancel" onclick="hideSideCLI()" style="background-color: #7f8c8d; padding: 5px 12px; font-size: 13px;">取消</button> <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()" style="background-color: #27ae60; 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()" style="background-color: #3498db; padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button> <button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div> <div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">&times;</span> <span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">&times;</span>
@ -339,10 +345,19 @@
</p> </p>
</div> </div>
<button onclick="loadRealConfigForFilters()" style="background-color: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin-bottom: 15px;"> <!-- 🌟 新增:過濾器模式切換下拉選單 -->
<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()" style="background-color: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
📥 載入設備配置以設定過濾 📥 載入設備配置以設定過濾
</button> </button>
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span> <span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
</div>
<!-- 樹狀 Checkbox 容器 --> <!-- 樹狀 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 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;">

View File

@ -1,66 +1,71 @@
import psycopg2 import asyncio
from psycopg2.extras import RealDictCursor import asyncpg
import logging
# 💡 資料庫連線設定 (請確認密碼與步驟一設定的相同) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# DB 設定
DB_CONFIG = { DB_CONFIG = {
"dbname": "cmts_nms", "database": "cmts_nms",
"user": "swpa", "user": "swpa",
"password": "swpa4920", # 替換為您的密碼 "password": "swpa4920",
"host": "127.0.0.1", # 如果 DB 在同一台機器上 "host": "127.0.0.1",
"port": "5432" "port": "5432"
} }
def init_database(): async def init_database():
try: try:
# 建立連線 logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...")
print("🔄 正在連線到 PostgreSQL...") conn = await asyncpg.connect(
conn = psycopg2.connect(**DB_CONFIG) database=DB_CONFIG["database"],
cursor = conn.cursor() user=DB_CONFIG["user"],
password=DB_CONFIG["password"],
host=DB_CONFIG["host"],
port=int(DB_CONFIG["port"])
)
# 1. 建立 CLI 字典表 (Global-Ready Schema) # 1. 建立選項快取表 cmts_options
print("🛠️ 正在建立 cli_schema_dictionary 資料表...") logger.info("🛠️ 正在建立 cmts_options 資料表...")
cursor.execute(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS cli_schema_dictionary ( CREATE TABLE IF NOT EXISTS cmts_options (
id SERIAL PRIMARY KEY, host VARCHAR(255) NOT NULL,
path VARCHAR(255) UNIQUE NOT NULL, config_type VARCHAR(50) NOT NULL,
node_type VARCHAR(50) DEFAULT 'leaf', path VARCHAR(500) NOT NULL,
options JSONB DEFAULT '[]'::jsonb, data JSONB NOT NULL,
description TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP PRIMARY KEY (host, config_type, path)
); );
CREATE INDEX IF NOT EXISTS idx_cli_schema_path ON cli_schema_dictionary(path);
""") """)
# 2. 建立系統狀態表 # 2. 建立設備狀態表 device_status
print("🛠️ 正在建立 system_metadata 資料表...") logger.info("🛠️ 正在建立 device_status 資料表...")
cursor.execute(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS system_metadata ( CREATE TABLE IF NOT EXISTS device_status (
key VARCHAR(50) PRIMARY KEY, host VARCHAR(255) NOT NULL,
value 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 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
""") """)
# 3. 寫入初始狀態預設值 (使用 ON CONFLICT 避免重複執行時報錯) await conn.close()
print("📝 寫入系統狀態預設值...") logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
cursor.execute("""
INSERT INTO system_metadata (key, value)
VALUES ('last_schema_sync_time', '1970-01-01 00:00:00')
ON CONFLICT (key) DO NOTHING;
INSERT INTO system_metadata (key, value)
VALUES ('last_schema_sync_cos_version', 'Unknown')
ON CONFLICT (key) DO NOTHING;
""")
# 提交變更並關閉連線
conn.commit()
cursor.close()
conn.close()
print("✅ 資料庫初始化完成!所有資料表已準備就緒。")
except Exception as e: except Exception as e:
print(f"❌ 資料庫初始化失敗: {e}") logger.error(f"❌ 資料庫初始化失敗: {e}")
if __name__ == "__main__": if __name__ == "__main__":
init_database() asyncio.run(init_database())

12
main.py
View File

@ -3,11 +3,21 @@ import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from contextlib import asynccontextmanager
import database
# 引入我們剛剛拆分出來的路由模組 # 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options from routers import query, config, terminal, lock, leaf_options
app = FastAPI(title="Harmonic CMTS Manager", version="2.0") @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) # 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@ -36,3 +36,4 @@ uvicorn==0.46.0
uvloop==0.22.1 uvloop==0.22.1
watchfiles==1.1.1 watchfiles==1.1.1
websockets==16.0 websockets==16.0
asyncpg==0.30.0

View File

@ -1,15 +1,69 @@
# --- routers/config.py --- # --- routers/config.py ---
import re import re
import json
import os
import database
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
from netmiko import ConnectHandler from netmiko import ConnectHandler
# 💡 確保從 shared 引入 deep_split_tree # 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict from collections import defaultdict
router = APIRouter() 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): class ConfigRequest(BaseModel):
script: str script: str
host: str host: str
@ -24,7 +78,7 @@ class DiffItem(BaseModel):
class GenerateCliRequest(BaseModel): class GenerateCliRequest(BaseModel):
diffs: List[DiffItem] diffs: List[DiffItem]
interfaces_with_admin_state: List[str] = [] # 💡 接收前端傳來的動態探測名單 interfaces_with_admin_state: List[str] = []
@router.post("/cmts-config") @router.post("/cmts-config")
async def execute_cmts_config(req: ConfigRequest): async def execute_cmts_config(req: ConfigRequest):
@ -38,12 +92,10 @@ async def execute_cmts_config(req: ConfigRequest):
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')] commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
# 🌟 1. 手動進入設定模式 (恢復您原本正確的寫法) # 1. 手動進入設定模式
net_connect.send_command_timing("config") net_connect.send_command_timing("config")
# 🌟 2. 批次送出設定指令 # 2. 批次送出設定指令
# 加上 enter_config_mode=False 與 exit_config_mode=False
# 這樣 Netmiko 就不會自作主張去打 config terminal 了
output = net_connect.send_config_set( output = net_connect.send_config_set(
commands, commands,
read_timeout=60, read_timeout=60,
@ -52,14 +104,13 @@ async def execute_cmts_config(req: ConfigRequest):
exit_config_mode=False exit_config_mode=False
) )
# 🌟 3. 手動退出設定模式 # 3. 手動退出設定模式
net_connect.send_command_timing("exit") net_connect.send_command_timing("exit")
net_connect.disconnect() net_connect.disconnect()
# 🌟 新增:檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字 # 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
lower_output = output.lower() lower_output = output.lower()
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output: if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output:
# 回傳錯誤狀態,前端就會顯示紅色字體且「不會」自動關閉視窗
return { return {
"status": "error", "status": "error",
"message": "CMTS 拒絕了設定 (Commit Aborted)", "message": "CMTS 拒絕了設定 (Commit Aborted)",
@ -76,21 +127,16 @@ async def generate_cli(req: GenerateCliRequest):
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本 將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
並自動處理 admin-state 的安全生命週期 ( down up) 並自動處理 admin-state 的安全生命週期 ( down up)
""" """
# 用來將同一個介面的變更群組化
interface_groups = defaultdict(list) interface_groups = defaultdict(list)
# 用來記錄該介面最終的 admin-state 應該是什麼
interface_admin_states = {} interface_admin_states = {}
for diff in req.diffs: for diff in req.diffs:
# 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency']
parts = diff.path.split('::') parts = diff.path.split('::')
param_name = parts[-1] param_name = parts[-1]
interface_path = " ".join(parts[:-1]) interface_path = " ".join(parts[:-1])
# 🌟 新增這行:全域清除路徑中間可能夾帶的虛擬資料夾索引 (例如 [0], [1]) # 全域清除路徑中間可能夾帶的虛擬資料夾索引
# 這樣 "logging evt 1024 [1] active" 就會還原成 "logging evt 1024 active"
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path) interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
if param_name == "admin-state": if param_name == "admin-state":
@ -98,21 +144,17 @@ async def generate_cli(req: GenerateCliRequest):
else: else:
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val)) interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
# 開始組裝最終的 CLI 腳本
cli_lines = [] cli_lines = []
cli_lines.append("! --- Auto-Generated Configuration Script ---") cli_lines.append("! --- Auto-Generated Configuration Script ---")
for interface, changes in interface_groups.items(): for interface, changes in interface_groups.items():
cli_lines.append(f"! Configuring: {interface}") cli_lines.append(f"! Configuring: {interface}")
# 💡 核心邏輯:判斷這個特定區塊是否支援 admin-state
supports_admin_state = interface in req.interfaces_with_admin_state supports_admin_state = interface in req.interfaces_with_admin_state
# 1. 安全機制:如果支援,才強制將介面 admin-state down
if supports_admin_state: if supports_admin_state:
cli_lines.append(f"{interface} admin-state down") cli_lines.append(f"{interface} admin-state down")
# 2. 寫入所有被修改的參數
for param, old_val, new_val in changes: for param, old_val, new_val in changes:
if re.match(r"^\[\d+\]$", param): if re.match(r"^\[\d+\]$", param):
if new_val == "": if new_val == "":
@ -128,16 +170,13 @@ async def generate_cli(req: GenerateCliRequest):
else: else:
cli_lines.append(f"{interface} {param} {new_val}") cli_lines.append(f"{interface} {param} {new_val}")
# 3. 恢復狀態:如果支援,才恢復為 up 或使用者指定的值
if supports_admin_state: if supports_admin_state:
final_state = interface_admin_states.get(interface, "up") final_state = interface_admin_states.get(interface, "up")
cli_lines.append(f"{interface} admin-state {final_state}") cli_lines.append(f"{interface} admin-state {final_state}")
# Harmonic 必須要有 commit 才會生效
cli_lines.append("commit") cli_lines.append("commit")
cli_lines.append("!") # 空行分隔 cli_lines.append("!")
# 將獨立修改 admin-state (沒有修改其他參數) 的情況也補上
for interface, state in interface_admin_states.items(): for interface, state in interface_admin_states.items():
if interface not in interface_groups: if interface not in interface_groups:
cli_lines.append(f"! Configuring state only: {interface}") cli_lines.append(f"! Configuring state only: {interface}")
@ -145,13 +184,11 @@ async def generate_cli(req: GenerateCliRequest):
cli_lines.append("commit") cli_lines.append("commit")
cli_lines.append("!") cli_lines.append("!")
# 將陣列組合成字串回傳
final_script = "\n".join(cli_lines) final_script = "\n".join(cli_lines)
return {"status": "success", "data": final_script} return {"status": "success", "data": final_script}
# ========================================== # ==========================================
# 💡 以下為 DS RF Port 動態樹狀查詢 API # DS RF Port 動態樹狀查詢 API
# ========================================== # ==========================================
@router.get("/cmts-ds-rf-port-list") @router.get("/cmts-ds-rf-port-list")
@ -162,13 +199,10 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
# 使用 include 只抓取關鍵字,並把等待時間延長至 60 秒
command = 'show running-config | include "cable ds-rf-port"' command = 'show running-config | include "cable ds-rf-port"'
raw_output = net_connect.send_command(command, read_timeout=60) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect() net_connect.disconnect()
# 使用正規表達式擷取 port 號碼 (例如 62:0/0)
pattern = r"cable ds-rf-port\s+([0-9:/]+)" pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output) ports = re.findall(pattern, raw_output)
unique_ports = sorted(list(set(ports))) unique_ports = sorted(list(set(ports)))
@ -191,17 +225,15 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore" command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = net_connect.send_command(command, read_timeout=60) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect() net_connect.disconnect()
# 1. 初步解析縮排
base_tree = parse_cli_to_tree(raw_output) base_tree = parse_cli_to_tree(raw_output)
# 2. 💡 深層解析空白字元,建立完美樹狀結構
perfect_tree = deep_split_tree(base_tree) perfect_tree = deep_split_tree(base_tree)
return { return {
"status": "success", "status": "success",
"data": perfect_tree, # 回傳完美的樹狀結構 "data": perfect_tree,
"raw_cli": raw_output.strip() "raw_cli": raw_output.strip()
} }
except Exception as e: except Exception as e:
@ -209,29 +241,39 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
@router.get("/cmts-full-config") @router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False): async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON""" """獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) 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" command = "show running-config | nomore"
raw_output = net_connect.send_command(command, read_timeout=60) raw_output = str(net_connect.send_command(command, read_timeout=180))
net_connect.disconnect() net_connect.disconnect()
base_tree = parse_cli_to_tree(raw_output) clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output)
perfect_tree = deep_split_tree(base_tree) perfect_tree = deep_split_tree(base_tree)
# ========================================== # ==========================================
# 💡 深度過濾攔截器:支援多層級路徑 (如 cable.mac-domain) # 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
# ========================================== # ==========================================
if not skip_filter: if not skip_filter:
settings = load_settings() hidden_keys = await load_tree_filters(config_type)
hidden_keys = settings.get("hidden_keys", [])
for path in hidden_keys: for path in hidden_keys:
keys = path.split('.') keys = path.split('::')
current = perfect_tree current = perfect_tree
# 走到倒數第二層 # 走到倒數第二層
for k in keys[:-1]: for k in keys[:-1]:
@ -250,19 +292,22 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
# ========================================== # ==========================================
# 💡 系統設定 API (System Settings) # 🌟 系統設定 API (System Settings)
# ========================================== # ==========================================
class SettingsRequest(BaseModel): class SettingsRequest(BaseModel):
hidden_keys: list[str] hidden_keys: list[str]
config_type: str = "running" # 🌟 加入 config_type 參數
@router.get("/settings/tree-filters") @router.get("/settings/tree-filters")
async def get_tree_filters(): async def get_tree_filters(config_type: str = "running"):
"""獲取目前的樹狀圖隱藏名單""" """獲取目前的樹狀圖隱藏名單"""
settings = load_settings() # 🌟 根據 config_type 讀取對應的 JSON
return {"status": "success", "data": settings["hidden_keys"]} hidden_keys = await load_tree_filters(config_type)
return {"status": "success", "data": hidden_keys}
@router.post("/settings/tree-filters") @router.post("/settings/tree-filters")
async def update_tree_filters(req: SettingsRequest): async def update_tree_filters(req: SettingsRequest):
"""更新樹狀圖隱藏名單並存檔""" """更新樹狀圖隱藏名單並存檔"""
save_settings({"hidden_keys": req.hidden_keys}) # 🌟 根據 config_type 寫入對應的 JSON
return {"status": "success", "message": "系統設定已更新"} await save_tree_filters(req.config_type, req.hidden_keys)
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}

View File

@ -1,49 +1,155 @@
# --- routers/leaf_options.py --- # --- routers/leaf_options.py ---
from fastapi import APIRouter, BackgroundTasks, HTTPException, Body from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
import json, os, asyncio, time, re import json, os, asyncio, re
import database
# 🌟 引入新的 asyncssh 版本爬蟲
from cmts_scraper import sync_cmts_leaves_async from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE from shared import CMTS_DEVICE, USE_DB
# 🌟 移除原本硬寫的 prefix="/api/v1",只留下 tags
router = APIRouter(tags=["Options"]) router = APIRouter(tags=["Options"])
CACHE_FILE = "cmts_leaf_options_cache.json"
# 🌟 1. 改用全域布林變數,狀態切換更即時,消滅非同步時間差 # 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
IS_SCANNING = False def get_cache_file(host: str, config_type: str):
safe_host = host.replace(".", "_")
return f"{safe_host}_{config_type}_cache.json"
# ==========================================
# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
# ==========================================
# 將狀態改為空字典,動態根據 host_configType 建立
SCAN_STATUS = {}
active_clients = {}
async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
"""將訊息推播給特定頻道的連線前端"""
channel_key = f"{host}_{config_type}"
dead_clients = set()
# SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
message_str = f"data: {json.dumps(message_dict)}\n\n"
for client_queue in active_clients.get(channel_key, set()):
try:
await client_queue.put(message_str)
except Exception:
dead_clients.add(client_queue)
for dead in dead_clients:
active_clients[channel_key].discard(dead)
@router.get("/cmts-leaf-options/stream")
async def sse_stream(request: Request, host: str, config_type: str = "running"):
"""前端一載入就會連上這個路由,持續監聽特定頻道的廣播"""
channel_key = f"{host}_{config_type}"
client_queue = asyncio.Queue()
if channel_key not in active_clients:
active_clients[channel_key] = set()
active_clients[channel_key].add(client_queue)
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
try:
message = await asyncio.wait_for(client_queue.get(), timeout=1.0)
yield message
except asyncio.TimeoutError:
yield ": keepalive\n\n"
except asyncio.CancelledError:
pass
finally:
if channel_key in active_clients:
active_clients[channel_key].discard(client_queue)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/scan-status")
async def get_scan_status(host: str, config_type: str = "running"):
channel_key = f"{host}_{config_type}"
return {"is_scanning": SCAN_STATUS.get(channel_key, False)}
# ==========================================
# 🌟 3. 快取讀取與背景掃描任務
# ==========================================
class SyncOptionsRequest(BaseModel): class SyncOptionsRequest(BaseModel):
host: str # 🌟 新增 host 參數
username: str = "" # 🌟 新增 username 參數
password: str = "" # 🌟 新增 password 參數
leaf_paths: List[str] leaf_paths: List[str]
config_type: str = "running"
@router.get("/cmts-leaf-options") @router.get("/cmts-leaf-options")
async def get_leaf_options(): async def get_leaf_options(host: str, config_type: str = "running"):
if not os.path.exists(CACHE_FILE): return {} if USE_DB:
with open(CACHE_FILE, "r", encoding="utf-8") as f: 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 json.load(f)
@router.post("/cmts-leaf-options/sync") return await asyncio.to_thread(read_json_from_file, cache_file)
async def sync_leaf_options(request: SyncOptionsRequest):
global IS_SCANNING # 宣告我們要修改全域變數
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: if not request.leaf_paths:
raise HTTPException(status_code=400) raise HTTPException(status_code=400)
# 🌟 2. 第一時間檢查並「立即」阻擋 channel_key = f"{request.host}_{request.config_type}"
if IS_SCANNING:
async def busy_stream():
yield json.dumps({"event": "error", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}) + "\n"
return StreamingResponse(busy_stream(), media_type="text/event-stream")
# 如果沒人掃描,立刻把狀態改為 True (在建立串流之前就上鎖) if SCAN_STATUS.get(channel_key, False):
IS_SCANNING = True return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"}
async def event_generator(): SCAN_STATUS[channel_key] = True
global IS_SCANNING
try: # 保留您原本完美的路徑清理邏輯
cmts_query_paths = [] cmts_query_paths = []
for p in request.leaf_paths: for p in request.leaf_paths:
clean_p = p.replace("::", " ") clean_p = p.replace("::", " ")
@ -52,50 +158,59 @@ async def sync_leaf_options(request: SyncOptionsRequest):
cmts_query_paths = list(dict.fromkeys(cmts_query_paths)) cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
# 🌟 因為爬蟲自己會存檔,我們只要專心把進度轉發給前端就好! # 決定帳密 (優先使用 request 傳來的,否則 fallback 到 shared.CMTS_DEVICE)
async for chunk in sync_cmts_leaves_async( # 🌟 加上 or "" 確保最終結果絕對是字串,消除 Pylance 警告
host=CMTS_DEVICE.get("host"), user = request.username or CMTS_DEVICE.get("username") or ""
username=CMTS_DEVICE.get("username"), pwd = request.password or CMTS_DEVICE.get("password") or ""
password=CMTS_DEVICE.get("password"),
leaf_paths=cmts_query_paths
):
yield chunk
except Exception as e: background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths, request.config_type)
yield json.dumps({"event": "error", "message": str(e)}) + "\n" return {"status": "started"}
finally: # ==========================================
# 🌟 3. 無論成功、失敗、或使用者提早關閉網頁,都確保會解鎖 # 🌟 4. 清除快取 API
IS_SCANNING = False # ==========================================
return StreamingResponse(event_generator(), media_type="text/event-stream")
# 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE)
@router.post("/clear_cache") @router.post("/clear_cache")
async def clear_specific_cache(paths_to_clear: list = Body(...)): async def clear_specific_cache(host: str, config_type: str = "running", paths_to_clear: list = Body(...)):
cleared_count = 0 cleared_count = 0
# 1. 檢查快取檔案是否存在 # 1. 嘗試清除 DB
if not os.path.exists(CACHE_FILE): 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": "快取檔案不存在"} return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
try: try:
# 2. 讀取現有的 JSON 快取資料 with open(cache_file, "r", encoding="utf-8") as f:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
cache_data = json.load(f) cache_data = json.load(f)
# 3. 逐一比對並刪除指定的路徑 json_cleared_count = 0
for path in paths_to_clear: for path in paths_to_clear:
if path in cache_data: if path in cache_data:
del cache_data[path] del cache_data[path]
cleared_count += 1 json_cleared_count += 1
# 4. 如果有刪除資料,將更新後的內容寫回 JSON 檔案 if json_cleared_count > 0:
if cleared_count > 0: with open(cache_file, "w", encoding="utf-8") as f:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=4) json.dump(cache_data, f, ensure_ascii=False, indent=4)
return {"status": "success", "cleared_count": cleared_count} # 回傳較大的那個數值
final_count = max(cleared_count, json_cleared_count)
return {"status": "success", "cleared_count": final_count}
except Exception as e: except Exception as e:
return {"status": "error", "message": f"清除快取時發生錯誤: {str(e)}"} # 確保回傳標準 JSON 格式
return {"status": "error", "message": f"資料庫連線與快取存取皆失敗: {str(e)}"}

View File

@ -1,48 +1,49 @@
# --- routers/lock.py --- # --- routers/lock.py ---
import time import time
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, Optional from typing import Dict, Optional
# 🌟 只定義自己的子路徑
router = APIRouter(prefix="/locks", tags=["Lock Management"]) router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ========================================== # ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table) # 💡 全域鎖定表 (In-Memory Lock Table)
# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} } # 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
# ========================================== # ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {} ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120
# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat否則自動釋放 # 🌟 新增 host 欄位
LOCK_TIMEOUT = 30
class LockAcquireReq(BaseModel): class LockAcquireReq(BaseModel):
host: str
path: str path: str
user_id: str # 前端產生的 UUID (Session ID) user_id: str
username: str # 登入的帳號 (用於 UI 提示,例如 "admin") username: str
class LockActionReq(BaseModel): class LockActionReq(BaseModel):
host: str
path: str path: str
user_id: str user_id: str
def clean_expired_locks(): def clean_expired_locks():
"""清除已經超時的鎖 (被動式清理)"""
current_time = time.time() current_time = time.time()
expired_paths = [path for path, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time] expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
for path in expired_paths: for k in expired_keys:
del ACTIVE_LOCKS[path] del ACTIVE_LOCKS[k]
def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[dict], str]: def is_path_locked_by_others(target_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
"""
檢查路徑是否被其他人鎖定並回傳 (衝突的鎖定資訊, 具體錯誤訊息)
"""
clean_expired_locks() clean_expired_locks()
prefix = f"{target_host}@@"
for locked_path, lock_info in ACTIVE_LOCKS.items(): 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: if lock_info["user_id"] == user_id:
continue # 自己鎖定的不算衝突 continue
# 💡 拆分判斷,回傳更精準的錯誤訊息
if target_path == locked_path: if target_path == locked_path:
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中" return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
@ -54,24 +55,15 @@ def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[d
return None, "" return None, ""
# ==========================================
# 💡 API Endpoints
# ==========================================
@router.post("/acquire") @router.post("/acquire")
async def acquire_lock(req: LockAcquireReq): async def acquire_lock(req: LockAcquireReq):
"""請求鎖定特定路徑""" conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
# 接收回傳的鎖定資訊與具體訊息
conflict_lock, error_msg = is_path_locked_by_others(req.path, req.user_id)
if conflict_lock: if conflict_lock:
raise HTTPException( raise HTTPException(status_code=409, detail=error_msg)
status_code=409,
detail=error_msg # 💡 將精準的錯誤訊息傳給前端
)
# 寫入或更新鎖定狀態 lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[req.path] = { ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id, "user_id": req.user_id,
"username": req.username, "username": req.username,
"expires_at": time.time() + LOCK_TIMEOUT "expires_at": time.time() + LOCK_TIMEOUT
@ -80,28 +72,33 @@ async def acquire_lock(req: LockAcquireReq):
@router.post("/heartbeat") @router.post("/heartbeat")
async def heartbeat_lock(req: LockActionReq): async def heartbeat_lock(req: LockActionReq):
"""延長鎖定時間 (心跳偵測)"""
clean_expired_locks() clean_expired_locks()
lock_key = f"{req.host}@@{req.path}"
if req.path not in ACTIVE_LOCKS: if lock_key not in ACTIVE_LOCKS:
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取") raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
if ACTIVE_LOCKS[req.path]["user_id"] != req.user_id: if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
raise HTTPException(status_code=403, detail="無權更新他人的鎖定") raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
# 延長鎖定時間 ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT
return {"status": "success", "message": "心跳更新成功"} return {"status": "success", "message": "心跳更新成功"}
@router.post("/release") @router.post("/release")
async def release_lock(req: LockActionReq): async def release_lock(req: LockActionReq):
"""主動釋放鎖定""" lock_key = f"{req.host}@@{req.path}"
if req.path in ACTIVE_LOCKS and ACTIVE_LOCKS[req.path]["user_id"] == req.user_id: if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
del ACTIVE_LOCKS[req.path] del ACTIVE_LOCKS[lock_key]
return {"status": "success", "message": "鎖定已釋放"} return {"status": "success", "message": "鎖定已釋放"}
@router.get("/status") @router.get("/status")
async def get_lock_status(): async def get_lock_status(host: str = Query(None)):
"""獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)""" """獲取特定設備的鎖定狀態"""
clean_expired_locks() clean_expired_locks()
return {"status": "success", "data": ACTIVE_LOCKS} if host:
prefix = f"{host}@@"
# 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 pathUI 不用改!
filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
return {"status": "success", "data": filtered_locks}
return {"status": "success", "data": {}}

View File

@ -32,7 +32,7 @@ def parse_cm_output(raw_text: str) -> list:
async def get_cable_modems(): async def get_cable_modems():
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) net_connect = ConnectHandler(**CMTS_DEVICE)
raw_output = net_connect.send_command("show cable modem") raw_output = str(net_connect.send_command("show cable modem"))
net_connect.disconnect() net_connect.disconnect()
structured_data = parse_cm_output(raw_output) structured_data = parse_cm_output(raw_output)
return {"status": "success", "total_count": len(structured_data), "data": structured_data} return {"status": "success", "total_count": len(structured_data), "data": structured_data}
@ -121,7 +121,7 @@ async def get_mac_domain_config(target: str, host: str, username: str, password:
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore" cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = net_connect.send_command(cli_command, read_timeout=15) raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
net_connect.disconnect() net_connect.disconnect()
# 💡 正名工程:更新字典的 Key 為一致性的命名 # 💡 正名工程:更新字典的 Key 為一致性的命名
@ -190,7 +190,7 @@ async def get_cmts_mac_domain_list(
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
raw_output = net_connect.send_command_timing("show running-config cable mac-domain ?") raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
net_connect.disconnect() net_connect.disconnect()
import re import re
@ -200,3 +200,29 @@ async def get_cmts_mac_domain_list(
return {"status": "success", "data": mac_domains} return {"status": "success", "data": mac_domains}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
@router.get("/cmts-version")
async def get_cmts_version(
host: str = Query(...),
username: str = Query(...),
password: str = Query(...)
):
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command("show version", read_timeout=15))
net_connect.disconnect()
import re
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
if match:
return {"status": "success", "version": match.group(1)}
else:
return {"status": "error", "message": "無法解析版本號", "raw_output": raw_output}
except Exception as e:
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}

View File

@ -6,6 +6,7 @@ import re
from collections import defaultdict from collections import defaultdict
DEBUG_MODE = False DEBUG_MODE = False
USE_DB = True # PostgreSQL Feature Toggle
def debug_print(msg: str): def debug_print(msg: str):
if DEBUG_MODE: if DEBUG_MODE:

View File

@ -3,56 +3,69 @@
// ========================================== // ==========================================
// 1. 鎖定管理 (Lock Management) // 1. 鎖定管理 (Lock Management)
// ========================================== // ==========================================
export async function apiAcquireLock(path, userId, username) { export async function apiAcquireLock(path, userId, username, host) {
const response = await fetch('/api/v1/locks/acquire', { const res = await fetch('/api/v1/locks/acquire', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, username }) body: JSON.stringify({ path, user_id: userId, username, host })
}); });
return { status: response.status, data: await response.json() }; const data = await res.json();
return { status: res.status, data };
} }
export async function apiReleaseLock(path, userId) { export async function apiReleaseLock(path, userId, host) {
return fetch('/api/v1/locks/release', { return fetch('/api/v1/locks/release', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId }) body: JSON.stringify({ path, user_id: userId, host })
}); });
} }
export async function apiSendHeartbeat(path, userId) { export async function apiSendHeartbeat(path, userId, host) {
return fetch('/api/v1/locks/heartbeat', { return fetch('/api/v1/locks/heartbeat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId }) 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) // 2. 樹狀圖與選項快取 (Tree & Options)
// ========================================== // ==========================================
export async function apiGetFullConfig(host, username, password, skipFilter = false) { export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; // 🌟 將 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'; if (skipFilter) url += '&skip_filter=true';
const response = await fetch(url); const response = await fetch(url);
return response.json(); return response.json();
} }
export async function apiGetLeafOptions() { // 🌟 1. 取得選項快取 (加上 configType 查詢參數)
const response = await fetch('/api/v1/cmts-leaf-options'); 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(); return response.json();
} }
export async function apiSyncLeafOptions(paths) { // 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
export async function apiSyncLeafOptions(host, paths, configType = 'running') {
return fetch('/api/v1/cmts-leaf-options/sync', { return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: paths }) body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
}); });
} }
export async function apiClearCache(paths) { // 🌟 3. 清除快取 (加上 configType 查詢參數)
const response = await fetch('/api/v1/clear_cache', { export async function apiClearCache(host, paths, configType = 'running') {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paths) body: JSON.stringify(paths)
@ -61,6 +74,22 @@ export async function apiClearCache(paths) {
return response.json(); 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) // 3. 指令生成與執行 (CLI Generation & Execution)
// ========================================== // ==========================================
@ -85,16 +114,18 @@ export async function apiExecuteConfig(script, host, username, password) {
// ========================================== // ==========================================
// 4. 系統設定與過濾器 (System Settings) // 4. 系統設定與過濾器 (System Settings)
// ========================================== // ==========================================
export async function apiGetTreeFilters() { // 🌟 加上 configType 參數
const response = await fetch('/api/v1/settings/tree-filters'); export async function apiGetTreeFilters(configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
return response.json(); return response.json();
} }
export async function apiSaveTreeFilters(hiddenKeys) { // 🌟 加上 configType 參數
const response = await fetch('/api/v1/settings/tree-filters', { export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden_keys: hiddenKeys }) body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
}); });
return response.json(); return response.json();
} }
@ -123,11 +154,12 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
// ========================================== // ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新) // 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ========================================== // ==========================================
export async function apiSyncLeafOptionsStream(paths, onProgress) { // 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
const response = await fetch('/api/v1/cmts-leaf-options/sync', { const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: paths }) body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
}); });
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream"); if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");

View File

@ -11,7 +11,8 @@ import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, dow
import { import {
apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -24,7 +25,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-u
import { import {
releaseAllLocks, startEditFolder, startEditLeaf, releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
hideSideCLI, executeSideCLI hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
} from './edit-mode.js'; } from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain) // 6. MAC Domain 配置模組 (MAC Domain)
@ -36,16 +37,120 @@ import {
// ============================================================================ // ============================================================================
// 🌟 1. 全域生命週期與閒置偵測 (Lifecycle & Idle Detection) // 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
// ============================================================================ // ============================================================================
let idleTimer; let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
function initGlobalSSE(mode = 'running') {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
if (globalSSE) globalSSE.close();
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
const statusEl = document.getElementById('scan-status');
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
if (data.event === 'scan_start' || data.event === 'progress') {
setAdminButtonsState(true, "⏳ 系統更新中...");
}
if (data.event === 'scan_start') {
if (statusEl) {
statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'progress') {
if (statusEl) {
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'done') {
if (statusEl) {
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
}
// 掃描完成才解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
else if (data.event === 'error') {
if (statusEl) {
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c";
}
// 發生錯誤也要解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
};
}
// 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線
window.addEventListener('beforeunload', () => {
if (globalSSE) {
globalSSE.close();
globalSSE = null;
}
});
let lockPollingTimer = null;
function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer);
lockPollingTimer = setInterval(async () => {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
try {
const result = await apiGetLockStatus(connInfo.host);
if (result.status === 'success') {
const activeLocks = result.data;
document.querySelectorAll('.edit-btn').forEach(btn => {
const path = btn.getAttribute('data-path');
if (activeLocks[path] && activeLocks[path].user_id !== SESSION_USER_ID) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
btn.title = `🔒 此區塊正由 [${activeLocks[path].username}] 編輯中`;
btn.innerText = "🔒";
} else {
if (btn.innerText === "🔒") {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
});
}
} catch (error) {
console.warn("獲取鎖定狀態失敗:", error);
}
}, 15000);
}
// 頁面載入與縮放初始化 // 頁面載入與縮放初始化
window.onload = () => { window.onload = () => {
initTerminal(); initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器 resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監聽
startLockStatusPolling(); // 啟動鎖定狀態輪詢
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
}; };
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); }; window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
@ -55,11 +160,18 @@ function resetIdleTimer() {
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT); idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
} }
// 監聽使用者的互動事件,只要有動作就重置計時器 // 🌟 效能急救:節流閥 (Throttle) 機制
window.addEventListener('mousemove', resetIdleTimer); let throttleTimer = false;
window.addEventListener('keydown', resetIdleTimer); function throttledReset() {
window.addEventListener('click', resetIdleTimer); 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) // 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
@ -85,17 +197,57 @@ function switchQueryTask() {
if (targetForm) targetForm.classList.add('active'); if (targetForm) targetForm.classList.add('active');
} }
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
let lastTreeMode = null;
// 切換「設備配置」內的子任務表單 // 切換「設備配置」內的子任務表單
function switchConfigTask() { function switchConfigTask() {
document.querySelectorAll('#config-tab .task-form').forEach(form => { document.querySelectorAll('#config-tab .task-form').forEach(form => {
form.classList.remove('active'); form.classList.remove('active');
form.style.display = 'none'; form.style.display = 'none';
}); });
const selectedTaskId = document.getElementById('configTask').value; const selectedTaskId = document.getElementById('configTask').value;
const targetForm = document.getElementById(selectedTaskId);
let targetFormId = selectedTaskId;
if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') {
targetFormId = 'form-tree-config';
}
const targetForm = document.getElementById(targetFormId);
if (targetForm) { if (targetForm) {
targetForm.classList.add('active'); targetForm.classList.add('active');
targetForm.style.display = 'block'; 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);
} }
} }
@ -112,8 +264,13 @@ function startConfigTask() {
if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return; if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
resetAllManagerData(); resetAllManagerData();
loadMacDomainList(); loadMacDomainList();
} else if (selectedTaskId === 'form-full-config') { }
fetchFullConfig(); // 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType
else if (selectedTaskId === 'form-running-config') {
fetchFullConfig('running');
}
else if (selectedTaskId === 'form-full-config') {
fetchFullConfig('full'); // 🌟 補上 full 參數
} }
} }
@ -202,10 +359,11 @@ async function executeQuery(mode) {
} }
} }
// 載入完整設備配置並生成樹狀圖 // 載入完整設備配置並生成樹狀圖,預設為 running
async function fetchFullConfig() { async function fetchFullConfig(configType = 'running') {
const loadingMsg = document.getElementById('loading-message'); const loadingMsg = document.getElementById('loading-message');
const treeContainer = document.getElementById('tree-container'); // 🌟 動態抓取當前模式的專屬容器
const treeContainer = document.getElementById(`tree-container-${configType}`);
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
@ -213,12 +371,76 @@ async function fetchFullConfig() {
if (loadingMsg) loadingMsg.style.display = 'inline-block'; if (loadingMsg) loadingMsg.style.display = 'inline-block';
if (treeContainer) treeContainer.innerHTML = ''; if (treeContainer) treeContainer.innerHTML = '';
let needAutoScan = false; // 🌟 標記是否需要自動掃描
try { try {
const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass); // --- 1. 版本守門員 ---
try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
// 🌟 修正 1讀取選項快取時必須加上 config_type 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
if (cachedVersion && cachedVersion !== currentVersion) {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
if (doClear) {
const allKeys = Object.keys(optData);
// 🌟 修正 2清空快取時必須傳遞 configType 告訴後端清哪一個
await apiClearCache(connInfo.host, allKeys, configType);
alert("✅ 舊版快取已清空!系統載入配置後,將自動為您重新掃描選項。");
needAutoScan = true; // 🌟 觸發自動掃描旗標
}
}
}
} catch (vErr) {
console.warn("版本檢查失敗,略過", vErr);
}
// --- 2. 載入完整配置 ---
const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass, false, configType);
// 🌟 新增:將原始資料存入全域變數,供全域掃描使用
window.currentTreeData = result.data;
// 🌟 新增防呆攔截:檢查使用者是否在等待期間切換了下拉選單
const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
if (currentSelectedTask !== expectedTask) {
console.warn(`[防呆攔截] 正在載入 ${configType},但使用者已切換至 ${currentSelectedTask},捨棄本次結果以防畫面錯亂。`);
return; // 🌟 發現模式不符,直接中斷,不要把舊樹狀圖貼上去!
}
if (result.status === 'success') { if (result.status === 'success') {
if (treeContainer) { if (treeContainer) {
treeContainer.innerHTML = buildTree(result.data); // 🌟 傳入 configType讓 ID 加上前綴
treeContainer.innerHTML = buildTree(result.data, '', false, configType);
// 🌟 修正:樹狀圖一畫完,立刻隱藏載入訊息,讓使用者感覺「秒開」
if (loadingMsg) loadingMsg.style.display = 'none';
// 🌟 3. 檢查後端是否已經有人在掃描
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
if (isSomeoneScanning) {
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000);
} else {
enableGlobalScanButton(); enableGlobalScanButton();
// 如果剛清空快取,自動啟動掃描
if (needAutoScan) {
setTimeout(() => {
// 🌟 修正 3全域掃描也必須知道現在要掃哪一棵樹
scanGlobalMissingOptions(configType);
}, 500);
}
}
} }
} else { } else {
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`; if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
@ -230,24 +452,25 @@ async function fetchFullConfig() {
} }
} }
// ============================================================================ // ============================================================================
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning) // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================ // ============================================================================
// 為現有的 input 加上下拉選項 (不破壞原有結構) // 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
async function enhanceInputWithOptions(path, elementId) { async function enhanceInputWithOptions(path, elementId, mode = 'running') {
// 🌟 1. 取得當前連線的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
try { try {
const optRes = await fetch('/api/v1/cmts-leaf-options'); // 🌟 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 optData = await optRes.json();
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey]; const leafCache = optData[cacheKey];
const currentTime = Math.floor(Date.now() / 1000); if (leafCache && leafCache.options && leafCache.options.length > 0) {
const CACHE_TTL = 86400 * 7;
if (leafCache && leafCache.options && leafCache.options.length > 0 && (currentTime - leafCache.updated_at < CACHE_TTL)) {
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const input = container.querySelector('.edit-input'); const input = container.querySelector('.edit-input');
@ -264,10 +487,11 @@ async function enhanceInputWithOptions(path, elementId) {
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join(''); dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
} }
} else { } else {
// 🌟 3. POST 請求:把 host 塞進 body 裡面
fetch('/api/v1/cmts-leaf-options/sync', { fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: [cacheKey] }) body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode })
}); });
} }
} catch (e) { } catch (e) {
@ -275,117 +499,134 @@ async function enhanceInputWithOptions(path, elementId) {
} }
} }
// 一鍵掃描缺失選項 (包含快取比對 + 防連點保護 + 僅掃描可見項目) // ============================================================================
async function scanAllMissingOptions() { // 🌟 核心功能:執行掃描 (廣播升級版),加上 mode 參數
const btn = document.getElementById('global-scan-btn'); // ============================================================================
const statusEl = document.getElementById('scan-status');
const treeContainer = document.getElementById('tree-container');
if (!treeContainer) return; // 🌟 新增:遞迴提取所有快取 Key 的輔助函數 (不依賴網頁 DOM)
function extractAllCacheKeys(node, path = '') {
const LOCK_DURATION_MS = 60000; let keys = [];
localStorage.setItem('scanLockUntil', Date.now() + LOCK_DURATION_MS); for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key;
if (btn) { if (typeof value === 'object' && value !== null) {
btn.disabled = true; keys = keys.concat(extractAllCacheKeys(value, currentPath));
btn.style.backgroundColor = "#bdc3c7"; } else {
btn.style.cursor = "not-allowed"; const origVal = (value === null ? '' : value).toString();
btn.innerText = "⏳ 掃描冷卻中..."; let cacheKey = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (currentPath.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
} }
keys.push(cacheKey);
statusEl.innerHTML = `⏳ 正在比對快取資料...`;
statusEl.style.color = "#f39c12";
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
const allContainers = treeContainer.querySelectorAll('.leaf-container');
const pathsToSync = [];
allContainers.forEach(container => {
const isHiddenByFolder = container.closest('details:not([open])') !== null;
if (isHiddenByFolder) return;
const path = container.getAttribute('data-path');
if (path) {
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
if (!pathsToSync.includes(cacheKey)) pathsToSync.push(cacheKey);
} }
} }
}); return keys;
}
if (pathsToSync.length === 0) { async function performScan(isGlobal, mode = 'running') {
statusEl.textContent = "✅ 當前展開的欄位都已有最新選項,無需掃描!"; const connInfo = getGlobalConnectionInfo();
statusEl.style.color = "#27ae60"; if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
setTimeout(() => statusEl.textContent = "", 3000);
if (btn) { const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
btn.disabled = false; if (isSomeoneScanning) {
btn.style.backgroundColor = ""; alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
btn.style.cursor = "pointer";
btn.innerHTML = "🔍 掃描畫面缺失選項";
}
localStorage.removeItem('scanLockUntil');
return; return;
} }
statusEl.innerHTML = `⏳ 正在建立即時連線...`; const treeContainer = document.getElementById(`tree-container-${mode}`);
statusEl.style.color = "#e67e22"; if (!treeContainer) return;
// 🌟 核心修改:改用串流 API並傳入進度更新的回呼函數 const statusEl = document.getElementById('scan-status');
await apiSyncLeafOptionsStream(pathsToSync, (data) => {
if (data.event === 'progress') {
// 收到進度:即時更新畫面數字與當前路徑
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
statusEl.style.color = "#f39c12";
}
else if (data.event === 'done') {
// 掃描完成:恢復按鈕狀態並顯示成功
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
if (btn) { try {
btn.disabled = false; const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
btn.style.backgroundColor = "#8e44ad"; const optData = await optRes.json();
btn.style.cursor = "pointer"; const pathsToSync = [];
btn.innerText = "🔍 掃描畫面缺失選項";
}
localStorage.removeItem('scanLockUntil');
setTimeout(() => statusEl.textContent = "", 5000);
}
else if (data.event === 'error') {
// 發生錯誤:顯示紅字並恢復按鈕
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c";
if (btn) { if (isGlobal) {
btn.disabled = false; // 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
btn.style.backgroundColor = "#8e44ad"; if (!window.currentTreeData) return alert("請先載入設備配置!");
btn.style.cursor = "pointer"; const allKeys = extractAllCacheKeys(window.currentTreeData);
btn.innerText = "🔍 掃描畫面缺失選項";
} allKeys.forEach(cacheKey => {
localStorage.removeItem('scanLockUntil'); 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) { } catch (error) {
console.error("掃描請求失敗:", error); console.error("掃描請求發送失敗:", error);
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。"; statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
statusEl.style.color = "#e74c3c"; statusEl.style.color = "#e74c3c";
} }
} }
// 清除畫面選項快取功能 (精準可見度判定版) // ============================================================================
async function clearVisibleCache() { // 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
const elements = document.querySelectorAll('.leaf-container'); // ============================================================================
const pathsToClear = []; 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 => { elements.forEach(el => {
const isHiddenByFolder = el.closest('details:not([open])') !== null; const isHiddenByFolder = el.closest('details:not([open])') !== null;
if (!isHiddenByFolder) { if (!isHiddenByFolder) {
@ -396,19 +637,51 @@ async function clearVisibleCache() {
} }
} }
}); });
}
if (pathsToClear.length === 0) return alert("畫面上沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。"); if (pathsToClear.length === 0) return alert(isGlobal ? "目前沒有任何快取可以清除!" : "局部沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。");
if (!confirm(`確定要清除畫面上 ${pathsToClear.length} 個選項的快取嗎?\n(清除後請重新點擊「掃描畫面缺失選項」)`)) return;
const msg = isGlobal
? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)`
: `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後將自動為您重新掃描)`;
if (!confirm(msg)) return;
try { try {
const result = await apiClearCache(pathsToClear); // 🌟 4. 呼叫清除 API 時,傳入 host
alert(`✅ 成功清除 ${result.cleared_count} 筆快取!\n現在請點擊旁邊的「掃描畫面缺失選項」來重新擷取。`); 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) { } catch (error) {
console.error("清除快取失敗:", error); console.error("清除快取失敗:", error);
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。"); 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) // 🌟 5. 系統設定與過濾器 (System Settings & Filters)
@ -416,10 +689,21 @@ async function clearVisibleCache() {
let currentHiddenKeys = []; let currentHiddenKeys = [];
// 🌟 新增:切換過濾器模式時觸發
async function switchFilterMode() {
const container = document.getElementById('filter-checkboxes');
if (container) container.style.display = 'none'; // 先隱藏樹狀圖,強迫使用者重新載入
await openSystemSettings();
}
// 開啟系統設定並讀取目前的過濾器清單 // 開啟系統設定並讀取目前的過濾器清單
async function openSystemSettings() { async function openSystemSettings() {
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
try { try {
const result = await apiGetTreeFilters(); const result = await apiGetTreeFilters(mode);
if (result.status === 'success') currentHiddenKeys = result.data; if (result.status === 'success') currentHiddenKeys = result.data;
} catch (error) { } catch (error) {
console.error("讀取設定失敗:", error); console.error("讀取設定失敗:", error);
@ -431,6 +715,10 @@ async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!"); 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 loadingMsg = document.getElementById('filter-loading-msg');
const container = document.getElementById('filter-checkboxes'); const container = document.getElementById('filter-checkboxes');
@ -438,13 +726,15 @@ async function loadRealConfigForFilters() {
container.style.display = 'none'; container.style.display = 'none';
try { try {
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true`; // 🌟 根據模式載入對應的配置檔 (Running 或 Full)
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
const response = await fetch(url); const response = await fetch(url);
const result = await response.json(); const result = await response.json();
if (result.status === 'success') { if (result.status === 'success') {
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys); // 🌟 傳遞 mode 給 buildRealFilterTree
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
} else { } else {
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`; container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
@ -484,11 +774,15 @@ function updateParentCheckboxState(element) {
// 儲存系統設定 (過濾器) // 儲存系統設定 (過濾器)
async function saveSystemSettings() { async function saveSystemSettings() {
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
const checkboxes = document.querySelectorAll('.filter-checkbox:checked'); const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value); const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
try { try {
const result = await apiSaveTreeFilters(selectedHiddenKeys); const result = await apiSaveTreeFilters(selectedHiddenKeys, mode);
if (result.status === 'success') { if (result.status === 'success') {
const statusText = document.getElementById('settings-status'); const statusText = document.getElementById('settings-status');
statusText.style.display = 'inline-block'; statusText.style.display = 'inline-block';
@ -524,49 +818,65 @@ function closeModal(event) {
document.addEventListener("DOMContentLoaded", checkScanButtonState); document.addEventListener("DOMContentLoaded", checkScanButtonState);
// ========================================== // ==========================================
// 🛡️ 系統管理員雙重解鎖邏輯 (URL + 彩蛋) // 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯
// ========================================== // ==========================================
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const scanBtn = document.getElementById('global-scan-btn');
const clearBtn = document.getElementById('clearCacheBtn');
const appTitle = document.getElementById('app-title'); const appTitle = document.getElementById('app-title');
// 定義解鎖動作:把按鈕顯示出來
function unlockAdminFeatures() { function unlockAdminFeatures() {
if (scanBtn) scanBtn.style.display = 'inline-block'; ADMIN_BTN_IDS.forEach(id => {
if (clearBtn) clearBtn.style.display = 'inline-block'; const btn = document.getElementById(id);
if (btn) btn.style.display = 'inline-block';
});
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
btn.style.display = 'inline-block';
});
} }
// 方案一URL 隱藏密鑰檢查 (?mode=godmode)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('mode') === 'godmode') { if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
unlockAdminFeatures();
}
// 方案二:彩蛋解鎖 (對標題連點 5 次)
let clickCount = 0; let clickCount = 0;
let clickTimer = null; let clickTimer = null;
if (appTitle) { if (appTitle) {
appTitle.addEventListener('click', () => { appTitle.addEventListener('click', () => {
clickCount++; clickCount++;
if (clickCount === 5) { if (clickCount === 5) {
unlockAdminFeatures(); unlockAdminFeatures();
alert('🔓 已解鎖系統維護者模式!'); alert('🔓 已解鎖系統維護者模式!');
clickCount = 0; clickCount = 0;
} }
clearTimeout(clickTimer); clearTimeout(clickTimer);
clickTimer = setTimeout(() => { clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
clickCount = 0;
}, 1000);
}); });
} }
}); });
// 統一控制 4 顆按鈕的狀態
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = isBusy;
if (isBusy) {
btn.classList.add('is-busy');
if (id.includes('scan')) btn.innerText = text;
} else {
btn.classList.remove('is-busy');
// 恢復原本的文字
if (id === 'btn-scan-visible') btn.innerText = "🔍 掃描局部缺失選項";
if (id === 'btn-scan-global') btn.innerText = "🌍 掃描全域缺失選項";
}
}
});
}
function checkScanButtonState() { function checkScanButtonState() {
const btn = document.getElementById('global-scan-btn'); const btn = document.getElementById('btn-scan-visible');
if (!btn) return; if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil'); const scanLockUntil = localStorage.getItem('scanLockUntil');
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) { if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
@ -575,35 +885,23 @@ function checkScanButtonState() {
} }
function lockScanButton(durationMs) { function lockScanButton(durationMs) {
const btn = document.getElementById('global-scan-btn'); setAdminButtonsState(true, "⏳ 背景掃描執行中...");
if (!btn) return;
btn.disabled = true;
btn.style.backgroundColor = "#e67e22";
btn.style.cursor = "not-allowed";
btn.innerText = "⏳ 背景掃描執行中...";
setTimeout(() => { setTimeout(() => {
const treeContainer = document.getElementById('tree-container'); 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')) { if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) {
enableGlobalScanButton(); enableGlobalScanButton();
} else { } else {
btn.disabled = true; setAdminButtonsState(true, "⏳ 請先載入樹狀圖");
btn.style.backgroundColor = "#bdc3c7";
btn.innerText = "⏳ 請先載入樹狀圖";
} }
localStorage.removeItem('scanLockUntil'); localStorage.removeItem('scanLockUntil');
}, durationMs); }, durationMs);
} }
function enableGlobalScanButton() { function enableGlobalScanButton() {
const btn = document.getElementById('global-scan-btn');
if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil'); const scanLockUntil = localStorage.getItem('scanLockUntil');
if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) { if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) {
btn.disabled = false; setAdminButtonsState(false);
btn.style.backgroundColor = "#8e44ad";
btn.style.cursor = "pointer";
btn.innerText = "🔍 掃描畫面缺失選項";
} }
} }
@ -640,8 +938,12 @@ window.loadMacDomainList = loadMacDomainList;
window.openSystemSettings = openSystemSettings; window.openSystemSettings = openSystemSettings;
window.loadRealConfigForFilters = loadRealConfigForFilters; window.loadRealConfigForFilters = loadRealConfigForFilters;
window.saveSystemSettings = saveSystemSettings; window.saveSystemSettings = saveSystemSettings;
window.scanVisibleMissingOptions = scanVisibleMissingOptions;
window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache; window.clearVisibleCache = clearVisibleCache;
window.scanAllMissingOptions = scanAllMissingOptions; window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; // 🌟 新增綁定
// --- 樹狀圖展開與編輯操作 --- // --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll; window.expandAll = expandAll;

View File

@ -2,7 +2,8 @@
import { import {
apiAcquireLock, apiReleaseLock, apiSendHeartbeat, apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
apiSyncLeafOptionsStream // 🌟 新增這一個
} from './api.js'; } from './api.js';
import { getGlobalConnectionInfo } from './utils.js'; import { getGlobalConnectionInfo } from './utils.js';
@ -20,7 +21,7 @@ function escapeHTML(str) {
// ========================================== // ==========================================
// 1. 全域狀態與鎖定管理 (Lock Management) // 1. 全域狀態與鎖定管理 (Lock Management)
// ========================================== // ==========================================
const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {}; const ACTIVE_HEARTBEATS = {};
// 💡 追蹤當前編輯狀態 // 💡 追蹤當前編輯狀態
@ -28,15 +29,18 @@ export let currentEditPath = null;
export let currentEditElementId = null; export let currentEditElementId = null;
export let currentEditIsSuccess = false; export let currentEditIsSuccess = false;
export let currentEditType = null; export let currentEditType = null;
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑
export async function releaseAllLocks() { export async function releaseAllLocks() {
const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS); const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (pathsToRelease.length === 0) return; if (keysToRelease.length === 0) return;
const releasePromises = pathsToRelease.map(path => { const releasePromises = keysToRelease.map(key => {
clearInterval(ACTIVE_HEARTBEATS[path]); // 🌟 解析出 host 與 path
delete ACTIVE_HEARTBEATS[path]; const [host, path] = key.split('@@');
return apiReleaseLock(path, SESSION_USER_ID) clearInterval(ACTIVE_HEARTBEATS[key]);
delete ACTIVE_HEARTBEATS[key];
return apiReleaseLock(path, SESSION_USER_ID, host)
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err)); .catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
}); });
@ -45,14 +49,17 @@ export async function releaseAllLocks() {
location.reload(); location.reload();
} }
async function sendHeartbeat(path) { // 🌟 加入 host 與 lockKey 參數
async function sendHeartbeat(path, host, intervalId, lockKey) {
try { try {
const response = await apiSendHeartbeat(path, SESSION_USER_ID); const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
if (!response.ok) { if (!response.ok) {
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`); console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
if (response.status === 404) { if (response.status === 404) {
clearInterval(ACTIVE_HEARTBEATS[path]); clearInterval(intervalId);
delete ACTIVE_HEARTBEATS[path]; if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
delete ACTIVE_HEARTBEATS[lockKey];
}
} }
} }
} catch (error) { } catch (error) {
@ -63,49 +70,67 @@ async function sendHeartbeat(path) {
// ========================================== // ==========================================
// 2. 編輯模式啟動與取消 (Folder & Leaf) // 2. 編輯模式啟動與取消 (Folder & Leaf)
// ========================================== // ==========================================
export async function startEditFolder(path, elementId) { export async function startEditFolder(path, elementId) {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : ''; // 🌟 取得設備 IP
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (result.data.status === 'success') { if (result.data.status === 'success') {
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000); if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
let intervalId;
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; // 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; const editBtn = document.getElementById(`edit-btn-${elementId}`);
document.getElementById(`details-${elementId}`).open = true; 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}`); const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) {
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯
}
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {}; let optData = {};
try { optData = await apiGetLeafOptions(); } catch (e) { console.warn("無法取得選項快取", e); } // 🌟 修改 1傳入 host 給 apiGetLeafOptions
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
const pathsToSync = []; const pathsToSync = [];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
leafContainers.forEach(container => { // ==========================================
// 🌟 效能急救:分批渲染 (Chunking) 避免卡死主執行緒
// ==========================================
const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50; // 每次處理 50 個,確保畫面不結凍
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
chunk.forEach(container => {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const rawPath = container.getAttribute('data-path'); const rawPath = container.getAttribute('data-path');
// ✅ 修改這裡:新增 safeOrigVal
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (rawPath.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
let leafCache = optData[cacheKey]; let leafCache = optData[cacheKey];
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) { if (!leafCache) pathsToSync.push(cacheKey);
pathsToSync.push(cacheKey);
}
let inputHtml = ""; let inputHtml = "";
let hintAttr = ""; let hintAttr = "";
@ -123,23 +148,23 @@ export async function startEditFolder(path, elementId) {
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;">`; 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 => { options.forEach(opt => {
// ✅ 修改這裡:新增 safeOpt並替換 value 與顯示文字
const safeOpt = escapeHTML(opt); const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : ''; const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`; inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
}); });
inputHtml += `</select>${hintIcon}`; inputHtml += `</select>${hintIcon}`;
} else { } else {
// ✅ 修改這裡:把 value="${origVal}" 改成 value="${safeOrigVal}"
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}`; 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; container.innerHTML = inputHtml;
}); });
if (pathsToSync.length > 0) { // 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`); await new Promise(resolve => setTimeout(resolve, 0));
apiSyncLeafOptions(pathsToSync);
} }
// 🌟 傳入 host 給 apiSyncLeafOptions
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
} }
} catch (error) { } catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
@ -149,49 +174,50 @@ export async function startEditFolder(path, elementId) {
export async function startEditLeaf(path, elementId) { export async function startEditLeaf(path, elementId) {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (result.data.status === 'success') { if (result.data.status === 'success') {
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000); if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
let intervalId;
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:新增 safeOrigVal
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`; container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try { try {
let optData = await apiGetLeafOptions(); // 🌟 修改 1傳入 host
let optData = await apiGetLeafOptions(host, currentMode);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (path.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
let leafCache = optData[cacheKey]; let leafCache = optData[cacheKey];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) { if (!leafCache) {
apiSyncLeafOptions([cacheKey]); // 🌟 修改 2傳入 host
apiSyncLeafOptions(host, [cacheKey], currentMode);
let found = false; let found = false;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(); // 🌟 修改 3傳入 host
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey]; leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) { if (leafCache && leafCache.options && leafCache.options.length > 0) {
found = true; break; found = true; break;
} }
} }
if (!found) console.warn("⏳ 等待選項超時,退回純文字模式");
} }
let inputHtml = ""; let inputHtml = "";
@ -210,20 +236,16 @@ export async function startEditLeaf(path, elementId) {
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;">`; 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 => { options.forEach(opt => {
// ✅ 修改這裡:新增 safeOpt
const safeOpt = escapeHTML(opt); const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : ''; const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`; inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
}); });
inputHtml += `</select>${hintIcon}`; inputHtml += `</select>${hintIcon}`;
} else { } else {
// ✅ 修改這裡:使用 safeOrigVal
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}`; 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; container.innerHTML = inputHtml;
} catch (e) { } catch (e) {
console.warn("選項載入失敗", e);
// ✅ 修改這裡catch 區塊也要使用 safeOrigVal
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;">`; 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;">`;
} }
} }
@ -233,11 +255,15 @@ export async function startEditLeaf(path, elementId) {
} }
export async function cancelEditFolder(path, elementId) { export async function cancelEditFolder(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) { const connInfo = getGlobalConnectionInfo();
clearInterval(ACTIVE_HEARTBEATS[path]); const host = connInfo ? connInfo.host : '';
delete ACTIVE_HEARTBEATS[path]; const lockKey = `${host}@@${path}`;
if (ACTIVE_HEARTBEATS[lockKey]) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey];
} }
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {} try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'none';
@ -246,25 +272,27 @@ export async function cancelEditFolder(path, elementId) {
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
leafContainers.forEach(container => { leafContainers.forEach(container => {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:還原時也要跳脫
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`; container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
}); });
} }
export async function cancelEditLeaf(path, elementId) { export async function cancelEditLeaf(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) { const connInfo = getGlobalConnectionInfo();
clearInterval(ACTIVE_HEARTBEATS[path]); const host = connInfo ? connInfo.host : '';
delete ACTIVE_HEARTBEATS[path]; const lockKey = `${host}@@${path}`;
if (ACTIVE_HEARTBEATS[lockKey]) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey];
} }
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {} try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'none';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:還原時也要跳脫
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`; container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
} }
@ -273,7 +301,12 @@ export async function cancelEditLeaf(path, elementId) {
// 3. CLI 生成與預覽 (CLI Generation) // 3. CLI 生成與預覽 (CLI Generation)
// ========================================== // ==========================================
function getInterfacesWithAdminState() { function getInterfacesWithAdminState() {
const nodes = document.querySelectorAll('.leaf-container'); // 🌟 限制只從當前顯示的樹狀圖中抓取狀態
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(); const interfaces = new Set();
nodes.forEach(node => { nodes.forEach(node => {
const path = node.getAttribute('data-path'); const path = node.getAttribute('data-path');
@ -310,6 +343,7 @@ export async function previewFolderCLI(path, elementId) {
}); });
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!"); if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
try { try {
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
@ -341,6 +375,7 @@ export async function previewLeafCLI(path, elementId) {
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!"); if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }]; const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
try { try {
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
@ -355,7 +390,8 @@ export async function previewLeafCLI(path, elementId) {
} }
function showCliPreviewModal(cliScript) { function showCliPreviewModal(cliScript) {
const leftPane = document.getElementById('tree-container'); // 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer'); const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview'); const rightPane = document.getElementById('side-cli-preview');
@ -369,7 +405,8 @@ function showCliPreviewModal(cliScript) {
document.getElementById('side-cli-textarea').value = cliScript; document.getElementById('side-cli-textarea').value = cliScript;
leftPane.style.width = 'calc(50% - 11px)'; // 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
rightPane.style.width = 'calc(50% - 11px)'; rightPane.style.width = 'calc(50% - 11px)';
resizer.style.display = 'flex'; resizer.style.display = 'flex';
rightPane.style.display = 'block'; rightPane.style.display = 'block';
@ -384,7 +421,8 @@ function showCliPreviewModal(cliScript) {
// 4. 側邊欄與執行邏輯 // 4. 側邊欄與執行邏輯
// ========================================== // ==========================================
export async function hideSideCLI() { export async function hideSideCLI() {
document.getElementById('tree-container').style.width = '100%'; // 🌟 修改:改用 class 統一控制所有樹狀圖容器
document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%');
document.getElementById('drag-resizer').style.display = 'none'; document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none'; document.getElementById('side-cli-preview').style.display = 'none';
@ -456,15 +494,45 @@ async function executeGeneratedCLI(script) {
try { try {
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass);
document.getElementById('btn-side-close').style.display = 'inline-block';
if (result.status === 'success') { if (result.status === 'success') {
currentEditIsSuccess = true; currentEditIsSuccess = true;
document.getElementById('side-pane-title').innerHTML = '✅ 執行成功';
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...';
document.getElementById('side-pane-title').style.color = '#f39c12';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...</span>`;
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 {
// 🌟 修改:取得當前模式,並將 connInfo.host 與 currentMode 傳給 apiSyncLeafOptionsStream
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
if (data.event === 'done') {
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成';
document.getElementById('side-pane-title').style.color = '#2ecc71'; document.getElementById('side-pane-title').style.color = '#2ecc71';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}</span>`; document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
} else if (data.event === 'error') {
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
document.getElementById('side-pane-title').style.color = '#e74c3c';
document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message}</span>`;
}
}, currentMode);
} catch (syncErr) {
document.getElementById('btn-side-close').style.display = 'inline-block';
}
} else { } else {
currentEditIsSuccess = false; currentEditIsSuccess = false;
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗'; document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
document.getElementById('side-pane-title').style.color = '#ff6b6b'; document.getElementById('side-pane-title').style.color = '#ff6b6b';
const rawLog = result.detail || result.message || ''; const rawLog = result.detail || result.message || '';
@ -483,7 +551,8 @@ async function executeGeneratedCLI(script) {
// ========================================== // ==========================================
function initResizer() { function initResizer() {
const resizer = document.getElementById('drag-resizer'); const resizer = document.getElementById('drag-resizer');
const leftPane = document.getElementById('tree-container'); // 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const rightPane = document.getElementById('side-cli-preview'); const rightPane = document.getElementById('side-cli-preview');
const container = document.getElementById('split-container'); const container = document.getElementById('split-container');
let isResizing = false; let isResizing = false;
@ -505,7 +574,8 @@ function initResizer() {
const leftPercentage = (newLeftWidth / containerRect.width) * 100; const leftPercentage = (newLeftWidth / containerRect.width) * 100;
const rightPercentage = 100 - leftPercentage; const rightPercentage = 100 - leftPercentage;
leftPane.style.width = `calc(${leftPercentage}% - 11px)`; // 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
rightPane.style.width = `calc(${rightPercentage}% - 11px)`; rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
}); });
@ -557,3 +627,89 @@ function transformNoToActive(elementId, newCommand) {
if (actionBtns) actionBtns.style.display = 'none'; if (actionBtns) actionBtns.style.display = 'none';
if (editBtn) editBtn.style.display = 'none'; if (editBtn) editBtn.style.display = 'none';
} }
// ============================================================================
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
// ============================================================================
export function previewNodeCLI(btnElement, rootPath) {
// 1. 定位當前點擊的容器
let container = btnElement.closest('details');
if (!container) {
const leafNode = btnElement.closest('.tree-leaf-node');
if (leafNode) container = leafNode.querySelector('.leaf-container');
}
if (!container) return;
let cliOutput = `! =========================================\n`;
cliOutput += `! 🔍 [Debug] 節點預覽: ${rootPath}\n`;
cliOutput += `! =========================================\n`;
cliOutput += `${rootPath.replace(/::/g, ' ')}\n`;
// 2. 找出該節點下所有的葉節點 (包含自己)
const allLeaves = container.classList.contains('leaf-container')
? [container]
: Array.from(container.querySelectorAll('.leaf-container'));
if (allLeaves.length === 1 && allLeaves[0] === container) {
// 情況 A它自己就是最底層的葉節點
const val = container.getAttribute('data-original');
if (val) cliOutput += ` ${val}\n`;
} else {
// 情況 B它是一個資料夾遞迴列出所有子項目的相對路徑與值
allLeaves.forEach(leaf => {
const leafPath = leaf.getAttribute('data-path');
const leafVal = leaf.getAttribute('data-original');
if (leafPath && leafVal !== null) {
const relativePath = leafPath.replace(rootPath, '').replace(/^::/, '').replace(/::/g, ' ');
cliOutput += ` ${relativePath} ${leafVal}\n`;
}
});
}
cliOutput += `exit\n`;
// 3. 呼叫現有的樹狀圖側邊欄 (Side Pane) 顯示
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview');
if (!rightPane) {
console.error("找不到側邊欄元素 'side-cli-preview'");
return;
}
// 設定標題與樣式
document.getElementById('side-pane-title').innerHTML = '🔍 節點指令預覽 (唯讀)';
document.getElementById('side-pane-title').style.color = '#3498db';
// 隱藏「確認寫入」按鈕,只顯示「取消/關閉」按鈕
document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-cancel').style.display = 'inline-block';
document.getElementById('btn-side-close').style.display = 'none';
// 顯示 Textarea 並填入內容
const textarea = document.getElementById('side-cli-textarea');
textarea.style.display = 'block';
textarea.value = cliOutput;
textarea.style.backgroundColor = '#1e1e1e'; // 終端機深色背景
textarea.style.color = '#d4d4d4';
document.getElementById('side-execution-result').style.display = 'none';
// 展開側邊欄
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
rightPane.style.width = 'calc(50% - 11px)';
if (resizer) resizer.style.display = 'flex';
rightPane.style.display = 'block';
// 初始化拖拉分隔線 (如果尚未初始化)
if (!window.isResizerInitialized && typeof initResizer === 'function') {
initResizer();
window.isResizerInitialized = true;
}
}
// --- EOF (檔案結束) ---

BIN
static/my_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

View File

@ -1,5 +1,87 @@
/* --- 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% 縮放感) */ /* 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; } 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; } h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-size: 24px; }

View File

@ -1,14 +1,57 @@
// --- static/tree-ui.js --- // --- static/tree-ui.js ---
// ========================================== // ==========================================
// 🌟 輔助函數:全部展開與全部收合 // 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ==========================================
export const folderDataCache = {};
export const filterFolderCache = {};
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
window.lazyLoadFolder = function(elementId, isFilter = false) {
const detailsEl = document.getElementById(`details-${elementId}`);
// 如果已經載入過,就不重複渲染
if (!detailsEl || detailsEl.dataset.loaded === 'true') return;
// 從 dataset 讀取當初存下來的屬性
const currentPath = detailsEl.dataset.path;
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
const mode = detailsEl.dataset.mode;
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
if (contentDiv && cache) {
if (isFilter) {
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
} else {
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
}
detailsEl.dataset.loaded = 'true'; // 標記為已載入
}
};
// ==========================================
// 🌟 輔助函數:全部展開與全部收合 (支援延遲渲染)
// ========================================== // ==========================================
export function expandAll(elementId) { export function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`); const details = document.getElementById(`details-${elementId}`);
if (details) { if (details) {
details.open = true; // 展開自己 // 如果還沒載入,先強制載入它
const subDetails = details.querySelectorAll('details'); if (!details.dataset.loaded) {
subDetails.forEach(d => d.open = true); // 展開所有子項目 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); // 遞迴展開
});
}
} }
} }
@ -16,15 +59,15 @@ export function collapseAll(elementId) {
const details = document.getElementById(`details-${elementId}`); const details = document.getElementById(`details-${elementId}`);
if (details) { if (details) {
const subDetails = details.querySelectorAll('details'); const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = false); // 收合所有子項目 subDetails.forEach(d => d.open = false);
details.open = false; // 收合自己 details.open = false;
} }
} }
// ========================================== // ==========================================
// 🌟 核心函數:生成完整設備配置樹狀圖 // 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ========================================== // ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false) { export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
let html = ''; let html = '';
if (!node || typeof node !== 'object') return html; if (!node || typeof node !== 'object') return html;
@ -35,9 +78,12 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null); const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject; const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; 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>' : ''; 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 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 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 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>`;
@ -64,9 +110,11 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
</span> </span>
`; `;
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
html += ` html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';"> 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" <summary class="tree-folder-header"
title="${cliCommand}" title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -81,7 +129,15 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
${expandCollapseBtns} ${expandCollapseBtns}
<div style="margin-left: auto; display: flex; align-items: center;"> <div style="margin-left: auto; display: flex; align-items: center;">
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')" <span class="debug-preview-btn admin-only"
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
title="[開發者] 預覽此節點下所有指令"
onmouseover="this.style.transform='scale(1.2)'"
onmouseout="this.style.transform='scale(1)'">
🔍
</span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
@ -93,15 +149,16 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
</div> </div>
</summary> </summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;"> <div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
${buildTree(value, currentPath, isCommandGroup)} <!-- 🌟 延遲渲染這裡一開始是空的展開時才會填入 -->
</div> </div>
</details> </details>
`; `;
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no'); const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
if (isNoCommand) { if (isNoCommand) {
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim(); let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
@ -151,7 +208,15 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
${displayContent} ${displayContent}
<div style="margin-left: auto; display: flex; align-items: center;"> <div style="margin-left: auto; display: flex; align-items: center;">
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')" <span class="debug-preview-btn admin-only"
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
title="[開發者] 預覽此節點下所有指令"
onmouseover="this.style.transform='scale(1.2)'"
onmouseout="this.style.transform='scale(1)'">
🔍
</span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
@ -169,9 +234,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
} }
// ========================================== // ==========================================
// 🌟 核心函數:生成系統設定的過濾樹狀圖 // 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
// ========================================== // ==========================================
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false) { export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
if (typeof data !== 'object' || data === null) return ''; if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`; let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
@ -196,7 +261,10 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
if (isFolder) { if (isFolder) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null); const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject; const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `filter-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
// 🌟 存入過濾器專用快取
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed; const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen; const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
@ -220,8 +288,11 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
</span> </span>
`; `;
// 🌟 加入 lazyLoadFolder 觸發
html += ` html += `
<details id="details-${elementId}" class="tree-folder" ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';"> <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;"> <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> <style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
${checkboxHtml} ${checkboxHtml}
@ -232,12 +303,13 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<span>${key}${folderSuffix}</span> <span>${key}${folderSuffix}</span>
${expandCollapseBtns} ${expandCollapseBtns}
</summary> </summary>
<div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;"> <div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)} <!-- 🌟 延遲渲染 -->
</div> </div>
</details> </details>
`; `;
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`; let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");

View File

@ -1,7 +1,3 @@
{ {
"hidden_keys": [ "hidden_keys": []
"ssh",
"hostname",
"clock"
]
} }