Compare commits
10 Commits
2f4c0c2a1a
...
fd36ca2fad
| Author | SHA1 | Date |
|---|---|---|
|
|
fd36ca2fad | |
|
|
5851166112 | |
|
|
9585a4c958 | |
|
|
7b5337383b | |
|
|
df90aa50fe | |
|
|
d7d6e63a7c | |
|
|
0afac3831c | |
|
|
f86a449642 | |
|
|
bee63b78ec | |
|
|
40a8227b45 |
|
|
@ -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/
|
||||
|
|
@ -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`。
|
||||
|
|
@ -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`。
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
cmts_api_env/
|
||||
*_cache.json
|
||||
filters_*.json
|
||||
*.bk
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because it is too large
Load Diff
|
|
@ -5,6 +5,8 @@ import re
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
import database
|
||||
from shared import USE_DB
|
||||
|
||||
def parse_question_mark_output(output: str) -> dict:
|
||||
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
|
||||
|
|
@ -142,7 +144,8 @@ def parse_device_response(output: str) -> dict:
|
|||
|
||||
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:
|
||||
total_paths = len(leaf_paths)
|
||||
processed_count = 0
|
||||
|
|
@ -150,6 +153,8 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
|||
BATCH_SIZE = 30
|
||||
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
|
||||
|
||||
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
||||
conn = None
|
||||
|
|
@ -172,11 +177,23 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
|||
break
|
||||
return output
|
||||
|
||||
# 🌟 新增:在第一批次連線時,先抓取設備版本
|
||||
if cmts_version == "unknown":
|
||||
process.stdin.write("show version\n")
|
||||
await process.stdin.drain()
|
||||
version_output = await read_until_quiet(timeout=1.5)
|
||||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
||||
if match:
|
||||
cmts_version = match.group(1)
|
||||
|
||||
process.stdin.write("config\n")
|
||||
await process.stdin.drain()
|
||||
await read_until_quiet(timeout=1.5)
|
||||
|
||||
for path in batch:
|
||||
# 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# --- 步驟 1:發送 '?' 查詢 ---
|
||||
command_to_send = f"{path} ?"
|
||||
process.stdin.write(command_to_send)
|
||||
|
|
@ -198,14 +215,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
|||
}
|
||||
|
||||
# --- 步驟 2:判斷是否需要發送 Enter ---
|
||||
# 🌟 配合新規則:如果 '?' 已經明確告訴我們這是 <格式> 欄位,直接跳過 Enter!
|
||||
if q_data.get("is_format_only"):
|
||||
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"]:
|
||||
# 原本的 Enter 邏輯
|
||||
process.stdin.write(f"{path}\n")
|
||||
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["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":
|
||||
process.stdin.write("\x03")
|
||||
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
|
||||
|
||||
# 🌟 核心修改:每處理完一個路徑,就推播一次進度!
|
||||
processed_count += 1
|
||||
event_data = {
|
||||
"event": "progress",
|
||||
|
|
@ -251,24 +260,75 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
|||
finally:
|
||||
if conn: conn.close()
|
||||
|
||||
# --- 步驟 3:寫入快取 (DB or JSON) ---
|
||||
try:
|
||||
cache_file = "cmts_leaf_options_cache.json"
|
||||
cache_data = {}
|
||||
if os.path.exists(cache_file):
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
db_success = False
|
||||
current_ts = int(time.time())
|
||||
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
|
||||
# 1. 嘗試寫入資料庫
|
||||
if USE_DB:
|
||||
try:
|
||||
# 寫入 metadata
|
||||
if cmts_version != "unknown":
|
||||
await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
|
||||
else:
|
||||
await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time})
|
||||
|
||||
# 寫入 options
|
||||
db_write_count = 0
|
||||
for p in batch:
|
||||
if p in result_data:
|
||||
result_data[p]["updated_at"] = current_ts
|
||||
success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
|
||||
if success:
|
||||
db_write_count += 1
|
||||
|
||||
if db_write_count > 0:
|
||||
db_success = True
|
||||
print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
|
||||
else:
|
||||
print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
|
||||
|
||||
# 2. 如果 DB 寫入失敗或 USE_DB=False,則 Fallback 寫入 JSON
|
||||
if not db_success:
|
||||
# 🌟 動態決定快取檔名 (加入 IP 隔離)
|
||||
safe_host = host.replace(".", "_")
|
||||
cache_file = f"{safe_host}_{config_type}_cache.json"
|
||||
|
||||
def read_json_from_file(filepath):
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
|
||||
|
||||
# 🌟 新增:寫入 Metadata (版本與掃描時間)
|
||||
if "__metadata__" not in cache_data:
|
||||
cache_data["__metadata__"] = {}
|
||||
|
||||
if cmts_version != "unknown":
|
||||
cache_data["__metadata__"]["cmts_version"] = cmts_version
|
||||
|
||||
cache_data["__metadata__"]["last_scanned"] = formatted_time
|
||||
|
||||
for p in batch:
|
||||
if p in result_data:
|
||||
result_data[p]["updated_at"] = current_ts
|
||||
cache_data[p] = result_data[p]
|
||||
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cache_data, f, indent=4, ensure_ascii=False)
|
||||
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已提早寫入快取檔!")
|
||||
def write_json_to_file(filepath, data):
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
|
||||
print(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ 提早寫入快取失敗: {e}")
|
||||
print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
|
||||
|
||||
if batch_idx < len(batches) - 1:
|
||||
await asyncio.sleep(2)
|
||||
|
|
@ -277,3 +337,4 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
|||
|
||||
except Exception as e:
|
||||
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"hidden_keys": []
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"hidden_keys": []
|
||||
}
|
||||
91
index.html
91
index.html
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Harmonic CMTS Manager</title>
|
||||
<link rel="icon" type="image/png" href="/static/my_icon.png">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
|
|
@ -23,8 +24,8 @@
|
|||
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;">
|
||||
|
||||
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
|
||||
<button onclick="connectWebSocket()" id="btnConnect" style="background-color: #27ae60;">連線至 CMTS</button>
|
||||
<button onclick="disconnectWebSocket()" id="btnDisconnect" style="background-color: #c0392b; display: none;">斷開連線</button>
|
||||
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
|
||||
<button onclick="disconnectWebSocket()" id="btnDisconnect" class="btn-modern btn-disconnect" style="display: none;">斷開連線</button>
|
||||
<span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -42,14 +43,14 @@
|
|||
<div class="control-group">
|
||||
<label><strong>快捷指令:</strong></label>
|
||||
<select id="fixedCmd">
|
||||
<option value="show cable modem">數據機狀態 (show cable modem)</option>
|
||||
<option value="show cable rpd">RPD 狀態 (show cable rpd)</option>
|
||||
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
|
||||
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
|
||||
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
|
||||
</select>
|
||||
<button onclick="injectCommand()" style="background-color: #8e44ad;">送出指令</button>
|
||||
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
|
||||
|
||||
<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>
|
||||
|
||||
|
|
@ -109,7 +110,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<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;">
|
||||
<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>
|
||||
|
|
@ -144,7 +145,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<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;">
|
||||
<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>
|
||||
|
|
@ -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;">
|
||||
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">🛠️ 選擇配置任務:</label>
|
||||
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
|
||||
<!-- 🌟 拆分為兩個獨立的選項 -->
|
||||
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
|
||||
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
|
||||
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
|
||||
<option value="form-full-config">🌳 完整設備配置樹狀圖</option>
|
||||
</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>
|
||||
|
||||
<!-- 表單 3:MAC 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;">
|
||||
<option value="">請先點擊上方載入任務...</option>
|
||||
</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>
|
||||
</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 class="form-actions">
|
||||
<button id="btnRevert" onclick="revertFormChanges()" style="background-color: #7f8c8d; color: white; margin-right: 15px;">
|
||||
🔄 復原重填
|
||||
</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>
|
||||
<button id="btnRevert" onclick="revertFormChanges()" class="btn-modern btn-disconnect" style="margin-right: 15px;">🔄 復原重填</button>
|
||||
<button onclick="generateMacDomainCLI()" class="btn-modern btn-scan">⚙️ 產生絕對路徑指令預覽</button>
|
||||
<button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表單 4:完整設備配置樹狀圖 -->
|
||||
<div id="form-full-config" class="task-form" style="display: none;">
|
||||
<!-- 表單 4:設備配置樹狀圖 (共用容器) -->
|
||||
<!-- 🌟 修正 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;">
|
||||
🌳 完整設備配置樹狀圖
|
||||
|
||||
<!-- 🌟 掃描按鈕 -->
|
||||
<button id="global-scan-btn" onclick="scanAllMissingOptions()" disabled
|
||||
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);">
|
||||
🔍 掃描畫面缺失選項
|
||||
<!-- 🌟 修正 2:加上 span 與 ID,用來動態切換文字 -->
|
||||
<span id="tree-form-title">🌳 設備配置樹狀圖</span>
|
||||
|
||||
<!-- 🌟 掃描與清除按鈕群組 -->
|
||||
<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 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 id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none; margin-left: 10px;">
|
||||
🌍 掃描全域缺失選項
|
||||
</button>
|
||||
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none; margin-left: 10px;">
|
||||
🔥 清除全域快取
|
||||
</button>
|
||||
|
||||
<!-- 🌟 新增:掃描進度提示 -->
|
||||
|
|
@ -277,14 +284,13 @@
|
|||
<!-- 🌟 左右分屏容器 -->
|
||||
<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;">
|
||||
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||
</div> -->
|
||||
<!-- 左側:樹狀圖 (雙容器實體隔離) -->
|
||||
<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;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||
</div>
|
||||
|
||||
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) -->
|
||||
<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;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||
<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;">
|
||||
<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||
</div>
|
||||
|
||||
<!-- 🖱️ 拖曳分隔線 (預設隱藏) -->
|
||||
|
|
@ -301,11 +307,11 @@
|
|||
|
||||
<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-confirm" onclick="executeSideCLI()" style="background-color: #27ae60; 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()" 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>
|
||||
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">×</span>
|
||||
|
|
@ -339,10 +345,19 @@
|
|||
</p>
|
||||
</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>
|
||||
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
|
||||
</div>
|
||||
|
||||
<!-- 樹狀 Checkbox 容器 -->
|
||||
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
|
||||
|
|
|
|||
99
init_db.py
99
init_db.py
|
|
@ -1,66 +1,71 @@
|
|||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import logging
|
||||
|
||||
# 💡 資料庫連線設定 (請確認密碼與步驟一設定的相同)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DB 設定
|
||||
DB_CONFIG = {
|
||||
"dbname": "cmts_nms",
|
||||
"database": "cmts_nms",
|
||||
"user": "swpa",
|
||||
"password": "swpa4920", # 替換為您的密碼
|
||||
"host": "127.0.0.1", # 如果 DB 在同一台機器上
|
||||
"password": "swpa4920",
|
||||
"host": "127.0.0.1",
|
||||
"port": "5432"
|
||||
}
|
||||
|
||||
def init_database():
|
||||
async def init_database():
|
||||
try:
|
||||
# 建立連線
|
||||
print("🔄 正在連線到 PostgreSQL...")
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...")
|
||||
conn = await asyncpg.connect(
|
||||
database=DB_CONFIG["database"],
|
||||
user=DB_CONFIG["user"],
|
||||
password=DB_CONFIG["password"],
|
||||
host=DB_CONFIG["host"],
|
||||
port=int(DB_CONFIG["port"])
|
||||
)
|
||||
|
||||
# 1. 建立 CLI 字典表 (Global-Ready Schema)
|
||||
print("🛠️ 正在建立 cli_schema_dictionary 資料表...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cli_schema_dictionary (
|
||||
id SERIAL PRIMARY KEY,
|
||||
path VARCHAR(255) UNIQUE NOT NULL,
|
||||
node_type VARCHAR(50) DEFAULT 'leaf',
|
||||
options JSONB DEFAULT '[]'::jsonb,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
# 1. 建立選項快取表 cmts_options
|
||||
logger.info("🛠️ 正在建立 cmts_options 資料表...")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cmts_options (
|
||||
host VARCHAR(255) NOT NULL,
|
||||
config_type VARCHAR(50) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (host, config_type, path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_schema_path ON cli_schema_dictionary(path);
|
||||
""")
|
||||
|
||||
# 2. 建立系統狀態表
|
||||
print("🛠️ 正在建立 system_metadata 資料表...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_metadata (
|
||||
key VARCHAR(50) PRIMARY KEY,
|
||||
value VARCHAR(255) NOT NULL,
|
||||
# 2. 建立設備狀態表 device_status
|
||||
logger.info("🛠️ 正在建立 device_status 資料表...")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS device_status (
|
||||
host VARCHAR(255) NOT NULL,
|
||||
config_type VARCHAR(50) NOT NULL,
|
||||
cmts_version VARCHAR(100) DEFAULT 'unknown',
|
||||
last_scanned VARCHAR(100),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (host, config_type)
|
||||
);
|
||||
""")
|
||||
|
||||
# 3. 建立過濾器設定表 system_filters
|
||||
logger.info("🛠️ 正在建立 system_filters 資料表...")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_filters (
|
||||
config_type VARCHAR(50) PRIMARY KEY,
|
||||
hidden_keys TEXT[] DEFAULT '{}',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
|
||||
# 3. 寫入初始狀態預設值 (使用 ON CONFLICT 避免重複執行時報錯)
|
||||
print("📝 寫入系統狀態預設值...")
|
||||
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("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||||
await conn.close()
|
||||
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 資料庫初始化失敗: {e}")
|
||||
logger.error(f"❌ 資料庫初始化失敗: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_database()
|
||||
asyncio.run(init_database())
|
||||
|
|
|
|||
12
main.py
12
main.py
|
|
@ -3,11 +3,21 @@ import os
|
|||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import database
|
||||
|
||||
# 引入我們剛剛拆分出來的路由模組
|
||||
from routers import query, config, terminal, lock, leaf_options
|
||||
|
||||
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)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
|
|
|||
|
|
@ -36,3 +36,4 @@ uvicorn==0.46.0
|
|||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==16.0
|
||||
asyncpg==0.30.0
|
||||
|
|
|
|||
|
|
@ -1,15 +1,69 @@
|
|||
# --- routers/config.py ---
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import database
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from netmiko import ConnectHandler
|
||||
# 💡 確保從 shared 引入 deep_split_tree
|
||||
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings
|
||||
# 🌟 移除了舊的 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
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ==========================================
|
||||
# 🌟 雙軌過濾器檔案讀寫輔助函數 (加入高可用性 Fallback)
|
||||
# ==========================================
|
||||
def get_filter_file_path(config_type: str) -> str:
|
||||
# 確保檔名安全,只允許 'running' 或 'full'
|
||||
safe_type = "full" if config_type == "full" else "running"
|
||||
return f"filters_{safe_type}.json"
|
||||
|
||||
async def load_tree_filters(config_type: str) -> list:
|
||||
if USE_DB:
|
||||
try:
|
||||
db_filters = await database.get_tree_filters(config_type)
|
||||
if db_filters is not None:
|
||||
return db_filters
|
||||
else:
|
||||
print("⚠️ [Fallback] 資料庫無法取得過濾器,自動切換至 JSON 快取讀取...")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Fallback] 資料庫讀取過濾器發生例外: {e},自動切換至 JSON 快取讀取...")
|
||||
|
||||
file_path = get_filter_file_path(config_type)
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get("hidden_keys", [])
|
||||
except Exception as e:
|
||||
print(f"讀取過濾器檔案失敗: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
async def save_tree_filters(config_type: str, hidden_keys: list):
|
||||
db_success = False
|
||||
if USE_DB:
|
||||
try:
|
||||
if await database.upsert_tree_filters(config_type, hidden_keys):
|
||||
db_success = True
|
||||
else:
|
||||
print("⚠️ [Fallback] 資料庫儲存過濾器失敗,自動切換至 JSON 快取寫入...")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Fallback] 資料庫寫入過濾器發生例外: {e},自動切換至 JSON 快取寫入...")
|
||||
|
||||
if not db_success:
|
||||
file_path = get_filter_file_path(config_type)
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({"hidden_keys": hidden_keys}, f, ensure_ascii=False, indent=4)
|
||||
except Exception as e:
|
||||
print(f"儲存過濾器檔案失敗: {e}")
|
||||
|
||||
# ==========================================
|
||||
|
||||
class ConfigRequest(BaseModel):
|
||||
script: str
|
||||
host: str
|
||||
|
|
@ -24,7 +78,7 @@ class DiffItem(BaseModel):
|
|||
|
||||
class GenerateCliRequest(BaseModel):
|
||||
diffs: List[DiffItem]
|
||||
interfaces_with_admin_state: List[str] = [] # 💡 接收前端傳來的動態探測名單
|
||||
interfaces_with_admin_state: List[str] = []
|
||||
|
||||
@router.post("/cmts-config")
|
||||
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('!')]
|
||||
|
||||
net_connect = ConnectHandler(**device)
|
||||
# 🌟 1. 手動進入設定模式 (恢復您原本正確的寫法)
|
||||
# 1. 手動進入設定模式
|
||||
net_connect.send_command_timing("config")
|
||||
|
||||
# 🌟 2. 批次送出設定指令
|
||||
# 加上 enter_config_mode=False 與 exit_config_mode=False
|
||||
# 這樣 Netmiko 就不會自作主張去打 config terminal 了
|
||||
# 2. 批次送出設定指令
|
||||
output = net_connect.send_config_set(
|
||||
commands,
|
||||
read_timeout=60,
|
||||
|
|
@ -52,14 +104,13 @@ async def execute_cmts_config(req: ConfigRequest):
|
|||
exit_config_mode=False
|
||||
)
|
||||
|
||||
# 🌟 3. 手動退出設定模式
|
||||
# 3. 手動退出設定模式
|
||||
net_connect.send_command_timing("exit")
|
||||
net_connect.disconnect()
|
||||
|
||||
# 🌟 新增:檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
|
||||
# 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
|
||||
lower_output = output.lower()
|
||||
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output:
|
||||
# 回傳錯誤狀態,前端就會顯示紅色字體且「不會」自動關閉視窗
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "CMTS 拒絕了設定 (Commit Aborted)",
|
||||
|
|
@ -76,21 +127,16 @@ async def generate_cli(req: GenerateCliRequest):
|
|||
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
|
||||
並自動處理 admin-state 的安全生命週期 (先 down 後 up)
|
||||
"""
|
||||
# 用來將同一個介面的變更群組化
|
||||
interface_groups = defaultdict(list)
|
||||
|
||||
# 用來記錄該介面最終的 admin-state 應該是什麼
|
||||
interface_admin_states = {}
|
||||
|
||||
for diff in req.diffs:
|
||||
# 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency']
|
||||
parts = diff.path.split('::')
|
||||
|
||||
param_name = 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)
|
||||
|
||||
if param_name == "admin-state":
|
||||
|
|
@ -98,21 +144,17 @@ async def generate_cli(req: GenerateCliRequest):
|
|||
else:
|
||||
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
|
||||
|
||||
# 開始組裝最終的 CLI 腳本
|
||||
cli_lines = []
|
||||
cli_lines.append("! --- Auto-Generated Configuration Script ---")
|
||||
|
||||
for interface, changes in interface_groups.items():
|
||||
cli_lines.append(f"! Configuring: {interface}")
|
||||
|
||||
# 💡 核心邏輯:判斷這個特定區塊是否支援 admin-state
|
||||
supports_admin_state = interface in req.interfaces_with_admin_state
|
||||
|
||||
# 1. 安全機制:如果支援,才強制將介面 admin-state down
|
||||
if supports_admin_state:
|
||||
cli_lines.append(f"{interface} admin-state down")
|
||||
|
||||
# 2. 寫入所有被修改的參數
|
||||
for param, old_val, new_val in changes:
|
||||
if re.match(r"^\[\d+\]$", param):
|
||||
if new_val == "":
|
||||
|
|
@ -128,16 +170,13 @@ async def generate_cli(req: GenerateCliRequest):
|
|||
else:
|
||||
cli_lines.append(f"{interface} {param} {new_val}")
|
||||
|
||||
# 3. 恢復狀態:如果支援,才恢復為 up 或使用者指定的值
|
||||
if supports_admin_state:
|
||||
final_state = interface_admin_states.get(interface, "up")
|
||||
cli_lines.append(f"{interface} admin-state {final_state}")
|
||||
|
||||
# Harmonic 必須要有 commit 才會生效
|
||||
cli_lines.append("commit")
|
||||
cli_lines.append("!") # 空行分隔
|
||||
cli_lines.append("!")
|
||||
|
||||
# 將獨立修改 admin-state (沒有修改其他參數) 的情況也補上
|
||||
for interface, state in interface_admin_states.items():
|
||||
if interface not in interface_groups:
|
||||
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("!")
|
||||
|
||||
# 將陣列組合成字串回傳
|
||||
final_script = "\n".join(cli_lines)
|
||||
|
||||
return {"status": "success", "data": final_script}
|
||||
|
||||
# ==========================================
|
||||
# 💡 以下為 DS RF Port 動態樹狀查詢 API
|
||||
# DS RF Port 動態樹狀查詢 API
|
||||
# ==========================================
|
||||
|
||||
@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})
|
||||
|
||||
net_connect = ConnectHandler(**device)
|
||||
|
||||
# 使用 include 只抓取關鍵字,並把等待時間延長至 60 秒
|
||||
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()
|
||||
|
||||
# 使用正規表達式擷取 port 號碼 (例如 62:0/0)
|
||||
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
|
||||
ports = re.findall(pattern, raw_output)
|
||||
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)
|
||||
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()
|
||||
|
||||
# 1. 初步解析縮排
|
||||
base_tree = parse_cli_to_tree(raw_output)
|
||||
# 2. 💡 深層解析空白字元,建立完美樹狀結構
|
||||
perfect_tree = deep_split_tree(base_tree)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": perfect_tree, # 回傳完美的樹狀結構
|
||||
"data": perfect_tree,
|
||||
"raw_cli": raw_output.strip()
|
||||
}
|
||||
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")
|
||||
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"""
|
||||
try:
|
||||
device = CMTS_DEVICE.copy()
|
||||
device.update({'host': host, 'username': username, 'password': password})
|
||||
|
||||
net_connect = ConnectHandler(**device)
|
||||
|
||||
if config_type == "full":
|
||||
net_connect.send_command_timing("config")
|
||||
command = "show full-configuration | nomore"
|
||||
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
||||
net_connect.send_command_timing("exit")
|
||||
else:
|
||||
command = "show running-config | nomore"
|
||||
raw_output = net_connect.send_command(command, read_timeout=60)
|
||||
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
||||
|
||||
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)
|
||||
|
||||
# ==========================================
|
||||
# 💡 深度過濾攔截器:支援多層級路徑 (如 cable.mac-domain)
|
||||
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
|
||||
# ==========================================
|
||||
if not skip_filter:
|
||||
settings = load_settings()
|
||||
hidden_keys = settings.get("hidden_keys", [])
|
||||
hidden_keys = await load_tree_filters(config_type)
|
||||
|
||||
for path in hidden_keys:
|
||||
keys = path.split('.')
|
||||
keys = path.split('::')
|
||||
current = perfect_tree
|
||||
# 走到倒數第二層
|
||||
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)}
|
||||
|
||||
# ==========================================
|
||||
# 💡 系統設定 API (System Settings)
|
||||
# 🌟 系統設定 API (System Settings)
|
||||
# ==========================================
|
||||
class SettingsRequest(BaseModel):
|
||||
hidden_keys: list[str]
|
||||
config_type: str = "running" # 🌟 加入 config_type 參數
|
||||
|
||||
@router.get("/settings/tree-filters")
|
||||
async def get_tree_filters():
|
||||
async def get_tree_filters(config_type: str = "running"):
|
||||
"""獲取目前的樹狀圖隱藏名單"""
|
||||
settings = load_settings()
|
||||
return {"status": "success", "data": settings["hidden_keys"]}
|
||||
# 🌟 根據 config_type 讀取對應的 JSON
|
||||
hidden_keys = await load_tree_filters(config_type)
|
||||
return {"status": "success", "data": hidden_keys}
|
||||
|
||||
@router.post("/settings/tree-filters")
|
||||
async def update_tree_filters(req: SettingsRequest):
|
||||
"""更新樹狀圖隱藏名單並存檔"""
|
||||
save_settings({"hidden_keys": req.hidden_keys})
|
||||
return {"status": "success", "message": "系統設定已更新"}
|
||||
# 🌟 根據 config_type 寫入對應的 JSON
|
||||
await save_tree_filters(req.config_type, req.hidden_keys)
|
||||
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,155 @@
|
|||
# --- 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 pydantic import BaseModel
|
||||
from typing import List
|
||||
import json, os, asyncio, time, re
|
||||
|
||||
# 🌟 引入新的 asyncssh 版本爬蟲
|
||||
import json, os, asyncio, re
|
||||
import database
|
||||
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"])
|
||||
CACHE_FILE = "cmts_leaf_options_cache.json"
|
||||
|
||||
# 🌟 1. 改用全域布林變數,狀態切換更即時,消滅非同步時間差
|
||||
IS_SCANNING = False
|
||||
# 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
|
||||
def get_cache_file(host: str, config_type: str):
|
||||
safe_host = host.replace(".", "_")
|
||||
return f"{safe_host}_{config_type}_cache.json"
|
||||
|
||||
# ==========================================
|
||||
# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
|
||||
# ==========================================
|
||||
# 將狀態改為空字典,動態根據 host_configType 建立
|
||||
SCAN_STATUS = {}
|
||||
active_clients = {}
|
||||
|
||||
async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
|
||||
"""將訊息推播給特定頻道的連線前端"""
|
||||
channel_key = f"{host}_{config_type}"
|
||||
dead_clients = set()
|
||||
|
||||
# SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
|
||||
message_str = f"data: {json.dumps(message_dict)}\n\n"
|
||||
|
||||
for client_queue in active_clients.get(channel_key, set()):
|
||||
try:
|
||||
await client_queue.put(message_str)
|
||||
except Exception:
|
||||
dead_clients.add(client_queue)
|
||||
for dead in dead_clients:
|
||||
active_clients[channel_key].discard(dead)
|
||||
|
||||
@router.get("/cmts-leaf-options/stream")
|
||||
async def sse_stream(request: Request, host: str, config_type: str = "running"):
|
||||
"""前端一載入就會連上這個路由,持續監聽特定頻道的廣播"""
|
||||
channel_key = f"{host}_{config_type}"
|
||||
client_queue = asyncio.Queue()
|
||||
|
||||
if channel_key not in active_clients:
|
||||
active_clients[channel_key] = set()
|
||||
active_clients[channel_key].add(client_queue)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
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):
|
||||
host: str # 🌟 新增 host 參數
|
||||
username: str = "" # 🌟 新增 username 參數
|
||||
password: str = "" # 🌟 新增 password 參數
|
||||
leaf_paths: List[str]
|
||||
config_type: str = "running"
|
||||
|
||||
@router.get("/cmts-leaf-options")
|
||||
async def get_leaf_options():
|
||||
if not os.path.exists(CACHE_FILE): return {}
|
||||
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
async def get_leaf_options(host: str, config_type: str = "running"):
|
||||
if USE_DB:
|
||||
try:
|
||||
db_options = await database.get_all_leaf_options(host, config_type)
|
||||
if db_options is not None:
|
||||
# 取得 metadata
|
||||
metadata = await database.get_device_status(host, config_type)
|
||||
if metadata:
|
||||
db_options["__metadata__"] = metadata
|
||||
return db_options
|
||||
else:
|
||||
# 若回傳 None 表示 DB 連線異常,執行 Fallback
|
||||
print("⚠️ [Fallback] 資料庫無法取得資料,自動切換至 JSON 快取讀取...")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Fallback] 資料庫讀取發生例外: {e},自動切換至 JSON 快取讀取...")
|
||||
|
||||
cache_file = get_cache_file(host, config_type)
|
||||
if not os.path.exists(cache_file): return {}
|
||||
|
||||
# 🌟 優化 3:將讀取動作丟到背景執行緒
|
||||
def read_json_from_file(filepath):
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
@router.post("/cmts-leaf-options/sync")
|
||||
async def sync_leaf_options(request: SyncOptionsRequest):
|
||||
global IS_SCANNING # 宣告我們要修改全域變數
|
||||
return await asyncio.to_thread(read_json_from_file, cache_file)
|
||||
|
||||
async def run_scan_task(host: str, username: str, password: str, paths: List[str], config_type: str):
|
||||
"""在背景執行的爬蟲任務,並將進度廣播出去"""
|
||||
global SCAN_STATUS
|
||||
channel_key = f"{host}_{config_type}"
|
||||
try:
|
||||
await broadcast_message({"event": "scan_start"}, host, config_type)
|
||||
|
||||
# 呼叫爬蟲 (帶入設備連線資訊與 config_type)
|
||||
async for chunk in sync_cmts_leaves_async(
|
||||
host=host,
|
||||
username=username,
|
||||
password=password,
|
||||
leaf_paths=paths,
|
||||
config_type=config_type
|
||||
):
|
||||
if isinstance(chunk, str):
|
||||
await broadcast_message(json.loads(chunk), host, config_type)
|
||||
else:
|
||||
await broadcast_message(chunk, host, config_type)
|
||||
|
||||
await broadcast_message({"event": "done"}, host, config_type)
|
||||
except Exception as e:
|
||||
await broadcast_message({"event": "error", "message": str(e)}, host, config_type)
|
||||
finally:
|
||||
# 解除特定頻道的鎖定
|
||||
SCAN_STATUS[channel_key] = False
|
||||
|
||||
@router.post("/cmts-leaf-options/sync")
|
||||
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
|
||||
global SCAN_STATUS
|
||||
if not request.leaf_paths:
|
||||
raise HTTPException(status_code=400)
|
||||
|
||||
# 🌟 2. 第一時間檢查並「立即」阻擋
|
||||
if IS_SCANNING:
|
||||
async def busy_stream():
|
||||
yield json.dumps({"event": "error", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}) + "\n"
|
||||
return StreamingResponse(busy_stream(), media_type="text/event-stream")
|
||||
channel_key = f"{request.host}_{request.config_type}"
|
||||
|
||||
# 如果沒人掃描,立刻把狀態改為 True (在建立串流之前就上鎖)
|
||||
IS_SCANNING = True
|
||||
if SCAN_STATUS.get(channel_key, False):
|
||||
return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"}
|
||||
|
||||
async def event_generator():
|
||||
global IS_SCANNING
|
||||
try:
|
||||
SCAN_STATUS[channel_key] = True
|
||||
|
||||
# 保留您原本完美的路徑清理邏輯
|
||||
cmts_query_paths = []
|
||||
for p in request.leaf_paths:
|
||||
clean_p = p.replace("::", " ")
|
||||
|
|
@ -52,50 +158,59 @@ async def sync_leaf_options(request: SyncOptionsRequest):
|
|||
|
||||
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
|
||||
|
||||
# 🌟 因為爬蟲自己會存檔,我們只要專心把進度轉發給前端就好!
|
||||
async for chunk in sync_cmts_leaves_async(
|
||||
host=CMTS_DEVICE.get("host"),
|
||||
username=CMTS_DEVICE.get("username"),
|
||||
password=CMTS_DEVICE.get("password"),
|
||||
leaf_paths=cmts_query_paths
|
||||
):
|
||||
yield chunk
|
||||
# 決定帳密 (優先使用 request 傳來的,否則 fallback 到 shared.CMTS_DEVICE)
|
||||
# 🌟 加上 or "" 確保最終結果絕對是字串,消除 Pylance 警告
|
||||
user = request.username or CMTS_DEVICE.get("username") or ""
|
||||
pwd = request.password or CMTS_DEVICE.get("password") or ""
|
||||
|
||||
except Exception as e:
|
||||
yield json.dumps({"event": "error", "message": str(e)}) + "\n"
|
||||
background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths, request.config_type)
|
||||
return {"status": "started"}
|
||||
|
||||
finally:
|
||||
# 🌟 3. 無論成功、失敗、或使用者提早關閉網頁,都確保會解鎖
|
||||
IS_SCANNING = False
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
# 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE)
|
||||
# ==========================================
|
||||
# 🌟 4. 清除快取 API
|
||||
# ==========================================
|
||||
@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
|
||||
|
||||
# 1. 檢查快取檔案是否存在
|
||||
if not os.path.exists(CACHE_FILE):
|
||||
# 1. 嘗試清除 DB
|
||||
if USE_DB:
|
||||
try:
|
||||
db_cleared = await database.delete_leaf_options(host, config_type, paths_to_clear)
|
||||
if db_cleared >= 0:
|
||||
cleared_count = db_cleared
|
||||
else:
|
||||
print("⚠️ [Fallback] 資料庫清除失敗,自動切換至 JSON 快取清除...")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Fallback] 資料庫清除發生例外: {e},自動切換至 JSON 快取清除...")
|
||||
|
||||
# 2. 清除 JSON (如果 DB 沒清掉,或 USE_DB=False,或者是連同舊檔一起清確保乾淨)
|
||||
cache_file = get_cache_file(host, config_type)
|
||||
|
||||
if not os.path.exists(cache_file):
|
||||
# 如果是走 DB,且有清掉,回傳 DB 的結果
|
||||
if USE_DB and cleared_count > 0:
|
||||
return {"status": "success", "cleared_count": cleared_count}
|
||||
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
|
||||
|
||||
try:
|
||||
# 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)
|
||||
|
||||
# 3. 逐一比對並刪除指定的路徑
|
||||
json_cleared_count = 0
|
||||
for path in paths_to_clear:
|
||||
if path in cache_data:
|
||||
del cache_data[path]
|
||||
cleared_count += 1
|
||||
json_cleared_count += 1
|
||||
|
||||
# 4. 如果有刪除資料,將更新後的內容寫回 JSON 檔案
|
||||
if cleared_count > 0:
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
if json_cleared_count > 0:
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cache_data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
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:
|
||||
return {"status": "error", "message": f"清除快取時發生錯誤: {str(e)}"}
|
||||
# 確保回傳標準 JSON 格式
|
||||
return {"status": "error", "message": f"資料庫連線與快取存取皆失敗: {str(e)}"}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,49 @@
|
|||
# --- routers/lock.py ---
|
||||
import time
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Optional
|
||||
|
||||
# 🌟 只定義自己的子路徑
|
||||
router = APIRouter(prefix="/locks", tags=["Lock Management"])
|
||||
|
||||
# ==========================================
|
||||
# 💡 全域鎖定表 (In-Memory Lock Table)
|
||||
# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} }
|
||||
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
|
||||
# ==========================================
|
||||
ACTIVE_LOCKS: Dict[str, dict] = {}
|
||||
LOCK_TIMEOUT = 120
|
||||
|
||||
# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat,否則自動釋放
|
||||
LOCK_TIMEOUT = 30
|
||||
|
||||
# 🌟 新增 host 欄位
|
||||
class LockAcquireReq(BaseModel):
|
||||
host: str
|
||||
path: str
|
||||
user_id: str # 前端產生的 UUID (Session ID)
|
||||
username: str # 登入的帳號 (用於 UI 提示,例如 "admin")
|
||||
user_id: str
|
||||
username: str
|
||||
|
||||
class LockActionReq(BaseModel):
|
||||
host: str
|
||||
path: str
|
||||
user_id: str
|
||||
|
||||
def clean_expired_locks():
|
||||
"""清除已經超時的鎖 (被動式清理)"""
|
||||
current_time = time.time()
|
||||
expired_paths = [path for path, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
|
||||
for path in expired_paths:
|
||||
del ACTIVE_LOCKS[path]
|
||||
expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
|
||||
for k in expired_keys:
|
||||
del ACTIVE_LOCKS[k]
|
||||
|
||||
def is_path_locked_by_others(target_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()
|
||||
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:
|
||||
continue # 自己鎖定的不算衝突
|
||||
continue
|
||||
|
||||
# 💡 拆分判斷,回傳更精準的錯誤訊息
|
||||
if target_path == locked_path:
|
||||
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, ""
|
||||
|
||||
# ==========================================
|
||||
# 💡 API Endpoints
|
||||
# ==========================================
|
||||
|
||||
@router.post("/acquire")
|
||||
async def acquire_lock(req: LockAcquireReq):
|
||||
"""請求鎖定特定路徑"""
|
||||
# 接收回傳的鎖定資訊與具體訊息
|
||||
conflict_lock, error_msg = is_path_locked_by_others(req.path, req.user_id)
|
||||
conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
|
||||
|
||||
if conflict_lock:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=error_msg # 💡 將精準的錯誤訊息傳給前端
|
||||
)
|
||||
raise HTTPException(status_code=409, detail=error_msg)
|
||||
|
||||
# 寫入或更新鎖定狀態
|
||||
ACTIVE_LOCKS[req.path] = {
|
||||
lock_key = f"{req.host}@@{req.path}"
|
||||
ACTIVE_LOCKS[lock_key] = {
|
||||
"user_id": req.user_id,
|
||||
"username": req.username,
|
||||
"expires_at": time.time() + LOCK_TIMEOUT
|
||||
|
|
@ -80,28 +72,33 @@ async def acquire_lock(req: LockAcquireReq):
|
|||
|
||||
@router.post("/heartbeat")
|
||||
async def heartbeat_lock(req: LockActionReq):
|
||||
"""延長鎖定時間 (心跳偵測)"""
|
||||
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="鎖定已失效,請重新獲取")
|
||||
|
||||
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="無權更新他人的鎖定")
|
||||
|
||||
# 延長鎖定時間
|
||||
ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT
|
||||
ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
|
||||
return {"status": "success", "message": "心跳更新成功"}
|
||||
|
||||
@router.post("/release")
|
||||
async def release_lock(req: LockActionReq):
|
||||
"""主動釋放鎖定"""
|
||||
if req.path in ACTIVE_LOCKS and ACTIVE_LOCKS[req.path]["user_id"] == req.user_id:
|
||||
del ACTIVE_LOCKS[req.path]
|
||||
lock_key = f"{req.host}@@{req.path}"
|
||||
if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
|
||||
del ACTIVE_LOCKS[lock_key]
|
||||
return {"status": "success", "message": "鎖定已釋放"}
|
||||
|
||||
@router.get("/status")
|
||||
async def get_lock_status():
|
||||
"""獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)"""
|
||||
async def get_lock_status(host: str = Query(None)):
|
||||
"""獲取特定設備的鎖定狀態"""
|
||||
clean_expired_locks()
|
||||
return {"status": "success", "data": ACTIVE_LOCKS}
|
||||
if host:
|
||||
prefix = f"{host}@@"
|
||||
# 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 path,UI 不用改!
|
||||
filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
|
||||
return {"status": "success", "data": filtered_locks}
|
||||
|
||||
return {"status": "success", "data": {}}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def parse_cm_output(raw_text: str) -> list:
|
|||
async def get_cable_modems():
|
||||
try:
|
||||
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()
|
||||
structured_data = parse_cm_output(raw_output)
|
||||
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"
|
||||
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()
|
||||
|
||||
# 💡 正名工程:更新字典的 Key 為一致性的命名
|
||||
|
|
@ -190,7 +190,7 @@ async def get_cmts_mac_domain_list(
|
|||
device.update({'host': host, 'username': username, 'password': password})
|
||||
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()
|
||||
|
||||
import re
|
||||
|
|
@ -200,3 +200,29 @@ async def get_cmts_mac_domain_list(
|
|||
return {"status": "success", "data": mac_domains}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@router.get("/cmts-version")
|
||||
async def get_cmts_version(
|
||||
host: str = Query(...),
|
||||
username: str = Query(...),
|
||||
password: str = Query(...)
|
||||
):
|
||||
try:
|
||||
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)}"}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import re
|
|||
from collections import defaultdict
|
||||
|
||||
DEBUG_MODE = False
|
||||
USE_DB = True # PostgreSQL Feature Toggle
|
||||
|
||||
def debug_print(msg: str):
|
||||
if DEBUG_MODE:
|
||||
|
|
|
|||
|
|
@ -3,56 +3,69 @@
|
|||
// ==========================================
|
||||
// 1. 鎖定管理 (Lock Management)
|
||||
// ==========================================
|
||||
export async function apiAcquireLock(path, userId, username) {
|
||||
const response = await fetch('/api/v1/locks/acquire', {
|
||||
export async function apiAcquireLock(path, userId, username, host) {
|
||||
const res = await fetch('/api/v1/locks/acquire', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, user_id: userId, username })
|
||||
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', {
|
||||
method: 'POST',
|
||||
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', {
|
||||
method: 'POST',
|
||||
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)
|
||||
// ==========================================
|
||||
export async function apiGetFullConfig(host, username, password, skipFilter = false) {
|
||||
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||
export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
|
||||
// 🌟 將 config_type 加入 URL 參數中
|
||||
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`;
|
||||
|
||||
if (skipFilter) url += '&skip_filter=true';
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function apiGetLeafOptions() {
|
||||
const response = await fetch('/api/v1/cmts-leaf-options');
|
||||
// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
|
||||
export async function apiGetLeafOptions(host, configType = 'running') {
|
||||
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
const response = await fetch('/api/v1/clear_cache', {
|
||||
// 🌟 3. 清除快取 (加上 configType 查詢參數)
|
||||
export async function apiClearCache(host, paths, configType = 'running') {
|
||||
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(paths)
|
||||
|
|
@ -61,6 +74,22 @@ export async function apiClearCache(paths) {
|
|||
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)
|
||||
// ==========================================
|
||||
|
|
@ -85,16 +114,18 @@ export async function apiExecuteConfig(script, host, username, password) {
|
|||
// ==========================================
|
||||
// 4. 系統設定與過濾器 (System Settings)
|
||||
// ==========================================
|
||||
export async function apiGetTreeFilters() {
|
||||
const response = await fetch('/api/v1/settings/tree-filters');
|
||||
// 🌟 加上 configType 參數
|
||||
export async function apiGetTreeFilters(configType = 'running') {
|
||||
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function apiSaveTreeFilters(hiddenKeys) {
|
||||
const response = await fetch('/api/v1/settings/tree-filters', {
|
||||
// 🌟 加上 configType 參數
|
||||
export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
|
||||
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hidden_keys: hiddenKeys })
|
||||
body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
|
@ -123,11 +154,12 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
|
|||
// ==========================================
|
||||
// 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', {
|
||||
method: 'POST',
|
||||
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");
|
||||
|
|
|
|||
618
static/app.js
618
static/app.js
|
|
@ -11,7 +11,8 @@ import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, dow
|
|||
import {
|
||||
apiGetFullConfig, apiClearCache, apiExecuteQuery,
|
||||
apiGetTreeFilters, apiSaveTreeFilters,
|
||||
apiSyncLeafOptionsStream
|
||||
apiSyncLeafOptionsStream, apiGetCmtsVersion,
|
||||
apiGetScanStatus, apiGetLockStatus
|
||||
} from './api.js';
|
||||
|
||||
// 3. 共用工具模組 (Utils)
|
||||
|
|
@ -24,7 +25,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-u
|
|||
import {
|
||||
releaseAllLocks, startEditFolder, startEditLeaf,
|
||||
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
|
||||
hideSideCLI, executeSideCLI
|
||||
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
|
||||
} from './edit-mode.js';
|
||||
|
||||
// 6. MAC Domain 配置模組 (MAC Domain)
|
||||
|
|
@ -36,16 +37,120 @@ import {
|
|||
|
||||
|
||||
// ============================================================================
|
||||
// 🌟 1. 全域生命週期與閒置偵測 (Lifecycle & Idle Detection)
|
||||
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
|
||||
// ============================================================================
|
||||
|
||||
let idleTimer;
|
||||
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
|
||||
let globalSSE = null;
|
||||
|
||||
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
|
||||
function initGlobalSSE(mode = 'running') {
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
|
||||
if (globalSSE) globalSSE.close();
|
||||
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
||||
|
||||
globalSSE.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
const statusEl = document.getElementById('scan-status');
|
||||
|
||||
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
|
||||
if (data.event === 'scan_start' || data.event === 'progress') {
|
||||
setAdminButtonsState(true, "⏳ 系統更新中...");
|
||||
}
|
||||
|
||||
if (data.event === 'scan_start') {
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
|
||||
statusEl.style.color = "#f39c12";
|
||||
}
|
||||
}
|
||||
else if (data.event === 'progress') {
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
|
||||
statusEl.style.color = "#f39c12";
|
||||
}
|
||||
}
|
||||
else if (data.event === 'done') {
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
|
||||
statusEl.style.color = "#27ae60";
|
||||
setTimeout(() => statusEl.textContent = "", 5000);
|
||||
}
|
||||
// 掃描完成才解鎖
|
||||
setAdminButtonsState(false);
|
||||
localStorage.removeItem('scanLockUntil');
|
||||
}
|
||||
else if (data.event === 'error') {
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
|
||||
statusEl.style.color = "#e74c3c";
|
||||
}
|
||||
// 發生錯誤也要解鎖
|
||||
setAdminButtonsState(false);
|
||||
localStorage.removeItem('scanLockUntil');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (globalSSE) {
|
||||
globalSSE.close();
|
||||
globalSSE = null;
|
||||
}
|
||||
});
|
||||
|
||||
let 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 = () => {
|
||||
initTerminal();
|
||||
resetIdleTimer(); // 頁面載入時啟動閒置計時器
|
||||
initGlobalSSE(); // 啟動全域廣播監聽
|
||||
startLockStatusPolling(); // 啟動鎖定狀態輪詢
|
||||
|
||||
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
|
||||
const configTaskSelect = document.getElementById('configTask');
|
||||
if (configTaskSelect) {
|
||||
configTaskSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
};
|
||||
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
|
||||
|
||||
|
|
@ -55,11 +160,18 @@ function resetIdleTimer() {
|
|||
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
// 監聽使用者的互動事件,只要有動作就重置計時器
|
||||
window.addEventListener('mousemove', resetIdleTimer);
|
||||
window.addEventListener('keydown', resetIdleTimer);
|
||||
window.addEventListener('click', resetIdleTimer);
|
||||
|
||||
// 🌟 效能急救:節流閥 (Throttle) 機制
|
||||
let throttleTimer = false;
|
||||
function throttledReset() {
|
||||
if (!throttleTimer) {
|
||||
resetIdleTimer();
|
||||
throttleTimer = true;
|
||||
setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設
|
||||
}
|
||||
}
|
||||
window.addEventListener('mousemove', throttledReset);
|
||||
window.addEventListener('keydown', throttledReset);
|
||||
window.addEventListener('click', throttledReset);
|
||||
|
||||
// ============================================================================
|
||||
// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
|
||||
|
|
@ -85,17 +197,57 @@ function switchQueryTask() {
|
|||
if (targetForm) targetForm.classList.add('active');
|
||||
}
|
||||
|
||||
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
|
||||
let lastTreeMode = null;
|
||||
|
||||
// 切換「設備配置」內的子任務表單
|
||||
function switchConfigTask() {
|
||||
document.querySelectorAll('#config-tab .task-form').forEach(form => {
|
||||
form.classList.remove('active');
|
||||
form.style.display = 'none';
|
||||
});
|
||||
|
||||
const selectedTaskId = document.getElementById('configTask').value;
|
||||
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) {
|
||||
targetForm.classList.add('active');
|
||||
targetForm.style.display = 'block';
|
||||
|
||||
const titleSpan = document.getElementById('tree-form-title');
|
||||
const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running';
|
||||
|
||||
if (titleSpan) {
|
||||
titleSpan.textContent = currentMode === 'running'
|
||||
? '🌳 設備配置樹狀圖 (running-config)'
|
||||
: '🌳 完整設備配置樹狀圖 (full configuration)';
|
||||
}
|
||||
|
||||
if (targetFormId === 'form-tree-config') {
|
||||
// 🌟 魔法所在:純視覺切換,不銷毀資料!
|
||||
const runningContainer = document.getElementById('tree-container-running');
|
||||
const fullContainer = document.getElementById('tree-container-full');
|
||||
if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none';
|
||||
if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none';
|
||||
|
||||
if (lastTreeMode !== currentMode) {
|
||||
const statusEl = document.getElementById('scan-status');
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
|
||||
// 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏!
|
||||
const loadingMsg = document.getElementById('loading-message');
|
||||
if (loadingMsg) loadingMsg.style.display = 'none';
|
||||
|
||||
lastTreeMode = currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
initGlobalSSE(currentMode);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,8 +264,13 @@ function startConfigTask() {
|
|||
if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
|
||||
resetAllManagerData();
|
||||
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) {
|
|||
}
|
||||
}
|
||||
|
||||
// 載入完整設備配置並生成樹狀圖
|
||||
async function fetchFullConfig() {
|
||||
// 載入完整設備配置並生成樹狀圖,預設為 running
|
||||
async function fetchFullConfig(configType = 'running') {
|
||||
const loadingMsg = document.getElementById('loading-message');
|
||||
const treeContainer = document.getElementById('tree-container');
|
||||
// 🌟 動態抓取當前模式的專屬容器
|
||||
const treeContainer = document.getElementById(`tree-container-${configType}`);
|
||||
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo) return;
|
||||
|
|
@ -213,12 +371,76 @@ async function fetchFullConfig() {
|
|||
if (loadingMsg) loadingMsg.style.display = 'inline-block';
|
||||
if (treeContainer) treeContainer.innerHTML = '';
|
||||
|
||||
let needAutoScan = false; // 🌟 標記是否需要自動掃描
|
||||
|
||||
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 (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();
|
||||
|
||||
// 如果剛清空快取,自動啟動掃描
|
||||
if (needAutoScan) {
|
||||
setTimeout(() => {
|
||||
// 🌟 修正 3:全域掃描也必須知道現在要掃哪一棵樹
|
||||
scanGlobalMissingOptions(configType);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
// ============================================================================
|
||||
|
||||
// 為現有的 input 加上下拉選項 (不破壞原有結構)
|
||||
async function enhanceInputWithOptions(path, elementId) {
|
||||
// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
|
||||
async function enhanceInputWithOptions(path, elementId, mode = 'running') {
|
||||
// 🌟 1. 取得當前連線的 IP
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo || !connInfo.host) return;
|
||||
|
||||
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 cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||
const leafCache = optData[cacheKey];
|
||||
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const CACHE_TTL = 86400 * 7;
|
||||
|
||||
if (leafCache && leafCache.options && leafCache.options.length > 0 && (currentTime - leafCache.updated_at < CACHE_TTL)) {
|
||||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||||
const container = document.getElementById(`container-${elementId}`);
|
||||
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('');
|
||||
}
|
||||
} else {
|
||||
// 🌟 3. POST 請求:把 host 塞進 body 裡面
|
||||
fetch('/api/v1/cmts-leaf-options/sync', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
|
|
@ -275,117 +499,134 @@ async function enhanceInputWithOptions(path, elementId) {
|
|||
}
|
||||
}
|
||||
|
||||
// 一鍵掃描缺失選項 (包含快取比對 + 防連點保護 + 僅掃描可見項目)
|
||||
async function scanAllMissingOptions() {
|
||||
const btn = document.getElementById('global-scan-btn');
|
||||
const statusEl = document.getElementById('scan-status');
|
||||
const treeContainer = document.getElementById('tree-container');
|
||||
// ============================================================================
|
||||
// 🌟 核心功能:執行掃描 (廣播升級版),加上 mode 參數
|
||||
// ============================================================================
|
||||
|
||||
if (!treeContainer) return;
|
||||
|
||||
const LOCK_DURATION_MS = 60000;
|
||||
localStorage.setItem('scanLockUntil', Date.now() + LOCK_DURATION_MS);
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.style.backgroundColor = "#bdc3c7";
|
||||
btn.style.cursor = "not-allowed";
|
||||
btn.innerText = "⏳ 掃描冷卻中...";
|
||||
// 🌟 新增:遞迴提取所有快取 Key 的輔助函數 (不依賴網頁 DOM)
|
||||
function extractAllCacheKeys(node, path = '') {
|
||||
let keys = [];
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
const currentPath = path ? `${path}::${key}` : key;
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
keys = keys.concat(extractAllCacheKeys(value, currentPath));
|
||||
} else {
|
||||
const origVal = (value === null ? '' : value).toString();
|
||||
let cacheKey = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||
if (currentPath.endsWith('::no')) {
|
||||
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||||
}
|
||||
|
||||
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);
|
||||
keys.push(cacheKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
if (pathsToSync.length === 0) {
|
||||
statusEl.textContent = "✅ 當前展開的欄位都已有最新選項,無需掃描!";
|
||||
statusEl.style.color = "#27ae60";
|
||||
setTimeout(() => statusEl.textContent = "", 3000);
|
||||
async function performScan(isGlobal, mode = 'running') {
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.style.backgroundColor = "";
|
||||
btn.style.cursor = "pointer";
|
||||
btn.innerHTML = "🔍 掃描畫面缺失選項";
|
||||
}
|
||||
localStorage.removeItem('scanLockUntil');
|
||||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
|
||||
if (isSomeoneScanning) {
|
||||
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.innerHTML = `⏳ 正在建立即時連線...`;
|
||||
statusEl.style.color = "#e67e22";
|
||||
const treeContainer = document.getElementById(`tree-container-${mode}`);
|
||||
if (!treeContainer) return;
|
||||
|
||||
// 🌟 核心修改:改用串流 API,並傳入進度更新的回呼函數
|
||||
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";
|
||||
const statusEl = document.getElementById('scan-status');
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.style.backgroundColor = "#8e44ad";
|
||||
btn.style.cursor = "pointer";
|
||||
btn.innerText = "🔍 掃描畫面缺失選項";
|
||||
}
|
||||
localStorage.removeItem('scanLockUntil');
|
||||
setTimeout(() => statusEl.textContent = "", 5000);
|
||||
}
|
||||
else if (data.event === 'error') {
|
||||
// 發生錯誤:顯示紅字並恢復按鈕
|
||||
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
|
||||
statusEl.style.color = "#e74c3c";
|
||||
try {
|
||||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
||||
const optData = await optRes.json();
|
||||
const pathsToSync = [];
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.style.backgroundColor = "#8e44ad";
|
||||
btn.style.cursor = "pointer";
|
||||
btn.innerText = "🔍 掃描畫面缺失選項";
|
||||
}
|
||||
localStorage.removeItem('scanLockUntil');
|
||||
if (isGlobal) {
|
||||
// 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
|
||||
if (!window.currentTreeData) return alert("請先載入設備配置!");
|
||||
const allKeys = extractAllCacheKeys(window.currentTreeData);
|
||||
|
||||
allKeys.forEach(cacheKey => {
|
||||
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
|
||||
pathsToSync.push(cacheKey);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的)
|
||||
const allContainers = treeContainer.querySelectorAll('.leaf-container');
|
||||
allContainers.forEach(container => {
|
||||
const isHiddenByFolder = container.closest('details:not([open])') !== null;
|
||||
if (!isHiddenByFolder) {
|
||||
const path = container.getAttribute('data-path');
|
||||
if (path) {
|
||||
const origVal = container.getAttribute('data-original') || '';
|
||||
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||||
|
||||
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
|
||||
pathsToSync.push(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (pathsToSync.length === 0) {
|
||||
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!";
|
||||
statusEl.style.color = "#27ae60";
|
||||
setTimeout(() => statusEl.textContent = "", 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status === 'busy') {
|
||||
alert(result.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("掃描請求失敗:", error);
|
||||
console.error("掃描請求發送失敗:", error);
|
||||
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
|
||||
statusEl.style.color = "#e74c3c";
|
||||
}
|
||||
}
|
||||
|
||||
// 清除畫面選項快取功能 (精準可見度判定版)
|
||||
async function clearVisibleCache() {
|
||||
const elements = document.querySelectorAll('.leaf-container');
|
||||
const pathsToClear = [];
|
||||
// ============================================================================
|
||||
// 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
|
||||
// ============================================================================
|
||||
async function performClear(isGlobal, mode = 'running') {
|
||||
// 🌟 1. 取得當前連線的 IP
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
|
||||
|
||||
// 🌟 2. 檢查掃描狀態時,傳入 host
|
||||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
|
||||
if (isSomeoneScanning) {
|
||||
alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突,請稍候掃描完成再執行清除動作。");
|
||||
return;
|
||||
}
|
||||
|
||||
let pathsToClear = [];
|
||||
|
||||
if (isGlobal) {
|
||||
try {
|
||||
// 🌟 3. GET 請求:網址加上 host 參數
|
||||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
||||
const optData = await optRes.json();
|
||||
pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return alert("❌ 無法取得全域快取清單。");
|
||||
}
|
||||
} else {
|
||||
const treeContainer = document.getElementById(`tree-container-${mode}`);
|
||||
const elements = treeContainer ? treeContainer.querySelectorAll('.leaf-container') : [];
|
||||
elements.forEach(el => {
|
||||
const isHiddenByFolder = el.closest('details:not([open])') !== null;
|
||||
if (!isHiddenByFolder) {
|
||||
|
|
@ -396,19 +637,51 @@ async function clearVisibleCache() {
|
|||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (pathsToClear.length === 0) return alert("畫面上沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。");
|
||||
if (!confirm(`確定要清除畫面上 ${pathsToClear.length} 個選項的快取嗎?\n(清除後請重新點擊「掃描畫面缺失選項」)`)) return;
|
||||
if (pathsToClear.length === 0) return alert(isGlobal ? "目前沒有任何快取可以清除!" : "局部沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。");
|
||||
|
||||
const msg = isGlobal
|
||||
? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)`
|
||||
: `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後將自動為您重新掃描)`;
|
||||
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
try {
|
||||
const result = await apiClearCache(pathsToClear);
|
||||
alert(`✅ 成功清除 ${result.cleared_count} 筆快取!\n現在請點擊旁邊的「掃描畫面缺失選項」來重新擷取。`);
|
||||
// 🌟 4. 呼叫清除 API 時,傳入 host
|
||||
const result = await apiClearCache(connInfo.host, pathsToClear, mode);
|
||||
const statusEl = document.getElementById('scan-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`;
|
||||
statusEl.style.color = "#27ae60";
|
||||
}
|
||||
|
||||
// 清除完畢後,自動呼叫簡化版的 performScan
|
||||
await performScan(isGlobal, mode);
|
||||
|
||||
} catch (error) {
|
||||
console.error("清除快取失敗:", error);
|
||||
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");
|
||||
}
|
||||
}
|
||||
|
||||
// 綁定給 HTML 呼叫的 4 個獨立函數
|
||||
function scanVisibleMissingOptions() {
|
||||
performScan(false);
|
||||
}
|
||||
function scanGlobalMissingOptions() {
|
||||
// 🌟 動態抓取當前選擇的模式,傳給 performScan
|
||||
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||||
performScan(true, mode);
|
||||
}
|
||||
function clearVisibleCache() {
|
||||
performClear(false);
|
||||
}
|
||||
function clearGlobalCache() {
|
||||
// 🌟 動態抓取當前選擇的模式,傳給 performClear
|
||||
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||||
performClear(true, mode);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 🌟 5. 系統設定與過濾器 (System Settings & Filters)
|
||||
|
|
@ -416,10 +689,21 @@ async function clearVisibleCache() {
|
|||
|
||||
let currentHiddenKeys = [];
|
||||
|
||||
// 🌟 新增:切換過濾器模式時觸發
|
||||
async function switchFilterMode() {
|
||||
const container = document.getElementById('filter-checkboxes');
|
||||
if (container) container.style.display = 'none'; // 先隱藏樹狀圖,強迫使用者重新載入
|
||||
await openSystemSettings();
|
||||
}
|
||||
|
||||
// 開啟系統設定並讀取目前的過濾器清單
|
||||
async function openSystemSettings() {
|
||||
// 🌟 動態抓取過濾器模式
|
||||
const modeSelect = document.getElementById('filter-mode-select');
|
||||
const mode = modeSelect ? modeSelect.value : 'running';
|
||||
|
||||
try {
|
||||
const result = await apiGetTreeFilters();
|
||||
const result = await apiGetTreeFilters(mode);
|
||||
if (result.status === 'success') currentHiddenKeys = result.data;
|
||||
} catch (error) {
|
||||
console.error("讀取設定失敗:", error);
|
||||
|
|
@ -431,6 +715,10 @@ async function loadRealConfigForFilters() {
|
|||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
|
||||
|
||||
// 🌟 動態抓取過濾器模式
|
||||
const modeSelect = document.getElementById('filter-mode-select');
|
||||
const mode = modeSelect ? modeSelect.value : 'running';
|
||||
|
||||
const loadingMsg = document.getElementById('filter-loading-msg');
|
||||
const container = document.getElementById('filter-checkboxes');
|
||||
|
||||
|
|
@ -438,13 +726,15 @@ async function loadRealConfigForFilters() {
|
|||
container.style.display = 'none';
|
||||
|
||||
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 result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys);
|
||||
// 🌟 傳遞 mode 給 buildRealFilterTree
|
||||
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
|
||||
|
|
@ -484,11 +774,15 @@ function updateParentCheckboxState(element) {
|
|||
|
||||
// 儲存系統設定 (過濾器)
|
||||
async function saveSystemSettings() {
|
||||
// 🌟 動態抓取過濾器模式
|
||||
const modeSelect = document.getElementById('filter-mode-select');
|
||||
const mode = modeSelect ? modeSelect.value : 'running';
|
||||
|
||||
const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
|
||||
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
try {
|
||||
const result = await apiSaveTreeFilters(selectedHiddenKeys);
|
||||
const result = await apiSaveTreeFilters(selectedHiddenKeys, mode);
|
||||
if (result.status === 'success') {
|
||||
const statusText = document.getElementById('settings-status');
|
||||
statusText.style.display = 'inline-block';
|
||||
|
|
@ -524,49 +818,65 @@ function closeModal(event) {
|
|||
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', () => {
|
||||
const scanBtn = document.getElementById('global-scan-btn');
|
||||
const clearBtn = document.getElementById('clearCacheBtn');
|
||||
const appTitle = document.getElementById('app-title');
|
||||
|
||||
// 定義解鎖動作:把按鈕顯示出來
|
||||
function unlockAdminFeatures() {
|
||||
if (scanBtn) scanBtn.style.display = 'inline-block';
|
||||
if (clearBtn) clearBtn.style.display = 'inline-block';
|
||||
ADMIN_BTN_IDS.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = 'inline-block';
|
||||
});
|
||||
|
||||
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
|
||||
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
||||
btn.style.display = 'inline-block';
|
||||
});
|
||||
}
|
||||
|
||||
// 方案一:URL 隱藏密鑰檢查 (?mode=godmode)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('mode') === 'godmode') {
|
||||
unlockAdminFeatures();
|
||||
}
|
||||
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
|
||||
|
||||
// 方案二:彩蛋解鎖 (對標題連點 5 次)
|
||||
let clickCount = 0;
|
||||
let clickTimer = null;
|
||||
|
||||
if (appTitle) {
|
||||
appTitle.addEventListener('click', () => {
|
||||
clickCount++;
|
||||
|
||||
if (clickCount === 5) {
|
||||
unlockAdminFeatures();
|
||||
alert('🔓 已解鎖系統維護者模式!');
|
||||
clickCount = 0;
|
||||
}
|
||||
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = setTimeout(() => {
|
||||
clickCount = 0;
|
||||
}, 1000);
|
||||
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 統一控制 4 顆按鈕的狀態
|
||||
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
||||
ADMIN_BTN_IDS.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) {
|
||||
btn.disabled = isBusy;
|
||||
if (isBusy) {
|
||||
btn.classList.add('is-busy');
|
||||
if (id.includes('scan')) btn.innerText = text;
|
||||
} else {
|
||||
btn.classList.remove('is-busy');
|
||||
// 恢復原本的文字
|
||||
if (id === 'btn-scan-visible') btn.innerText = "🔍 掃描局部缺失選項";
|
||||
if (id === 'btn-scan-global') btn.innerText = "🌍 掃描全域缺失選項";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkScanButtonState() {
|
||||
const btn = document.getElementById('global-scan-btn');
|
||||
const btn = document.getElementById('btn-scan-visible');
|
||||
if (!btn) return;
|
||||
const scanLockUntil = localStorage.getItem('scanLockUntil');
|
||||
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
|
||||
|
|
@ -575,35 +885,23 @@ function checkScanButtonState() {
|
|||
}
|
||||
|
||||
function lockScanButton(durationMs) {
|
||||
const btn = document.getElementById('global-scan-btn');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.style.backgroundColor = "#e67e22";
|
||||
btn.style.cursor = "not-allowed";
|
||||
btn.innerText = "⏳ 背景掃描執行中...";
|
||||
|
||||
setAdminButtonsState(true, "⏳ 背景掃描執行中...");
|
||||
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')) {
|
||||
enableGlobalScanButton();
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
btn.style.backgroundColor = "#bdc3c7";
|
||||
btn.innerText = "⏳ 請先載入樹狀圖";
|
||||
setAdminButtonsState(true, "⏳ 請先載入樹狀圖");
|
||||
}
|
||||
localStorage.removeItem('scanLockUntil');
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
function enableGlobalScanButton() {
|
||||
const btn = document.getElementById('global-scan-btn');
|
||||
if (!btn) return;
|
||||
const scanLockUntil = localStorage.getItem('scanLockUntil');
|
||||
if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) {
|
||||
btn.disabled = false;
|
||||
btn.style.backgroundColor = "#8e44ad";
|
||||
btn.style.cursor = "pointer";
|
||||
btn.innerText = "🔍 掃描畫面缺失選項";
|
||||
setAdminButtonsState(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -640,8 +938,12 @@ window.loadMacDomainList = loadMacDomainList;
|
|||
window.openSystemSettings = openSystemSettings;
|
||||
window.loadRealConfigForFilters = loadRealConfigForFilters;
|
||||
window.saveSystemSettings = saveSystemSettings;
|
||||
window.scanVisibleMissingOptions = scanVisibleMissingOptions;
|
||||
window.scanGlobalMissingOptions = scanGlobalMissingOptions;
|
||||
window.clearVisibleCache = clearVisibleCache;
|
||||
window.scanAllMissingOptions = scanAllMissingOptions;
|
||||
window.clearGlobalCache = clearGlobalCache;
|
||||
window.previewNodeCLI = previewNodeCLI;
|
||||
window.switchFilterMode = switchFilterMode; // 🌟 新增綁定
|
||||
|
||||
// --- 樹狀圖展開與編輯操作 ---
|
||||
window.expandAll = expandAll;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import {
|
||||
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
|
||||
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig
|
||||
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
|
||||
apiSyncLeafOptionsStream // 🌟 新增這一個
|
||||
} from './api.js';
|
||||
import { getGlobalConnectionInfo } from './utils.js';
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ function escapeHTML(str) {
|
|||
// ==========================================
|
||||
// 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 = {};
|
||||
|
||||
// 💡 追蹤當前編輯狀態
|
||||
|
|
@ -28,15 +29,18 @@ export let currentEditPath = null;
|
|||
export let currentEditElementId = null;
|
||||
export let currentEditIsSuccess = false;
|
||||
export let currentEditType = null;
|
||||
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑
|
||||
|
||||
export async function releaseAllLocks() {
|
||||
const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
||||
if (pathsToRelease.length === 0) return;
|
||||
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
||||
if (keysToRelease.length === 0) return;
|
||||
|
||||
const releasePromises = pathsToRelease.map(path => {
|
||||
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||
delete ACTIVE_HEARTBEATS[path];
|
||||
return apiReleaseLock(path, SESSION_USER_ID)
|
||||
const releasePromises = keysToRelease.map(key => {
|
||||
// 🌟 解析出 host 與 path
|
||||
const [host, path] = key.split('@@');
|
||||
clearInterval(ACTIVE_HEARTBEATS[key]);
|
||||
delete ACTIVE_HEARTBEATS[key];
|
||||
return apiReleaseLock(path, SESSION_USER_ID, host)
|
||||
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
|
||||
});
|
||||
|
||||
|
|
@ -45,14 +49,17 @@ export async function releaseAllLocks() {
|
|||
location.reload();
|
||||
}
|
||||
|
||||
async function sendHeartbeat(path) {
|
||||
// 🌟 加入 host 與 lockKey 參數
|
||||
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||
try {
|
||||
const response = await apiSendHeartbeat(path, SESSION_USER_ID);
|
||||
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
|
||||
if (!response.ok) {
|
||||
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
|
||||
if (response.status === 404) {
|
||||
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||
delete ACTIVE_HEARTBEATS[path];
|
||||
clearInterval(intervalId);
|
||||
if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
|
||||
delete ACTIVE_HEARTBEATS[lockKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -63,49 +70,67 @@ async function sendHeartbeat(path) {
|
|||
// ==========================================
|
||||
// 2. 編輯模式啟動與取消 (Folder & Leaf)
|
||||
// ==========================================
|
||||
|
||||
export async function startEditFolder(path, elementId) {
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
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 {
|
||||
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.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(`actions-${elementId}`).style.display = 'inline-block';
|
||||
document.getElementById(`details-${elementId}`).open = true;
|
||||
// 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
|
||||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||
if (editBtn) editBtn.style.display = 'none';
|
||||
|
||||
const actionBtn = document.getElementById(`actions-${elementId}`);
|
||||
if (actionBtn) actionBtn.style.display = 'inline-block';
|
||||
|
||||
const detailsEl = document.getElementById(`details-${elementId}`);
|
||||
if (detailsEl) detailsEl.open = true;
|
||||
|
||||
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||
if (!contentDiv) {
|
||||
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
|
||||
return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯
|
||||
}
|
||||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||
|
||||
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 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 rawPath = container.getAttribute('data-path');
|
||||
|
||||
// ✅ 修改這裡:新增 safeOrigVal
|
||||
const safeOrigVal = escapeHTML(origVal);
|
||||
|
||||
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];
|
||||
|
||||
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
|
||||
pathsToSync.push(cacheKey);
|
||||
}
|
||||
if (!leafCache) pathsToSync.push(cacheKey);
|
||||
|
||||
let inputHtml = "";
|
||||
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;">`;
|
||||
options.forEach(opt => {
|
||||
// ✅ 修改這裡:新增 safeOpt,並替換 value 與顯示文字
|
||||
const safeOpt = escapeHTML(opt);
|
||||
const isSelected = (opt === origVal) ? 'selected' : '';
|
||||
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
||||
});
|
||||
inputHtml += `</select>${hintIcon}`;
|
||||
} 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}`;
|
||||
}
|
||||
container.innerHTML = inputHtml;
|
||||
});
|
||||
|
||||
if (pathsToSync.length > 0) {
|
||||
console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`);
|
||||
apiSyncLeafOptions(pathsToSync);
|
||||
// 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
// 🌟 傳入 host 給 apiSyncLeafOptions
|
||||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||
|
|
@ -149,49 +174,50 @@ export async function startEditFolder(path, elementId) {
|
|||
export async function startEditLeaf(path, elementId) {
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
const username = connInfo ? connInfo.user : 'admin';
|
||||
const host = connInfo ? connInfo.host : '';
|
||||
const lockKey = `${host}@@${path}`;
|
||||
|
||||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||||
|
||||
try {
|
||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username);
|
||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
||||
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
|
||||
|
||||
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(`actions-${elementId}`).style.display = 'inline-block';
|
||||
|
||||
const container = document.getElementById(`container-${elementId}`);
|
||||
const origVal = container.getAttribute('data-original');
|
||||
|
||||
// ✅ 修改這裡:新增 safeOrigVal
|
||||
const safeOrigVal = escapeHTML(origVal);
|
||||
|
||||
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
|
||||
|
||||
try {
|
||||
let optData = await apiGetLeafOptions();
|
||||
// 🌟 修改 1:傳入 host
|
||||
let optData = await apiGetLeafOptions(host, currentMode);
|
||||
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];
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const CACHE_TTL = 86400 * 7;
|
||||
|
||||
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
|
||||
apiSyncLeafOptions([cacheKey]);
|
||||
if (!leafCache) {
|
||||
// 🌟 修改 2:傳入 host
|
||||
apiSyncLeafOptions(host, [cacheKey], currentMode);
|
||||
let found = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
optData = await apiGetLeafOptions();
|
||||
// 🌟 修改 3:傳入 host
|
||||
optData = await apiGetLeafOptions(host, currentMode);
|
||||
leafCache = optData[cacheKey];
|
||||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||||
found = true; break;
|
||||
}
|
||||
}
|
||||
if (!found) console.warn("⏳ 等待選項超時,退回純文字模式");
|
||||
}
|
||||
|
||||
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;">`;
|
||||
options.forEach(opt => {
|
||||
// ✅ 修改這裡:新增 safeOpt
|
||||
const safeOpt = escapeHTML(opt);
|
||||
const isSelected = (opt === origVal) ? 'selected' : '';
|
||||
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
||||
});
|
||||
inputHtml += `</select>${hintIcon}`;
|
||||
} 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}`;
|
||||
}
|
||||
container.innerHTML = inputHtml;
|
||||
} 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;">`;
|
||||
}
|
||||
}
|
||||
|
|
@ -233,11 +255,15 @@ export async function startEditLeaf(path, elementId) {
|
|||
}
|
||||
|
||||
export async function cancelEditFolder(path, elementId) {
|
||||
if (ACTIVE_HEARTBEATS[path]) {
|
||||
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||
delete ACTIVE_HEARTBEATS[path];
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
const host = connInfo ? connInfo.host : '';
|
||||
const lockKey = `${host}@@${path}`;
|
||||
|
||||
if (ACTIVE_HEARTBEATS[lockKey]) {
|
||||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||||
delete ACTIVE_HEARTBEATS[lockKey];
|
||||
}
|
||||
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {}
|
||||
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||||
|
||||
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
||||
|
|
@ -246,25 +272,27 @@ export async function cancelEditFolder(path, elementId) {
|
|||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||
leafContainers.forEach(container => {
|
||||
const origVal = container.getAttribute('data-original');
|
||||
// ✅ 修改這裡:還原時也要跳脫
|
||||
const safeOrigVal = escapeHTML(origVal);
|
||||
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelEditLeaf(path, elementId) {
|
||||
if (ACTIVE_HEARTBEATS[path]) {
|
||||
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||
delete ACTIVE_HEARTBEATS[path];
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
const host = connInfo ? connInfo.host : '';
|
||||
const lockKey = `${host}@@${path}`;
|
||||
|
||||
if (ACTIVE_HEARTBEATS[lockKey]) {
|
||||
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
||||
delete ACTIVE_HEARTBEATS[lockKey];
|
||||
}
|
||||
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {}
|
||||
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
||||
|
||||
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
||||
|
||||
const container = document.getElementById(`container-${elementId}`);
|
||||
const origVal = container.getAttribute('data-original');
|
||||
// ✅ 修改這裡:還原時也要跳脫
|
||||
const safeOrigVal = escapeHTML(origVal);
|
||||
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
||||
}
|
||||
|
|
@ -273,7 +301,12 @@ export async function cancelEditLeaf(path, elementId) {
|
|||
// 3. CLI 生成與預覽 (CLI Generation)
|
||||
// ==========================================
|
||||
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();
|
||||
nodes.forEach(node => {
|
||||
const path = node.getAttribute('data-path');
|
||||
|
|
@ -310,6 +343,7 @@ export async function previewFolderCLI(path, elementId) {
|
|||
});
|
||||
|
||||
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
|
||||
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
|
||||
|
||||
try {
|
||||
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||||
|
|
@ -341,6 +375,7 @@ export async function previewLeafCLI(path, elementId) {
|
|||
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
|
||||
|
||||
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
|
||||
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
|
||||
|
||||
try {
|
||||
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||||
|
|
@ -355,7 +390,8 @@ export async function previewLeafCLI(path, elementId) {
|
|||
}
|
||||
|
||||
function showCliPreviewModal(cliScript) {
|
||||
const leftPane = document.getElementById('tree-container');
|
||||
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
|
||||
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
||||
const resizer = document.getElementById('drag-resizer');
|
||||
const rightPane = document.getElementById('side-cli-preview');
|
||||
|
||||
|
|
@ -369,7 +405,8 @@ function showCliPreviewModal(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)';
|
||||
resizer.style.display = 'flex';
|
||||
rightPane.style.display = 'block';
|
||||
|
|
@ -384,7 +421,8 @@ function showCliPreviewModal(cliScript) {
|
|||
// 4. 側邊欄與執行邏輯
|
||||
// ==========================================
|
||||
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('side-cli-preview').style.display = 'none';
|
||||
|
||||
|
|
@ -456,15 +494,45 @@ async function executeGeneratedCLI(script) {
|
|||
|
||||
try {
|
||||
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass);
|
||||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||||
|
||||
if (result.status === 'success') {
|
||||
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';
|
||||
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 {
|
||||
currentEditIsSuccess = false;
|
||||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||||
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
|
||||
document.getElementById('side-pane-title').style.color = '#ff6b6b';
|
||||
const rawLog = result.detail || result.message || '';
|
||||
|
|
@ -483,7 +551,8 @@ async function executeGeneratedCLI(script) {
|
|||
// ==========================================
|
||||
function initResizer() {
|
||||
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 container = document.getElementById('split-container');
|
||||
let isResizing = false;
|
||||
|
|
@ -505,7 +574,8 @@ function initResizer() {
|
|||
|
||||
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
|
||||
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)`;
|
||||
});
|
||||
|
||||
|
|
@ -557,3 +627,89 @@ function transformNoToActive(elementId, newCommand) {
|
|||
if (actionBtns) actionBtns.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 (檔案結束) ---
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 622 B |
|
|
@ -1,5 +1,87 @@
|
|||
/* --- static/style.css --- */
|
||||
|
||||
/* =========================================
|
||||
全域按鈕色彩系統 (Modern Enterprise Vibe)
|
||||
========================================= */
|
||||
:root {
|
||||
/* 基礎設定 */
|
||||
--btn-radius: 6px;
|
||||
--btn-transition: all 0.2s ease-in-out;
|
||||
|
||||
/* 色彩定義 */
|
||||
--color-emerald: #059669; /* 連線 */
|
||||
--color-emerald-hover: #047857;
|
||||
|
||||
--color-slate: #64748B; /* 斷開/中性/取消 */
|
||||
--color-slate-hover: #475569;
|
||||
|
||||
--color-blue: #2563EB; /* 載入/查詢 */
|
||||
--color-blue-hover: #1D4ED8;
|
||||
|
||||
--color-indigo: #4F46E5; /* 儲存/套用 */
|
||||
--color-indigo-hover: #4338CA;
|
||||
|
||||
--color-violet: #7C3AED; /* 掃描/自動化 */
|
||||
--color-violet-hover: #6D28D9;
|
||||
|
||||
--color-rose: #DC2626; /* 清除/刪除 */
|
||||
--color-rose-hover: #B91C1C;
|
||||
|
||||
--color-disabled-bg: #E2E8F0;
|
||||
--color-disabled-text: #94A3B8;
|
||||
--color-busy-bg: #F59E0B; /* 忙碌中(橘黃) */
|
||||
}
|
||||
|
||||
/* 基礎按鈕共用樣式 */
|
||||
.btn-modern {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: var(--btn-radius);
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
transition: var(--btn-transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 禁用狀態 (所有按鈕共用) */
|
||||
.btn-modern:disabled {
|
||||
background-color: var(--color-disabled-bg) !important;
|
||||
color: var(--color-disabled-text) !important;
|
||||
cursor: not-allowed !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 各別語意 Class */
|
||||
.btn-connect { background-color: var(--color-emerald); }
|
||||
.btn-connect:hover:not(:disabled) { background-color: var(--color-emerald-hover); }
|
||||
|
||||
.btn-disconnect { background-color: var(--color-slate); }
|
||||
.btn-disconnect:hover:not(:disabled) { background-color: var(--color-slate-hover); }
|
||||
|
||||
.btn-load { background-color: var(--color-blue); }
|
||||
.btn-load:hover:not(:disabled) { background-color: var(--color-blue-hover); }
|
||||
|
||||
.btn-save { background-color: var(--color-indigo); }
|
||||
.btn-save:hover:not(:disabled) { background-color: var(--color-indigo-hover); }
|
||||
|
||||
.btn-scan { background-color: var(--color-violet); }
|
||||
.btn-scan:hover:not(:disabled) { background-color: var(--color-violet-hover); }
|
||||
|
||||
.btn-clear { background-color: var(--color-rose); }
|
||||
.btn-clear:hover:not(:disabled) { background-color: var(--color-rose-hover); }
|
||||
|
||||
/* 特殊忙碌狀態 (給掃描或載入時使用) */
|
||||
.btn-modern.is-busy:disabled {
|
||||
background-color: var(--color-busy-bg) !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* 1. 基礎滿版佈局 (全域 80% 縮放感) */
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 15px 20px; box-sizing: border-box; background-color: #f5f7fa; height: 100vh; display: flex; flex-direction: column; font-size: 14px; }
|
||||
h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-size: 24px; }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,57 @@
|
|||
// --- 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) {
|
||||
const details = document.getElementById(`details-${elementId}`);
|
||||
if (details) {
|
||||
details.open = true; // 展開自己
|
||||
const subDetails = details.querySelectorAll('details');
|
||||
subDetails.forEach(d => d.open = true); // 展開所有子項目
|
||||
// 如果還沒載入,先強制載入它
|
||||
if (!details.dataset.loaded) {
|
||||
const isFilter = details.id.includes('filter-');
|
||||
window.lazyLoadFolder(elementId, isFilter);
|
||||
}
|
||||
details.open = true;
|
||||
|
||||
// 找出剛剛渲染出來的第一層子資料夾,遞迴展開
|
||||
const contentDiv = details.querySelector(':scope > div');
|
||||
if (contentDiv) {
|
||||
const childDetails = contentDiv.querySelectorAll(':scope > details');
|
||||
childDetails.forEach(d => {
|
||||
const subId = d.id.replace('details-', '');
|
||||
expandAll(subId); // 遞迴展開
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16,15 +59,15 @@ export function collapseAll(elementId) {
|
|||
const details = document.getElementById(`details-${elementId}`);
|
||||
if (details) {
|
||||
const subDetails = details.querySelectorAll('details');
|
||||
subDetails.forEach(d => d.open = false); // 收合所有子項目
|
||||
details.open = false; // 收合自己
|
||||
subDetails.forEach(d => d.open = false);
|
||||
details.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 🌟 核心函數:生成完整設備配置樹狀圖
|
||||
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
|
||||
// ==========================================
|
||||
export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
|
||||
let 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) {
|
||||
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||||
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>' : '';
|
||||
|
||||
// 🌟 將資料存入快取,供延遲渲染使用
|
||||
folderDataCache[elementId] = value;
|
||||
|
||||
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||||
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||
|
|
@ -64,9 +110,11 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
|||
</span>
|
||||
`;
|
||||
|
||||
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
|
||||
html += `
|
||||
<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"
|
||||
title="${cliCommand}"
|
||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||
|
|
@ -81,7 +129,15 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
|||
${expandCollapseBtns}
|
||||
|
||||
<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;"
|
||||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
|
||||
✏️
|
||||
|
|
@ -93,15 +149,16 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
|||
</div>
|
||||
</summary>
|
||||
<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>
|
||||
</details>
|
||||
`;
|
||||
} else {
|
||||
// 葉節點邏輯保持不變
|
||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||
const isNoCommand = (key === 'no');
|
||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
|
||||
if (isNoCommand) {
|
||||
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
|
||||
|
|
@ -151,7 +208,15 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
|||
${displayContent}
|
||||
|
||||
<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;"
|
||||
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 '';
|
||||
|
||||
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
|
||||
|
|
@ -196,7 +261,10 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
|||
if (isFolder) {
|
||||
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||||
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 iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
||||
|
|
@ -220,8 +288,11 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
|||
</span>
|
||||
`;
|
||||
|
||||
// 🌟 加入 lazyLoadFolder 觸發
|
||||
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;">
|
||||
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
|
||||
${checkboxHtml}
|
||||
|
|
@ -232,12 +303,13 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
|||
<span>${key}${folderSuffix}</span>
|
||||
${expandCollapseBtns}
|
||||
</summary>
|
||||
<div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
|
||||
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
|
||||
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
|
||||
<!-- 🌟 延遲渲染 -->
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
} else {
|
||||
// 葉節點邏輯保持不變
|
||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
{
|
||||
"hidden_keys": [
|
||||
"ssh",
|
||||
"hostname",
|
||||
"clock"
|
||||
]
|
||||
"hidden_keys": []
|
||||
}
|
||||
Loading…
Reference in New Issue