feat(core): 系統底層架構重構、資安強化與極致效能優化

本次提交涵蓋了 Phase 3.5 至 Phase 4 的核心架構翻修,徹底解決了 SSH 互動陷阱、資料庫冗餘、機密外洩以及巨量 DOM 渲染的效能瓶頸。

🏗️ 1. 資料庫與快取架構整併 (DB Consolidation)
- 重構 `cmts_options` 資料表,主鍵簡化為 `(host, path)`,徹底解耦 `config_type`。
- 實現 `running-tree` 與 `full-tree` 共用全域選項字典,達成極致的「增量掃描 (Incremental Scanning)」。
- 強化 `init_db.py` 防呆機制,保留 `config_backups` 避免誤刪珍貴快照。

🔒 2. 企業級資安與權限管理 (Security Hardening)
- 導入 `python-dotenv`,全面落實 12-Factor App 規範。
- 徹底淨化 `index.html`, `shared.py`, `database.py` 中的硬編碼 (Hardcoded) 密碼。
- 建立 `.env.example` 範例檔,確保正式機密碼「零外洩 (Zero Secrets Leakage)」。
- 強化 God Mode 驗證邏輯,未設定環境變數時預設拒絕所有進階授權 (Fail-Fast)。

📡 3. SSH 引擎與 SSE 廣播機制 (SSH & SSE Engine)
- 修復 SSH Echo 與 MOTD 陷阱,導入精準的 `prompt_pattern` 絕對靜音偵測。
- 廢除危險的 `Ctrl+C`,改用底層 `Ctrl+U (\x15)` 瞬間清空輸入緩衝區,確保 100% 停留在 config 模式。
- 完善 SSE (Server-Sent Events) 廣播機制,前端精準呈現「百分比進度條」與「動態狀態文字」。

 4. 前端效能與使用者體驗 (Performance & UX)
- 升級 Regex 解析器,完美支援水平列表與無標題說明,成功找回消失的 `` 提示。
- 導入 HTML5 `<datalist>`,將 Enum 欄位無痛升級為「半自由下拉選單 (Combobox)」。
- 解決「延遲載入 (Lazy Load)」與「編輯模式」的衝突:實作「預先渲染但不展開」機制,結合 `content-visibility: auto` 達成零效能損耗。
- 實作 Xterm.js DOM 事件防火牆 (Capture Phase Interception),完美阻擋第三方外掛產生的殘缺鍵盤事件,消除 Console 紅字報錯。

⚠️ Breaking Changes (破壞性變更)
- 系統啟動前必須建立 `.env` 檔案並填入必要的資料庫與設備連線資訊。
- 資料庫結構已變更,舊有環境需執行 `python init_db.py --reset` 重新建表。
This commit is contained in:
swpa 2026-06-10 14:37:16 +08:00
parent 061ee3435a
commit 041b943ff6
20 changed files with 1515 additions and 1671 deletions

View File

@ -1,51 +1,71 @@
# Harmonic CMTS Manager - AI 開發守則與架構白皮書
# Harmonic CMTS Manager - AI Architect Guidelines & System Blueprint
## 1. 專案架構概覽與技術棧
- **定位**: 專為有線電視網路終端設備 (CMTS) 設計的企業級 Web 管理系統
- **後端**: Python 3.10+, FastAPI (Async-first), AsyncSSH, Netmiko, asyncpg.
- **前端**: 原生 Vanilla JS (ES Modules), HTML5, CSS3, Xterm.js.
- **資料庫**: PostgreSQL (主要), JSON File Cache (高可用性降級備援).
> **⚠️ AI 角色與絕對約束 (AI Persona & Absolute Directives)**
> 你現在是一位資深的系統架構師與全端工程師
> 1. **唯一真理**:絕對禁止自行幻想任何不存在的模組、變數或第三方套件。所有修改必須基於現有架構。
> 2. **語言規範**:註解、對話與 Git Commit 一律使用**繁體中文 (zh-TW)**。
> 3. **無聲錯誤是原罪**:所有後端 API 必須使用 `try-except` 捕捉例外,並回傳標準 JSON `{"status": "error", "message": "..."}`,嚴禁 FastAPI 直接拋出 HTTP 500 導致前端崩潰。
### 📂 目錄與檔案結構
- **進入點**: `main.py`
- **路由管理**: API 路由統一放置於 `routers/` 目錄。包含 backup.py, config.py, leaf_options.py, lock.py, query.py, terminal.py。
- **前端介面**: `index.html``static/` 目錄。 包含 api.js, app.js, edit-mode.js, mac-domain.js, style.css, terminal.js, tree-ui.js, utils.js。
- **核心邏輯**:
- `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()` 進行非同步批次處理。
- **絕對路徑與 Commit 機制 (Absolute Path)**: Harmonic CMTS 支援從 Global 模式直接寫入帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 ...`),無須層層進入子模式。所有變更指令發送完畢後,必須執行 `commit` 才能生效。
- **智慧差異還原 (Smart Diff Recovery)**: 設備還原不採用盲目覆蓋,而是透過深度比對 (Deep Diff) 當前設備狀態與備份快照,動態生成包含 `no` 的反向刪除指令與新增/修改指令,確保配置精準還原且無殘留。
- **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。
- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。
## 🏗️ 1. 系統總體架構 (System Architecture)
## 3. 🚨 AI 開發絕對約束 (Directives for AI)
本系統為專為 Harmonic CableOS 設計的企業級 Web 管理介面,採用前後端分離架構,並透過 WebSocket 與 SSE 實現即時雙向通訊。
### ⚙️ 系統與環境規範
1. **套件管理**: 若需安裝新套件,請提醒我手動在 `cmts_api_env` 中安裝,並更新 `requirements.txt`
2. **資料讀取限制**: 請勿隨意讀取 `*_cache.json` 檔案的內容,若需了解資料結構,請參考 `cmts_scraper.py` 中的定義。
3. **狀態同步**: 完成重大修改或一個 Phase 後,必須主動更新 `PROJECT_STATE.md` 記錄最新進度與待辦事項。
* **後端 (Backend)**: Python 3.10+, FastAPI (Async-first), `asyncssh` (核心連線引擎), `asyncpg` (資料庫連線池)。
* **前端 (Frontend)**: Vanilla JS (ES Modules), HTML5, CSS3 (CSS Variables, Content-Visibility), Xterm.js, Chart.js。
* **資料庫 (Database)**: PostgreSQL (`cmts_nms` 資料庫)。
### 💻 程式碼風格與後端規範
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. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`
10. **精準 Prompt 偵測**: 透過 SSH 讀取設備回傳時,嚴禁盲目依賴 `timeout`。必須在 `read_until_quiet` 中傳入 `prompt_pattern` (如 `r"\(config\)#"``r"(?:#|>)"`) 進行正規表達式匹配,以最高速釋放資源。
---
### 🎨 前端規範
11. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`
12. **UI 狀態安全還原**: 動態修改 DOM 的 CSS 屬性(如 `flex`, `width`)後,若需恢復原狀,嚴禁寫死預設值,必須將屬性設為空字串 `''`,讓瀏覽器自然退回 HTML 定義的樣式,避免改 A 壞 B。
13. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時必須透過 SSE 或 ReadableStream 即時回報進度。
14. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy保留策略/定期清理機制),不可只寫入不刪除。
15. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。
16. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()``?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。
## 🧩 2. 模組化切割與檔案關聯 (Module Map)
### 🟢 後端核心模組 (Backend Core)
* `main.py`: 系統進入點。負責掛載靜態檔案、初始化 DB Pool (`lifespan`),並將所有 API 路由統一掛載於 `/api/v1` 前綴之下。
* `database.py`: PostgreSQL 非同步連線池管理。嚴格區分 `running``full``config_type` 雙軌隔離設計。
* `cmts_scraper.py`: 底層 SSH 爬蟲引擎。負責發送 `?` 探測設備選項、解析終端機分頁 (`--More--`),並清理 ANSI 控制碼。
* `shared.py`: 純邏輯共用區。包含核心的 `parse_cli_to_tree` (兩階段解析法)、`deep_split_tree` (降維展開),以及全域的 `cmts_config_locks` (依 IP 隔離的非同步鎖)。
* `logger.py`: 具備 ANSI 色彩的自訂日誌系統,支援透過 API 動態調整各模組的 Log Level。
### 🔵 後端路由模組 (Routers - `/routers/`)
* `config.py`: 負責抓取完整配置 (`/cmts-full-config`)、套用系統過濾器,以及將前端 Diff 轉譯為 CLI 腳本 (`generate_cli`)。
* `leaf_options.py`: 負責選項快取的背景掃描,並透過 `asyncio.Queue` 實作 SSE (Server-Sent Events) 頻道分流,即時推播掃描進度。
* `lock.py`: 實作 In-Memory 的路徑階層鎖 (`ACTIVE_LOCKS`),支援 Heartbeat 續命與過期自動清理。
* `backup.py`: 設備快照與還原中心。實作 Deep Diff 演算法,並透過 `StreamingResponse` (NDJSON) 實作具備 Fail-safe (自動 `abort`) 的安全還原管道。
* `query.py`: 處理標準 `show` 指令查詢,以及 MAC Domain 的互動式解析。
* `diagnostics.py`: 深度解析 CM 狀態,包含 PHY 功率、SNR 與 OFDM MER 陣列。
* `terminal.py`: WebSocket 代理,將前端 Xterm.js 的輸入轉發至 `asyncssh` 的 PTY。
### 🟡 前端模組 (Frontend - `/static/`)
* `app.js`: 主協調器 (Orchestrator)。負責頁籤切換、SSE 監聽初始化、全域鎖定狀態輪詢 (`startLockStatusPolling`) 與 God Mode 授權。
* `api.js`: 純粹的 Fetch API 封裝層,負責與後端 `/api/v1` 溝通。
* `tree-ui.js`: **效能核心**。負責將 JSON 轉換為 HTML 樹狀圖。採用「記憶體遞迴渲染」與「延遲載入 (`lazyLoadFolder`)」。
* `edit-mode.js`: 編輯狀態機。處理鎖定獲取、UI 狀態切換 (✏️ -> 🔒 -> ⏳)、生成 Diff 陣列,以及右側 CLI 預覽面板的控制。
* `mac-domain.js`: 獨立的 MAC Domain 狀態感知配置精靈邏輯。
* `terminal.js`: Xterm.js 實例化、WebSocket 連線管理與終端機字串上色 (`colorizeTerminalStream`)。
---
## 🚀 3. 核心演算法與開發規範 (Core Mechanisms & Rules)
### ⚡ 3.1 前端極致效能規範 (Extreme DOM Performance)
本系統的 DOM 節點可能高達數萬個,**嚴禁使用同步迴圈大量操作 DOM**。
1. **記憶體遞迴渲染 (In-Memory Rendering)**:在 `tree-ui.js` 中,必須先在 JS 記憶體中將 HTML 字串完全組裝完畢,最後只執行 **1 次** `innerHTML` 寫入。
2. **非同步 UI 保護**:任何大型渲染(如展開全部、初始載入),必須先顯示 `⏳ 載入中...` 並將游標設為 `wait`,接著使用 `setTimeout(..., 20)` 讓出主執行緒,確保瀏覽器不卡死。
3. **CSS 渲染隔離**:依賴 `style.css` 中的 `content-visibility: auto;`,嚴禁在 JS 中破壞 `.tree-folder-content` 的結構。
### 🔒 3.2 併發與鎖定機制 (Concurrency & Locking)
1. **IP 隔離原則**:所有的鎖定 Key 必須是 `host@@path` 格式,確保不同設備間的鎖定互不干擾。
2. **父子階層鎖 (Cascading Lock)**:前端在輪詢鎖定狀態時,必須檢查「自身」、「父節點」與「子節點」的鎖定衝突。
3. **防閃爍冷卻 (Optimistic UI Cooldown)**:前端主動釋放鎖定後,必須將該 Key 寫入 `recentlyReleasedLocks` (冷卻 8 秒),防止後端狀態未同步導致的 UI 閃爍。
### 🛡️ 3.3 設備寫入與安全還原 (Safe SSH Execution)
1. **絕對路徑策略 (Absolute Path)**:寫入設備時,一律生成帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 admin-state down`),嚴禁依賴傳統的層層進入模式。
2. **Fail-safe 撤銷機制**:在 `backup.py``config.py` 的寫入迴圈中,只要偵測到設備回傳 `% Invalid`, `% Incomplete``Error`,必須**立即停止寫入**,並向設備發送 `abort` 指令放棄所有變更。
3. **精準 Prompt 偵測**:使用 `asyncssh` 讀取輸出時,嚴禁盲目等待 Timeout。必須在 `read_until_quiet` 中傳入精準的 `prompt_pattern` (如 `r"\(config.*\)#"``r"(?:#|>)"`)。
### 🌳 3.4 樹狀圖解析與資料結構 (Tree Parsing)
1. **兩階段解析**`shared.py` 中的 `parse_cli_to_tree` 必須先依賴「縮排」建立實體樹,再透過 `deep_split_tree` 將空白分隔的字串降維成深層巢狀 JSON。
2. **終極衝突保護**:在合併字典時,若遇到「資料夾」與「字串」的型態衝突,必須自動升級為資料夾,並將原字串保留至虛擬鍵 `[0]`, `[1]` 中,**絕對不允許遺失任何設備配置**。
3. **雙軌記憶體**:前端 `window.treeDataStore` 必須嚴格區分 `running``full`,切換視圖時純粹切換 CSS `display`,不銷毀資料。

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
# --- .env.example ---
# Database Configuration (請填入你的本地端或正式機設定)
DB_NAME=cmts_nms
DB_USER=
DB_PASS=
DB_HOST=127.0.0.1
DB_PORT=5432
# God Mode Secret
GOD_MODE_SECRET=
# Default CMTS Device
DEFAULT_CMTS_HOST=
DEFAULT_CMTS_USER=
DEFAULT_CMTS_PASS=

5
.gitignore vendored
View File

@ -4,3 +4,8 @@ cmts_api_env/
*_cache.json
filters_*.json
*.bk
# Environments
.env
.env.*
!.env.example

View File

@ -6,37 +6,54 @@
## ✅ 已完成開發階段 (Completed Phases)
### Phase 1: PostgreSQL 高可用性架構升級 (Completed)
### Phase 1: PostgreSQL 高可用性架構升級
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
- [x] 調整 `init_db.py` 為非同步啟動腳本。
### Phase 2: 設備配置備份與快照機制 (Completed)
### Phase 2: 設備配置備份與快照機制
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。
- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表。
- [x] **前端 UI 優化與防禦性編程**:實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
### Phase 3: 智慧差異還原與歷史預覽 (Completed)
- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
### Phase 3: 智慧差異還原與歷史預覽
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
- [x] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 `ReadableStream` 即時渲染後端傳來的 SSH 逐行執行 Log並具備自動捲動功能。
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**
- 實作 `/api/v1/backups/{id}/restore` API採用 `StreamingResponse` (NDJSON 串流回應)。
- 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止、發送 `abort` 撤銷變更,並回傳錯誤 Chunk。
- [x] **效能與體驗優化**:完成 DOM 陣列渲染優化、Terminal 資源回收 (防殭屍連線)、精準 Prompt 偵測,以及 UI 視窗連動防呆機制。
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:實作 `/api/v1/backups/{id}/restore` API採用 `StreamingResponse` (NDJSON 串流回應),並具備 Fail-safe `abort` 撤銷機制。
### Phase 3.5: 企業級前端效能重構 (Extreme Performance Optimization)
- [x] **記憶體遞迴渲染 (In-Memory Recursive Rendering)**:徹底重構 `tree-ui.js`,將數千次 DOM 寫入壓縮為單次 `innerHTML` 寫入,解決「展開全部」導致瀏覽器卡死的問題。
- [x] **非同步 UI 保護機制**:在所有大型渲染場景 (初始載入、單點展開、全部展開) 導入 `setTimeout` 讓出主執行緒,並搭配沙漏游標與橘色讀取提示,確保 UI 絕對滑順。
- [x] **CSS 渲染隔離**:導入 `content-visibility: auto`,讓不在可視範圍內的 DOM 節點暫停渲染計算。
- [x] **精準 DOM 查詢**:將鎖定狀態輪詢 (`startLockStatusPolling`) 的搜尋範圍限縮於當前啟用的視圖內,消除全域搜尋造成的卡頓 (Jank)。
---
## 🚧 目前開發階段 (Current Phase)
## 🚀 即將到來的里程碑 (Upcoming Milestones Summary)
### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management)
- [ ] **自動備份攔截 (Auto-Backup Interceptor)**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制,確保每次變更前都有還原點。
- [ ] **備份保留策略 (Retention Policy)**:實作定期清理過期或過多歷史快照的機制,防止資料庫無限膨脹。
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
- [x] **智能視覺診斷 (Visual Diagnostics)**
- CM 一鍵診斷中心整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**
- 重新 Review `generate_diff_commands` 演算法。
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)**
- 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。
- 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。
- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)**
- **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
- [ ] **RPD 樣板克隆引擎 (Template Cloning)**
- 於「MAC Domain 狀態感知配置精靈」中新增 RPD 擴容模式。允許使用者選擇現有設備上已配置完成的 RPD (支援 FDX, FDD, D3.1+) 作為基準樣板。
- [ ] **Tree 節點複製與參數替換 (Node Duplication & Modification)**
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
- [ ] **安全寫入與校驗 (Safe Provisioning)**
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
---
## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
- 目前系統運行穩定,各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。
## 🐛 已知問題與技術債 (Known Issues & Tech Debt)
- 目前系統運行極度穩定,前端效能瓶頸已徹底消除,各項併發鎖定與 UI 狀態連動皆已完善。準備進入 Phase 4 的 Diff 引擎強化開發。

File diff suppressed because it is too large Load Diff

View File

@ -6,113 +6,108 @@ import json
import os
import time
import database
from shared import USE_DB
from logger import get_logger
logger = get_logger("app.scraper")
def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
"""解析 '?' 回傳內容,無差別掃描支援所有標題排列組合"""
hint_lines = []
options = []
current_value = None
format_desc = None
is_format_only = False
state = "INIT"
has_description = False
has_bracket_value = False
# 🌟 Case-5 特殊處理dhcp-relay 混合型欄位 (選項 + IP輸入)
# 直接在開頭掃描完整輸出,若包含 dhcp-relay強制轉為純文字輸入框
if "dhcp-relay" in output.lower():
is_format_only = True
format_desc = "IP address or options"
for line in output.splitlines():
line = line.strip()
# 過濾雜訊與終端機提示字元
if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
# ==========================================
# 🛡️ 1. 終極雜訊與 Echo 過濾器
# ==========================================
if not line or line.startswith('%') or line.startswith('^'):
continue
if line.startswith('Description:'):
state = "DESC"
has_description = True
hint_lines.append(line)
# 攔截 Echo 的問號指令 (例如 "logging buffered ?")
if line.endswith('?'):
continue
if line.startswith('Possible completions:'):
state = "COMPLETIONS"
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
# 攔截終端機 Prompt (例如 "admin@SERCOMM-COS-02(config)# ..." 或 "admin@SERCOMM-COS-02>")
if re.search(r"[a-zA-Z0-9_.@-]+\(.*\)#", line) or re.search(r"^[a-zA-Z0-9_.@-]+>", line):
continue
if state == "DESC":
hint_lines.append(line)
elif state == "COMPLETIONS":
# Case 1~4: 無 Description 時,將後續選項說明也納入提示
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
# 規則 1: <格式說明>[當前值]
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
if match_format:
is_format_only = True
format_desc = match_format.group(1).strip()
if match_format.group(2):
current_value = match_format.group(2).strip()
continue
# 規則 1.5: 型態, 最小值 .. 最大值
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
if match_range:
is_format_only = True
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
continue
# 規則 1.6: IP address 等純文字關鍵字
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
if match_keyword:
is_format_only = True
format_desc = match_keyword.group(1)
continue
# 規則 2: 選項列表處理
match_current = re.search(r"^\[([^\]]+)\]", line)
if match_current:
has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單
if not current_value:
current_value = match_current.group(1).strip()
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
else:
clean_line = line.strip()
# ==========================================
# 🌟 Case-6 防呆:過濾垂直列表的說明文字
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線
if re.search(r"\s{3,}", clean_line):
clean_line = re.split(r"\s{3,}", clean_line)[0]
parts = clean_line.split()
for p in parts:
if p and p not in ["|", ".."]:
options.append(p)
# 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看)
hint_lines.append(line)
# 🌟 Case 2, 3, 4: 子命令防呆機制
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
# 3. 略過純標題行,不進行選項解析
if line.startswith('Description:') or line.startswith('Possible completions:'):
continue
# 4. 解析格式與選項 (無差別掃描每一行)
# 格式 A: <格式說明>[當前值] (例如: <string, min: 0 chars, max: 128 chars>[Cold Start])
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
if match_format:
is_format_only = True
format_desc = match_format.group(1).strip()
if match_format.group(2):
current_value = match_format.group(2).strip()
continue
# 格式 B: 型態, 最小值 .. 最大值 (例如: unsignedInt, 1 .. 3000)
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
if match_range:
is_format_only = True
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
continue
# 格式 C: 純文字關鍵字
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
if match_keyword:
is_format_only = True
format_desc = match_keyword.group(1)
continue
# 格式 D: 選項列表 [現值] 選項1 選項2
match_current = re.search(r"^\[([^\]]+)\]", line)
if match_current:
has_bracket_value = True
if not current_value:
current_value = match_current.group(1).strip()
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
else:
clean_line = line.strip()
# 分離選項與說明 (完美支援水平列表,如 severity size-mb)
parts = re.split(r'\s{2,}', clean_line)
valid_options = []
for p in parts:
p = p.strip()
if not p: continue
# 如果這個片段包含空白,代表它是說明文字 (Description),停止解析後續片段
if ' ' in p:
break
valid_options.append(p)
for p in valid_options:
if p not in ["|", ".."]:
options.append(p)
# 子命令防呆:如果抓到一堆單字,但沒有 [現值],且不是已知格式,很可能是子命令列表
if not has_bracket_value and not is_format_only and options:
is_format_only = True
options = []
# 確保現值一定包含在選項中(如果它是一般的下拉選單)
if current_value and options and current_value not in options:
options.append(current_value)
hint_text = "\n".join(hint_lines).strip()
return {
"hint": hint_text,
# 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
"hint": "\n".join(hint_lines).strip(),
"options": list(dict.fromkeys(options)) if not is_format_only else [],
"current_value": current_value,
"format_desc": format_desc,
@ -120,9 +115,8 @@ def parse_question_mark_output(output: str) -> dict:
}
def parse_device_response(output: str) -> dict:
"""支援多種編輯狀態格式解析"""
# 完美支援 (<string, min: 0 chars, max: 128 chars>) (Cold Start): 格式
match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
if match:
enum_content = match.group(1)
desc_content = match.group(2)
@ -144,11 +138,9 @@ def parse_device_response(output: str) -> dict:
"current_value": current_value,
"format_desc": desc_content.strip()
}
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
# 🌟 1. 函數簽名加上 config_type 參數,預設為 "running"
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
try:
total_paths = len(leaf_paths)
processed_count = 0
@ -156,13 +148,12 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
BATCH_SIZE = 30
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
cmts_version = "unknown"
for batch_idx, batch in enumerate(batches):
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
@ -176,43 +167,44 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError:
break
return output
# 🌟 新增:在第一批次連線時,先抓取設備版本
# 🌟 關鍵修復 1等待登入歡迎詞 (MOTD) 結束,確保設備準備好接收指令
await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
if cmts_version == "unknown":
# 🌟 補上 | nomore並稍微延長等待時間確保不會被分頁卡住
process.stdin.write("show version | nomore\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
match = re.search(r"(?:infra|vcmts-cd-0|CableOS)\s+([\w\.\-]+)", version_output, re.IGNORECASE)
if match:
cmts_version = match.group(1)
else:
cmts_version = "parse_failed" # 🌟 避免正則失敗導致每批次都重查
# 🌟 關鍵修復 2確保成功進入 config 模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config.*\)#")
for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
output_question = await read_until_quiet(timeout=1.0)
output_question = await read_until_quiet(timeout=0.8)
q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
# 🌟 頂級優雅解法:使用 Ctrl+U (\x15) 瞬間清空整行輸入緩衝區
process.stdin.write("\x15")
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
await read_until_quiet(timeout=0.2)
parsed_data = {
"hint": q_data["hint"],
@ -221,7 +213,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
"current_value": q_data.get("current_value")
}
# --- 步驟 2判斷是否需要發送 Enter ---
if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number"
@ -229,7 +220,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
process.stdin.write(f"{path}\n")
await process.stdin.drain()
output_enter = await read_until_quiet(timeout=1.0)
output_enter = await read_until_quiet(timeout=0.8)
enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"]
@ -237,25 +228,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
parsed_data["current_value"] = enter_data["current_value"]
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
# 如果進入了互動式輸入 (例如 prompt 變成 (val): ),按 Enter 接受預設值並退出
process.stdin.write("\n")
await process.stdin.drain()
else:
logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain()
# 如果只是印出錯誤,我們不需要做什麼
pass
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
processed_count += 1
event_data = {
yield json.dumps({
"event": "progress",
"current": processed_count,
"total": total_paths,
"current_path": path
}
yield json.dumps(event_data) + "\n"
}) + "\n"
process.stdin.write("exit\n")
await process.stdin.drain()
@ -265,75 +255,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue
# --- 步驟 3寫入快取 (DB or JSON) ---
try:
db_success = False
current_ts = int(time.time())
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
# 1. 嘗試寫入資料庫
if USE_DB:
try:
# 寫入 metadata
if cmts_version != "unknown":
await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
else:
await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time})
# 寫入 options
db_write_count = 0
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
if success:
db_write_count += 1
if db_write_count > 0:
db_success = True
logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
else:
logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
except Exception as e:
logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
# 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON
if not db_success:
# 🌟 動態決定快取檔名 (加入 IP 隔離)
safe_host = host.replace(".", "_")
cache_file = f"{safe_host}_{config_type}_cache.json"
# 寫入資料庫
if cmts_version not in ["unknown", "parse_failed"]:
await database.upsert_device_status(host, {"cmts_version": cmts_version, "last_scanned": formatted_time})
else:
await database.upsert_device_status(host, {"last_scanned": formatted_time})
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 {}
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
await database.upsert_leaf_option(host, p, result_data[p])
cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
# 🌟 新增:寫入 Metadata (版本與掃描時間)
if "__metadata__" not in cache_data:
cache_data["__metadata__"] = {}
if cmts_version != "unknown":
cache_data["__metadata__"]["cmts_version"] = cmts_version
cache_data["__metadata__"]["last_scanned"] = formatted_time
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p]
def write_json_to_file(filepath, data):
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
logger.info(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已寫入資料庫!")
except Exception as e:
logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
logger.error(f"⚠️ 資料庫寫入失敗: {e}")
if batch_idx < len(batches) - 1:
await asyncio.sleep(2)
@ -344,9 +283,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
"""連線至設備並抓取完整的純文字設定檔"""
try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
@ -357,65 +294,46 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
# 自動翻頁
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output):
break
except asyncio.TimeoutError:
break
return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
if config_type == "full":
# 1. 先進入 config 模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
# 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n")
await process.stdin.drain()
else:
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
# 最終退出設備
process.stdin.write("exit\n")
await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [
line for line in lines
if not line.strip().startswith(cmd)
@ -426,14 +344,5 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
except Exception as e:
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
finally:
if conn:
conn.close()
def parse_config_to_tree(raw_cli: str) -> dict:
"""將純文字設定檔轉換為簡單的階層式 JSON 樹狀圖 (Phase 3 會用到)"""
# 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化
tree = {}
# 暫時回傳空字典,確保 Phase 1 & 2 能順利走通
# 真正的樹狀解析邏輯我們可以在 Phase 3 完善
return {"_raw_length": len(raw_cli), "status": "pending_parser"}
return {"_raw_length": len(raw_cli), "status": "pending_parser"}

View File

@ -1,22 +1,27 @@
import asyncpg
import json
import uuid
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
from typing import Dict, List, Optional, Any
from logger import get_logger
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
# ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版)
# ==========================================
logger = get_logger("app.database")
# DB 設定 (從您的 init_db.py 中提取)
# 🌟 2. 同步改用 os.getenv 讀取環境變數
DB_CONFIG = {
"database": "cmts_nms",
"user": "swpa",
"password": "swpa4920",
"host": "127.0.0.1",
"port": "5432"
"database": os.getenv("DB_NAME", "cmts_nms"),
"user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": os.getenv("DB_HOST", "127.0.0.1"),
"port": os.getenv("DB_PORT", "5432")
}
_pool: Optional[asyncpg.Pool] = None
@ -56,148 +61,95 @@ async def get_pool() -> Optional[asyncpg.Pool]:
# CRUD Functions for cmts_options
# ------------------------------------------
async def upsert_leaf_option(host: str, config_type: str, path: str, data: dict) -> bool:
"""將選項寫入或更新至資料庫"""
async def upsert_leaf_option(host: str, path: str, data: dict) -> bool:
pool = await get_pool()
if not pool:
return False
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)
INSERT INTO cmts_options (host, path, data)
VALUES ($1, $2, $3)
ON CONFLICT (host, path)
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, config_type, path, json.dumps(data))
await conn.execute(query, host, path, json.dumps(data))
return True
except asyncpg.PostgresError as e:
except Exception 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 的所有選項"""
async def get_all_leaf_options(host: str) -> Optional[Dict[str, Any]]:
pool = await get_pool()
if not pool:
return None
query = """
SELECT path, data FROM cmts_options
WHERE host = $1 AND config_type = $2;
"""
if not pool: return None
query = "SELECT path, data FROM cmts_options WHERE host = $1;"
try:
async with pool.acquire() as conn:
records = await conn.fetch(query, host, config_type)
records = await conn.fetch(query, host)
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
result[record['path']] = json.loads(data_val) if isinstance(data_val, str) else data_val
return result
except asyncpg.PostgresError as e:
except Exception 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:
"""刪除特定路徑的選項快取"""
async def delete_leaf_options(host: str, paths: List[str]) -> int:
pool = await get_pool()
if not pool or not paths:
return -1
query = """
DELETE FROM cmts_options
WHERE host = $1 AND config_type = $2 AND path = ANY($3);
"""
if not pool or not paths: return -1
query = "DELETE FROM cmts_options WHERE host = $1 AND path = ANY($2);"
try:
async with pool.acquire() as conn:
# 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
status = await conn.execute(query, host, paths)
return int(status.split()[-1])
except Exception as e:
logger.error(f"Unknown Error (delete_leaf_options): {e}")
logger.error(f"❌ DB 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"""
async def upsert_device_status(host: str, metadata: dict) -> bool:
pool = await get_pool()
if not pool:
return False
if not pool: return False
cmts_version = metadata.get("cmts_version")
last_scanned = metadata.get("last_scanned", None)
try:
async with pool.acquire() as conn:
# 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號!
if cmts_version and cmts_version != "unknown":
query = """
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
VALUES ($1, $2, $3, $4)
ON CONFLICT (host, config_type)
DO UPDATE SET
cmts_version = EXCLUDED.cmts_version,
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO device_status (host, cmts_version, last_scanned)
VALUES ($1, $2, $3)
ON CONFLICT (host)
DO UPDATE SET cmts_version = EXCLUDED.cmts_version, last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, config_type, cmts_version, last_scanned)
await conn.execute(query, host, cmts_version, last_scanned)
else:
query = """
INSERT INTO device_status (host, config_type, last_scanned)
VALUES ($1, $2, $3)
ON CONFLICT (host, config_type)
DO UPDATE SET
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO device_status (host, last_scanned)
VALUES ($1, $2)
ON CONFLICT (host)
DO UPDATE SET last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, config_type, last_scanned)
await conn.execute(query, host, last_scanned)
return True
except Exception as e:
logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
"""獲取設備 metadata"""
async def get_device_status(host: str) -> Optional[Dict[str, Any]]:
pool = await get_pool()
if not pool:
return None
query = """
SELECT cmts_version, last_scanned FROM device_status
WHERE host = $1 AND config_type = $2;
"""
if not pool: return None
query = "SELECT cmts_version, last_scanned FROM device_status WHERE host = $1;"
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, host, config_type)
record = await conn.fetchrow(query, host)
if record:
return {
"cmts_version": record["cmts_version"],
"last_scanned": record["last_scanned"]
}
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}")
logger.error(f"❌ DB Error (get_device_status): {e}")
return None
# ------------------------------------------

View File

@ -20,9 +20,10 @@
<div class="global-settings">
<label><strong>🌐 目標設備:</strong></label>
<input type="text" id="cmtsHost" placeholder="IP 地址" value="10.14.110.4" style="width: 130px;">
<input type="text" id="cmtsUser" placeholder="帳號" value="admin" style="width: 100px;">
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;">
<!-- 🌟 修改後 (徹底淨化) -->
<input type="text" id="cmtsHost" placeholder="IP 地址" style="width: 130px;">
<input type="text" id="cmtsUser" placeholder="帳號" style="width: 100px;">
<input type="password" id="cmtsPass" placeholder="密碼" style="width: 100px;">
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
@ -338,10 +339,8 @@
</span>
</div>
<div style="display: flex; gap: 10px;">
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描局部缺失</button>
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">清除局部快取</button>
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
<button id="btn-scan-options" onclick="scanMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">🚀 掃描缺失選項</button>
<button id="btn-clear-options" onclick="clearOptionsCache()" class="btn-modern btn-clear" style="display: none;">🗑️ 清除選項快取</button>
</div>
</div>

View File

@ -1,20 +1,26 @@
import asyncio
import asyncpg
import logging
import sys
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# DB 設定
# 🌟 2. 改用 os.getenv 讀取環境變數,並保留原本的設定作為安全預設值
DB_CONFIG = {
"database": "cmts_nms",
"user": "swpa",
"password": "swpa4920",
"host": "127.0.0.1",
"port": "5432"
"database": os.getenv("DB_NAME", "cmts_nms"),
"user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": os.getenv("DB_HOST", "127.0.0.1"),
"port": os.getenv("DB_PORT", "5432")
}
async def init_database():
async def init_database(force_reset: bool = False):
try:
logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...")
conn = await asyncpg.connect(
@ -25,34 +31,46 @@ async def init_database():
port=int(DB_CONFIG["port"])
)
# 1. 建立選項快取表 cmts_options
logger.info("🛠️ 正在建立 cmts_options 資料表...")
# ==========================================
# 💣 核彈模式:清除舊有資料表
# ==========================================
if force_reset:
logger.warning("⚠️ 警告:啟動強制重建模式,正在刪除現有資料表...")
await conn.execute("DROP TABLE IF EXISTS cmts_options;")
await conn.execute("DROP TABLE IF EXISTS device_status;")
await conn.execute("DROP TABLE IF EXISTS system_filters;")
# 💡 備份表通常極度重要,即使 reset 也不建議輕易 DROP除非你確定要連備份一起砍
# await conn.execute("DROP TABLE IF EXISTS config_backups;")
logger.info("🗑️ 舊資料表已清除完畢。")
# ==========================================
# 🏗️ 安全建置模式:建立資料表 (IF NOT EXISTS)
# ==========================================
# 1. 建立選項快取表 cmts_options (無 config_type)
logger.info("🛠️ 正在檢查/建立 cmts_options 資料表...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS cmts_options (
host VARCHAR(255) NOT NULL,
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)
PRIMARY KEY (host, path)
);
""")
# 2. 建立設備狀態表 device_status
logger.info("🛠️ 正在建立 device_status 資料表...")
# 2. 建立設備狀態表 device_status (無 config_type)
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,
host VARCHAR(255) NOT NULL PRIMARY KEY,
cmts_version VARCHAR(100) DEFAULT 'unknown',
last_scanned VARCHAR(100),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (host, config_type)
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 3. 建立過濾器設定表 system_filters
logger.info("🛠️ 正在建立 system_filters 資料表...")
logger.info("🛠️ 正在檢查/建立 system_filters 資料表...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS system_filters (
config_type VARCHAR(50) PRIMARY KEY,
@ -61,8 +79,8 @@ async def init_database():
);
""")
# 4. 建立設備配置備份表 config_backups (Phase 1 新增)
logger.info("🛠️ 正在建立 config_backups 資料表與索引...")
# 4. 建立設備配置備份表 config_backups
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS config_backups (
id UUID PRIMARY KEY,
@ -70,6 +88,7 @@ async def init_database():
config_type VARCHAR(50) NOT NULL DEFAULT 'running',
timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
snapshot_name VARCHAR(255),
description TEXT DEFAULT '',
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
raw_cli TEXT,
parsed_tree JSONB,
@ -77,7 +96,6 @@ async def init_database():
);
""")
# 建立複合索引以加速列表查詢與排序
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
ON config_backups (host, config_type, timestamp DESC);
@ -90,4 +108,18 @@ async def init_database():
logger.error(f"❌ 資料庫初始化失敗: {e}")
if __name__ == "__main__":
asyncio.run(init_database())
# 偵測命令列參數是否包含 --reset
is_reset = "--reset" in sys.argv
if is_reset:
print("\n" + "="*50)
print("🚨 你正在執行資料庫重置 (--reset) 🚨")
print("這將會清空所有的選項快取與系統設定!")
print("="*50 + "\n")
confirm = input("確定要繼續嗎?(輸入 yes 繼續): ")
if confirm.lower() != "yes":
print("已取消操作。")
sys.exit(0)
asyncio.run(init_database(force_reset=is_reset))

View File

@ -13,8 +13,8 @@ class GodModeRequest(BaseModel):
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
async def verify_god_mode(req: GodModeRequest):
# 從環境變數讀取正確密碼,預設為 "admin999"
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
# 從環境變數讀取正確密碼,預設為 "19760107@Serc0mm"
expected_password = os.getenv("GOD_MODE_SECRET", "19760107@Serc0mm")
# 使用 compare_digest 防禦計時攻擊
if secrets.compare_digest(req.password, expected_password):

View File

@ -10,7 +10,7 @@ from pydantic import BaseModel
from typing import List
from netmiko import ConnectHandler
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree
from collections import defaultdict
from fastapi.responses import StreamingResponse
from logger import get_all_log_levels, set_log_level
@ -26,45 +26,18 @@ def get_filter_file_path(config_type: str) -> str:
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 []
try:
db_filters = await database.get_tree_filters(config_type)
return db_filters if db_filters is not None else []
except Exception as e:
print(f"資料庫讀取過濾器發生例外: {e}")
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}")
try:
await database.upsert_tree_filters(config_type, hidden_keys)
except Exception as e:
print(f"資料庫寫入過濾器發生例外: {e}")
# ==========================================

View File

@ -3,31 +3,19 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import List
import json, os, asyncio, re
import json, asyncio, re
import database
from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE, USE_DB
from shared import CMTS_DEVICE
router = APIRouter(tags=["Options"])
# 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
def get_cache_file(host: str, config_type: str):
safe_host = host.replace(".", "_")
return f"{safe_host}_{config_type}_cache.json"
# ==========================================
# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
# ==========================================
# 將狀態改為空字典,動態根據 host_configType 建立
SCAN_STATUS = {}
active_clients = {}
async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
"""將訊息推播給特定頻道的連線前端"""
channel_key = f"{host}_{config_type}"
async def broadcast_message(message_dict: dict, host: str):
channel_key = host
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()):
@ -39,9 +27,8 @@ async def broadcast_message(message_dict: dict, host: str, config_type: str = "r
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}"
async def sse_stream(request: Request, host: str):
channel_key = host
client_queue = asyncio.Queue()
if channel_key not in active_clients:
@ -51,92 +38,58 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
async def event_generator():
try:
while True:
# 主動檢查斷線,避免死鎖
if await request.is_disconnected():
break
try:
# [修復 Leak] 延長 timeout 降低輪詢負擔
message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
yield message
except asyncio.TimeoutError:
yield ": keepalive\n\n"
except asyncio.CancelledError:
pass
finally:
# [修復 Leak] 確保斷線時 Queue 絕對會被移出 active_clients 釋放記憶體
if channel_key in active_clients:
active_clients[channel_key].discard(client_queue)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/scan-status")
async def get_scan_status(host: str, config_type: str = "running"):
channel_key = f"{host}_{config_type}"
return {"is_scanning": SCAN_STATUS.get(channel_key, False)}
async def get_scan_status(host: str):
return {"is_scanning": SCAN_STATUS.get(host, False)}
# ==========================================
# 🌟 3. 快取讀取與背景掃描任務
# ==========================================
class SyncOptionsRequest(BaseModel):
host: str # 🌟 新增 host 參數
username: str = "" # 🌟 新增 username 參數
password: str = "" # 🌟 新增 password 參數
host: str
username: str = ""
password: str = ""
leaf_paths: List[str]
config_type: str = "running"
@router.get("/cmts-leaf-options")
async def get_leaf_options(host: str, config_type: str = "running"):
if USE_DB:
try:
db_options = await database.get_all_leaf_options(host, config_type)
if db_options is not None:
# 取得 metadata
metadata = await database.get_device_status(host, config_type)
if metadata:
db_options["__metadata__"] = metadata
return db_options
else:
# 若回傳 None 表示 DB 連線異常,執行 Fallback
print("⚠️ [Fallback] 資料庫無法取得資料,自動切換至 JSON 快取讀取...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫讀取發生例外: {e},自動切換至 JSON 快取讀取...")
cache_file = get_cache_file(host, config_type)
if not os.path.exists(cache_file): return {}
# 🌟 優化 3將讀取動作丟到背景執行緒
def read_json_from_file(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
return await asyncio.to_thread(read_json_from_file, cache_file)
async def run_scan_task(host: str, username: str, password: str, paths: List[str], config_type: str):
"""在背景執行的爬蟲任務,並將進度廣播出去"""
global SCAN_STATUS
channel_key = f"{host}_{config_type}"
async def get_leaf_options(host: str):
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)
db_options = await database.get_all_leaf_options(host)
if db_options is not None:
metadata = await database.get_device_status(host)
if metadata:
db_options["__metadata__"] = metadata
return db_options
raise HTTPException(status_code=500, detail="資料庫連線異常")
except Exception as e:
await broadcast_message({"event": "error", "message": str(e)}, host, config_type)
raise HTTPException(status_code=500, detail=f"資料庫讀取失敗: {e}")
async def run_scan_task(host: str, username: str, password: str, paths: List[str]):
global SCAN_STATUS
channel_key = host
try:
await broadcast_message({"event": "scan_start"}, host)
async for chunk in sync_cmts_leaves_async(host=host, username=username, password=password, leaf_paths=paths):
if isinstance(chunk, str):
await broadcast_message(json.loads(chunk), host)
else:
await broadcast_message(chunk, host)
await broadcast_message({"event": "done"}, host)
except Exception as e:
await broadcast_message({"event": "error", "message": str(e)}, host)
finally:
# 解除特定頻道的鎖定
SCAN_STATUS[channel_key] = False
@router.post("/cmts-leaf-options/sync")
@ -145,14 +98,12 @@ async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: Backg
if not request.leaf_paths:
raise HTTPException(status_code=400)
channel_key = f"{request.host}_{request.config_type}"
channel_key = request.host
if SCAN_STATUS.get(channel_key, False):
return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"}
return {"status": "busy", "message": "⚠️ 該設備的掃描任務正在執行中,請勿重複點擊!"}
SCAN_STATUS[channel_key] = True
# 保留您原本完美的路徑清理邏輯
cmts_query_paths = []
for p in request.leaf_paths:
clean_p = p.replace("::", " ")
@ -160,60 +111,18 @@ async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: Backg
cmts_query_paths.append(clean_p)
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
# 決定帳密 (優先使用 request 傳來的,否則 fallback 到 shared.CMTS_DEVICE)
# 🌟 加上 or "" 確保最終結果絕對是字串,消除 Pylance 警告
user = request.username or CMTS_DEVICE.get("username") or ""
pwd = request.password or CMTS_DEVICE.get("password") or ""
background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths, request.config_type)
background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths)
return {"status": "started"}
# ==========================================
# 🌟 4. 清除快取 API
# ==========================================
@router.post("/clear_cache")
async def clear_specific_cache(host: str, config_type: str = "running", paths_to_clear: list = Body(...)):
cleared_count = 0
# 1. 嘗試清除 DB
if USE_DB:
try:
db_cleared = await database.delete_leaf_options(host, config_type, paths_to_clear)
if db_cleared >= 0:
cleared_count = db_cleared
else:
print("⚠️ [Fallback] 資料庫清除失敗,自動切換至 JSON 快取清除...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫清除發生例外: {e},自動切換至 JSON 快取清除...")
# 2. 清除 JSON (如果 DB 沒清掉,或 USE_DB=False或者是連同舊檔一起清確保乾淨)
cache_file = get_cache_file(host, config_type)
if not os.path.exists(cache_file):
# 如果是走 DB且有清掉回傳 DB 的結果
if USE_DB and cleared_count > 0:
return {"status": "success", "cleared_count": cleared_count}
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
async def clear_specific_cache(host: str, paths_to_clear: list = Body(...)):
try:
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
json_cleared_count = 0
for path in paths_to_clear:
if path in cache_data:
del cache_data[path]
json_cleared_count += 1
if json_cleared_count > 0:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=4)
# 回傳較大的那個數值
final_count = max(cleared_count, json_cleared_count)
return {"status": "success", "cleared_count": final_count}
cleared_count = await database.delete_leaf_options(host, paths_to_clear)
if cleared_count >= 0:
return {"status": "success", "cleared_count": cleared_count}
raise HTTPException(status_code=500, detail="資料庫清除失敗")
except Exception as e:
# 確保回傳標準 JSON 格式
return {"status": "error", "message": f"資料庫連線與快取存取皆失敗: {str(e)}"}
raise HTTPException(status_code=500, detail=f"資料庫清除失敗: {str(e)}")

View File

@ -53,7 +53,10 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
await process.stdin.drain()
except WebSocketDisconnect:
# 主動拋出,讓外層捕捉以進行資源回收
raise
# raise
# 🌟 修正:不要再 raise 拋出去了,直接 return 結束任務,讓外層自然回收
logger.debug("前端 WebSocket 正常斷開 (使用者重整或關閉網頁)")
return
except asyncio.CancelledError:
pass
except Exception as e:

View File

@ -6,18 +6,17 @@ import re
from collections import defaultdict
DEBUG_MODE = False
USE_DB = True # PostgreSQL Feature Toggle
def debug_print(msg: str):
if DEBUG_MODE:
print(msg)
# 預設的 CMTS 連線樣板
# 🌟 2. 淨化預設的 CMTS 連線樣板
CMTS_DEVICE = {
'device_type': 'cisco_ios',
'host': '10.14.110.4',
'username': 'admin',
'password': 'nsgadmin',
'host': os.getenv("DEFAULT_CMTS_HOST", ""),
'username': os.getenv("DEFAULT_CMTS_USER", ""),
'password': os.getenv("DEFAULT_CMTS_PASS", ""),
'port': 22,
'fast_cli': False,
'global_delay_factor': 2

View File

@ -48,24 +48,21 @@ export async function apiGetFullConfig(host, username, password, skipFilter = fa
return response.json();
}
// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
export async function apiGetLeafOptions(host, configType = 'running') {
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
export async function apiGetLeafOptions(host) {
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}`);
return response.json();
}
// 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
export async function apiSyncLeafOptions(host, paths, configType = 'running') {
export async function apiSyncLeafOptions(host, paths) {
return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
body: JSON.stringify({ host, leaf_paths: paths })
});
}
// 🌟 3. 清除快取 (加上 configType 查詢參數)
export async function apiClearCache(host, paths, configType = 'running') {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
export async function apiClearCache(host, paths) {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paths)
@ -80,9 +77,9 @@ export async function apiGetCmtsVersion(host, username, password) {
return response.json();
}
export async function apiGetScanStatus(host, configType = 'running') {
export async function apiGetScanStatus(host) {
try {
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`);
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}`);
const data = await response.json();
return data.is_scanning;
} catch (e) {
@ -167,37 +164,25 @@ export async function apiListRpds(host, username, password) {
// ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ==========================================
// 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
export async function apiSyncLeafOptionsStream(host, paths, onProgress) {
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
body: JSON.stringify({ host, leaf_paths: paths })
});
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
// 🌟 核心修改:讀取串流資料
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
while (true) {
const { done, value } = await reader.read();
if (done) break; // 伺服器斷開連線 (任務完成)
// 解碼二進位資料為文字
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 因為一次可能收到多行 JSON我們用換行符號切開
const lines = chunk.split("\n").filter(line => line.trim() !== "");
for (const line of lines) {
try {
const data = JSON.parse(line);
onProgress(data); // 將解析後的資料傳給 UI 介面
} catch (e) {
console.error("JSON 解析錯誤:", e, line);
}
onProgress(data);
} catch (e) {}
}
}
}

View File

@ -16,7 +16,9 @@ import {
apiGetCmDiagnostics,
apiListCms, apiListRpds,
apiGetLogLevels, apiSetLogLevel,
apiVerifyGodMode
apiVerifyGodMode,
apiGetLeafOptions, // 🌟 補上這個
apiSyncLeafOptions // 🌟 補上這個
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -40,6 +42,11 @@ import {
} from './mac-domain.js';
// ============================================================================
// 🌟 全域記憶體 (Dual-Track Memory)
// ============================================================================
window.treeDataStore = { running: null, full: null };
// ============================================================================
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
// ============================================================================
@ -48,18 +55,28 @@ let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
function initGlobalSSE(mode = 'running') {
// 初始化全域 SSE 監聽器 (設備級別)
function initGlobalSSE() {
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
if (!isConnected) {
if (globalSSE) {
globalSSE.close();
globalSSE = null;
}
return;
}
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
if (!connInfo || !connInfo.host) return;
if (globalSSE) globalSSE.close();
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
// 🌟 拔除 config_type純粹監聽該設備的廣播
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}`);
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
const statusEl = document.getElementById('scan-status');
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
if (data.event === 'scan_start' || data.event === 'progress') {
setAdminButtonsState(true, "⏳ 系統更新中...");
}
@ -82,7 +99,6 @@ function initGlobalSSE(mode = 'running') {
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
}
// 掃描完成才解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
@ -91,7 +107,6 @@ function initGlobalSSE(mode = 'running') {
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c";
}
// 發生錯誤也要解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
@ -117,6 +132,8 @@ function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer);
lockPollingTimer = setInterval(async () => {
if (!isConnected) return; // 🌟 新增防線:未連線時不進行輪詢
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const host = connInfo.host;
@ -139,82 +156,84 @@ function startLockStatusPolling() {
for (const [path, lockInfo] of Object.entries(currentLocks)) {
const lockKey = `${host}@@${path}`;
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
// 🛡️ 防閃爍機制
if (window.recentlyReleasedLocks[lockKey]) {
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
continue; // 跳過,不當作鎖定
continue;
} else {
delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶
delete window.recentlyReleasedLocks[lockKey];
}
}
newLockState.add(lockKey); // 記憶體加入 Host 標籤
newLockState.add(lockKey);
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
targetBtns.forEach(btn => {
// 略過目前正在編輯的那個實體按鈕
if (btn.id === `edit-btn-${currentEditElementId}`) return;
// 🌟 效能優化:只在當前啟用的視圖中搜尋,避免掃描隱藏的龐大 DOM
const currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (activeContainer) {
const targetBtns = activeContainer.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
targetBtns.forEach(btn => {
if (btn.id === `edit-btn-${currentEditElementId}`) return;
if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
const btnPath = btn.getAttribute('data-path');
if (btnPath === path) {
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
if (lockInfo.user_id !== SESSION_USER_ID) {
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
const btnPath = btn.getAttribute('data-path');
if (btnPath === path) {
btn.title = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
} else {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
}
} else {
// 子節點被父節點鎖定
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
btn.innerText = "🔒";
}
btn.innerText = "🔒";
}
});
});
}
}
// 🔓 任務 2處理「解除鎖定」的節點
activeLockState.forEach(oldKey => {
const [oldHost, oldPath] = oldKey.split('@@');
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
if (oldHost === host && !newLockState.has(oldKey)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
targetBtns.forEach(btn => {
if (btn.innerText === "🔒") {
const btnPath = btn.getAttribute('data-path');
let stillLockedByOther = false;
// 檢查自己是否還在鎖定清單中
if (currentLocks[btnPath]) {
stillLockedByOther = true;
} else {
// 檢查是否有其他父節點鎖定它
for (const lockedPath of Object.keys(currentLocks)) {
if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true;
break;
// 🌟 效能優化:同樣只在當前啟用的視圖中搜尋
const currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (activeContainer) {
const targetBtns = activeContainer.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
targetBtns.forEach(btn => {
if (btn.innerText === "🔒") {
const btnPath = btn.getAttribute('data-path');
let stillLockedByOther = false;
if (currentLocks[btnPath]) {
stillLockedByOther = true;
} else {
for (const lockedPath of Object.keys(currentLocks)) {
if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true;
break;
}
}
}
if (!stillLockedByOther) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
if (!stillLockedByOther) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
});
});
}
}
});
@ -231,7 +250,7 @@ function startLockStatusPolling() {
window.onload = () => {
initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監
// initGlobalSSE(); <--- 🌟 刪除這行!不要在網頁剛開時就偷
startLockStatusPolling(); // 啟動鎖定狀態輪詢
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
@ -420,7 +439,7 @@ function switchConfigTask() {
}
}
initGlobalSSE(currentMode);
initGlobalSSE();
}
}
@ -596,7 +615,7 @@ async function fetchFullConfig(configType = 'running') {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
@ -678,7 +697,8 @@ async function fetchFullConfig(configType = 'running') {
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
window.currentTreeData = finalTreeData;
// 🌟 1. 存入雙軌記憶體,不再污染全域變數
window.treeDataStore[configType] = finalTreeData;
const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
@ -686,19 +706,29 @@ async function fetchFullConfig(configType = 'running') {
if (currentSelectedTask !== expectedTask) return;
if (treeContainer) {
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
if (loadingMsg) loadingMsg.style.display = 'none';
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
if (isSomeoneScanning) {
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000);
} else {
enableGlobalScanButton();
if (needAutoScan) {
setTimeout(() => scanGlobalMissingOptions(configType), 500);
// 🌟 統一渲染作法:先顯示 UI 提示,讓出主執行緒
treeContainer.innerHTML = `<div style="color: #f39c12; font-weight: bold; padding: 15px;">⏳ 正在極速建構樹狀圖,請稍候...</div>`;
document.body.style.cursor = 'wait';
// 因為內部有 await apiGetScanStatus所以這裡使用 async () => {}
setTimeout(async () => {
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
if (loadingMsg) loadingMsg.style.display = 'none';
document.body.style.cursor = 'default';
// 🌟 拔除 configType檢查設備級別的掃描狀態
const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
if (isSomeoneScanning) {
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000);
} else {
enableGlobalScanButton();
if (needAutoScan) {
// 🌟 呼叫新的全域掃描函數
setTimeout(() => scanMissingOptions(), 500);
}
}
}
}, 20);
}
} catch (error) {
@ -707,7 +737,7 @@ async function fetchFullConfig(configType = 'running') {
if (progressInterval) clearInterval(progressInterval);
if (loadingMsg) {
loadingMsg.style.display = 'none';
loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色
loadingMsg.style.color = "#f39c12";
}
}
}
@ -913,16 +943,14 @@ function renderMerChart(channelName, histogramData) {
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================
// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
async function enhanceInputWithOptions(path, elementId, mode = 'running') {
// 🌟 1. 取得當前連線的 IP
// 為現有的 input 加上下拉選項 (不破壞原有結構)
async function enhanceInputWithOptions(path, elementId) {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
try {
// 🌟 2. GET 請求:網址加上 host 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
// 🌟 拔除 config_type 參數
const optData = await apiGetLeafOptions(connInfo.host);
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
@ -944,11 +972,11 @@ async function enhanceInputWithOptions(path, elementId, mode = 'running') {
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
}
} else {
// 🌟 3. POST 請求:把 host 塞進 body 裡面
// 🌟 拔除 config_type 參數
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode })
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey] })
});
}
} catch (e) {
@ -979,58 +1007,38 @@ function extractAllCacheKeys(node, path = '') {
return keys;
}
async function performScan(isGlobal, mode = 'running') {
// 🌟 全新:設備級全域差異掃描
async function scanMissingOptions() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
if (isSomeoneScanning) {
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
return;
}
const treeContainer = document.getElementById(`tree-container-${mode}`);
if (!treeContainer) return;
// 🌟 從雙軌記憶體中精準提取當前視圖的資料樹
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const targetTreeData = window.treeDataStore[mode];
if (!targetTreeData) return alert("請先載入設備配置!");
const statusEl = document.getElementById('scan-status');
try {
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
const optData = await apiGetLeafOptions(connInfo.host);
const pathsToSync = [];
if (isGlobal) {
// 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
if (!window.currentTreeData) return alert("請先載入設備配置!");
const allKeys = extractAllCacheKeys(window.currentTreeData);
allKeys.forEach(cacheKey => {
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
});
} else {
// 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的)
const allContainers = treeContainer.querySelectorAll('.leaf-container');
allContainers.forEach(container => {
const isHiddenByFolder = container.closest('details:not([open])') !== null;
if (!isHiddenByFolder) {
const path = container.getAttribute('data-path');
if (path) {
const origVal = container.getAttribute('data-original') || '';
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
}
}
});
}
const allKeys = extractAllCacheKeys(targetTreeData);
// 差異比對:只挑出資料庫沒有的 Key
allKeys.forEach(cacheKey => {
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
});
if (pathsToSync.length === 0) {
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!";
statusEl.textContent = "✅ 所有欄位都已有最新選項,無需掃描!";
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 3000);
return;
@ -1039,7 +1047,7 @@ async function performScan(isGlobal, mode = 'running') {
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 })
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync })
});
const result = await response.json();
@ -1054,6 +1062,47 @@ async function performScan(isGlobal, mode = 'running') {
}
}
// 🌟 全新:設備級清除快取
async function clearOptionsCache() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
if (isSomeoneScanning) {
alert("⚠️ 系統正在進行選項掃描更新中!\n為避免資料衝突請稍候掃描完成再執行清除動作。");
return;
}
try {
const optData = await apiGetLeafOptions(connInfo.host);
const pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__');
if (pathsToClear.length === 0) return alert("目前沒有任何快取可以清除!");
if (!confirm(`確定要清除該設備共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料,並自動為您重新掃描)`)) return;
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(connInfo.host)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pathsToClear)
});
const result = await response.json();
const statusEl = document.getElementById('scan-status');
if (statusEl) {
statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`;
statusEl.style.color = "#27ae60";
}
// 清除完畢後,自動呼叫掃描
await scanMissingOptions();
} catch (error) {
console.error("清除快取失敗:", error);
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");
}
}
// ============================================================================
// 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
// ============================================================================
@ -1307,7 +1356,14 @@ async function loadRealConfigForFilters() {
if (finalTreeData) {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
// 🌟 統一渲染作法:先顯示 UI 提示,讓出主執行緒
container.innerHTML = `<div style="color: #f39c12; font-weight: bold; padding: 15px;">⏳ 正在極速建構過濾器樹狀圖,請稍候...</div>`;
document.body.style.cursor = 'wait';
setTimeout(() => {
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
document.body.style.cursor = 'default';
}, 20);
} else {
throw new Error("未收到完整的配置資料");
}
@ -1316,7 +1372,7 @@ async function loadRealConfigForFilters() {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
if (progressInterval) clearInterval(progressInterval);
loadingMsg.style.display = 'none';
}
}
@ -1904,22 +1960,19 @@ document.addEventListener("DOMContentLoaded", checkScanButtonState);
// ==========================================
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
// ==========================================
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
const ADMIN_BTN_IDS = ['btn-scan-options', 'btn-clear-options'];
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
function unlockAdminFeatures() {
// 1. 解鎖全域快取掃描按鈕
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.style.display = 'inline-block';
});
// 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
btn.style.display = 'inline-block';
});
// 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
document.querySelectorAll('.admin-only-action').forEach(btn => {
btn.style.display = 'inline-block';
});
@ -1933,9 +1986,7 @@ document.addEventListener('DOMContentLoaded', () => {
unlockAdminFeatures();
}
// 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門
// 🌟 3. 連點標題觸發機制
// 🌟 2. 連點標題觸發機制
let clickCount = 0;
let clickTimer = null;
if (appTitle) {
@ -1953,7 +2004,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// 🌟 4. 綁定密碼框的 Enter 快捷鍵
// 🌟 3. 綁定密碼框的 Enter 快捷鍵
const pwInput = document.getElementById('godModePassword');
if (pwInput) {
pwInput.addEventListener('keypress', function(e) {
@ -2023,7 +2074,7 @@ function triggerShake(element) {
element.focus();
}
// 統一控制 4 顆按鈕的狀態
// 統一控制 2 顆按鈕的狀態
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
@ -2031,19 +2082,17 @@ function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
btn.disabled = isBusy;
if (isBusy) {
btn.classList.add('is-busy');
if (id.includes('scan')) btn.innerText = text;
if (id === 'btn-scan-options') btn.innerText = text;
} else {
btn.classList.remove('is-busy');
// 恢復原本的文字
if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失";
if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失";
if (id === 'btn-scan-options') btn.innerText = "🚀 掃描缺失選項";
}
}
});
}
function checkScanButtonState() {
const btn = document.getElementById('btn-scan-visible');
const btn = document.getElementById('btn-scan-options');
if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil');
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
@ -2072,7 +2121,6 @@ function enableGlobalScanButton() {
}
}
// ============================================================================
// 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫)
// ============================================================================
@ -2105,10 +2153,8 @@ window.loadMacDomainList = loadMacDomainList;
window.openSystemSettings = openSystemSettings;
window.loadRealConfigForFilters = loadRealConfigForFilters;
window.saveSystemSettings = saveSystemSettings;
window.scanVisibleMissingOptions = scanVisibleMissingOptions;
window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache;
window.scanMissingOptions = scanMissingOptions;
window.clearOptionsCache = clearOptionsCache;
window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode;

View File

@ -86,18 +86,14 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
// ==========================================
export async function startEditFolder(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
activeEditSessions.add(elementId);
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
// 立即讓按鈕呈現讀取中,防止使用者焦慮連點
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
@ -107,13 +103,11 @@ export async function startEditFolder(path, elementId) {
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去
apiReleaseLock(path, SESSION_USER_ID, host);
}
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
@ -121,7 +115,7 @@ export async function startEditFolder(path, elementId) {
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️";
}
return; // 立即中斷,絕不觸碰 UI
return;
}
if (result.status === 409) {
@ -139,18 +133,15 @@ export async function startEditFolder(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
// 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
// 🌟 確保不在冷卻名單中 (防止手速過快)
if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:連同所有子節點一起秒級鎖定
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none';
@ -174,27 +165,61 @@ export async function startEditFolder(path, elementId) {
const detailsEl = document.getElementById(`details-${elementId}`);
if (detailsEl) detailsEl.open = true;
const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) {
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
return;
// ==========================================
// 🌟 完美修復:在抓取葉子節點前,強制將該資料夾底下的所有 HTML 預先渲染出來
// 這樣 querySelectorAll 就能一次抓到所有深層的欄位!
// ==========================================
if (typeof window.forceRenderFolderHTML === 'function') {
window.forceRenderFolderHTML(elementId);
}
const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) return;
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {};
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
try { optData = await apiGetLeafOptions(host); } catch (e) {}
// 🚨 防護 3如果等待 API 期間使用者按了取消,立刻中斷後續渲染
if (!activeEditSessions.has(elementId)) return;
const pathsToSync = [];
const containersArray = Array.from(leafContainers);
// 🌟 第一階段:盤點缺少的快取
containersArray.forEach(container => {
const rawPath = container.getAttribute('data-path');
const origVal = container.getAttribute('data-original');
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (!optData[cacheKey]) pathsToSync.push(cacheKey);
});
// 🌟 第二階段:如果有缺,觸發同步並「等待」
if (pathsToSync.length > 0) {
// 先讓所有欄位顯示載入中
containersArray.forEach(c => c.innerHTML = `<span style="font-size: 12px; color: #f39c12; font-weight: bold;">⏳ 載入選項中...</span>`);
apiSyncLeafOptions(host, pathsToSync);
for (let i = 0; i < 10; i++) {
if (!activeEditSessions.has(elementId)) return;
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host);
// 檢查是否至少抓到一部分了 (只要有 hint 或 options 就當作抓到了)
const isMakingProgress = pathsToSync.some(p => optData[p] && (optData[p].hint || (optData[p].options && optData[p].options.length > 0)));
if (isMakingProgress && i > 2) break; // 給它至少 3 秒,有進度就放行,避免死等
}
}
if (!activeEditSessions.has(elementId)) return;
// 🌟 第三階段:正式渲染輸入框與 ❓
const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
// 🚨 防護 4如果分批渲染期間使用者按了取消立刻中斷
if (!activeEditSessions.has(elementId)) return;
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
chunk.forEach(container => {
@ -206,8 +231,6 @@ export async function startEditFolder(path, elementId) {
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey];
if (!leafCache) pathsToSync.push(cacheKey);
let inputHtml = "";
let hintAttr = "";
let hintIcon = "";
@ -237,8 +260,6 @@ export async function startEditFolder(path, elementId) {
await new Promise(resolve => setTimeout(resolve, 0));
}
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
}
} catch (error) {
activeEditSessions.delete(elementId);
@ -251,7 +272,6 @@ export async function startEditFolder(path, elementId) {
}
export async function startEditLeaf(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId);
@ -260,8 +280,6 @@ export async function startEditLeaf(path, elementId) {
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
@ -271,10 +289,8 @@ export async function startEditLeaf(path, elementId) {
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host);
@ -328,32 +344,30 @@ export async function startEditLeaf(path, elementId) {
const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal);
// 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22)
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
try {
let optData = await apiGetLeafOptions(host, currentMode);
let optData = await apiGetLeafOptions(host);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey];
if (!leafCache) {
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
apiSyncLeafOptions(host, [cacheKey]);
let found = false;
for (let i = 0; i < 10; i++) {
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
if (!activeEditSessions.has(elementId)) return;
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host, currentMode);
optData = await apiGetLeafOptions(host);
leafCache = optData[cacheKey];
// 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
found = true; break;
}
}
}
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
if (!activeEditSessions.has(elementId)) return;
let inputHtml = "";
let hintAttr = "";
@ -381,7 +395,7 @@ export async function startEditLeaf(path, elementId) {
}
container.innerHTML = inputHtml;
} catch (e) {
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
if (!activeEditSessions.has(elementId)) return;
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
}
}
@ -891,7 +905,7 @@ async function executeGeneratedCLI(script) {
logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, currentMode);
});
// 🌟 修正 3: 再觸發後端同步任務
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);

View File

@ -287,3 +287,18 @@ button:hover { background-color: #3498db; }
border-color: #e74c3c !important; /* 震動時外框變紅 */
background-color: #fdedec !important;
}
/* =========================================
🌟 效能優化大型 DOM 樹渲染隔離
========================================= */
.tree-folder-content {
/* 讓不在畫面內的子節點跳過渲染計算,極大提升效能 */
content-visibility: auto;
/* 給予一個預設高度,避免捲軸在捲動時瘋狂跳動 */
contain-intrinsic-size: 0 24px;
}
.filter-children-container {
content-visibility: auto;
contain-intrinsic-size: 0 24px;
}

View File

@ -31,6 +31,20 @@ function colorizeTerminalStream(text) {
// 4. 匯出 (export) 所有需要被外部呼叫的函數
export function initTerminal() {
const container = document.getElementById('terminal-container');
// ==========================================
// 🛡️ DOM 防火牆:攔截殘缺的假鍵盤事件
// ==========================================
container.addEventListener('keydown', (e) => {
// 如果這個事件物件沒有 getModifierState 函數 (代表它是外掛產生的假事件)
if (typeof e.getModifierState !== 'function') {
// 🛑 停止事件傳遞!不讓它流進 Xterm.js 裡
e.stopPropagation();
}
}, true); // 🌟 關鍵:傳入 true 啟用「捕獲階段 (Capture Phase)」,確保我們比 Xterm.js 更早拿到事件
// ==========================================
term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 });
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
@ -74,11 +88,17 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsPass').disabled = true;
term.focus();
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
// 🌟 原有:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
const queryTaskSelect = document.getElementById('queryTask');
if (queryTaskSelect) {
queryTaskSelect.dispatchEvent(new Event('change'));
}
// 🌟 新增:連線成功後,強制觸發配置任務切換,藉此啟動「正確 IP」的 SSE 監聽
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
};
ws.onmessage = (event) => {
@ -102,6 +122,12 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsPass').disabled = false;
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
// 🌟 新增斷線時強制觸發配置任務切換藉此「關閉」SSE 監聽
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
if (resetCallback) resetCallback();
};
}

View File

@ -29,12 +29,19 @@ window.lazyLoadFolder = function(elementId, isFilter = false) {
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'; // 標記為已載入
// 🌟 統一渲染作法:單點展開也套用非同步渲染保護
contentDiv.innerHTML = `<span style="color: #f39c12; font-size: 13px; font-weight: bold; margin-left: 5px;">⏳ 載入中...</span>`;
document.body.style.cursor = 'wait';
setTimeout(() => {
if (isFilter) {
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
} else {
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
}
detailsEl.dataset.loaded = 'true'; // 標記為已載入
document.body.style.cursor = 'default';
}, 10);
}
};
@ -43,42 +50,87 @@ window.lazyLoadFolder = function(elementId, isFilter = false) {
// ==========================================
export function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
// 如果還沒載入,先強制載入它
if (!details.dataset.loaded) {
const isFilter = details.id.includes('filter-');
window.lazyLoadFolder(elementId, isFilter);
}
details.open = true;
// 找出剛剛渲染出來的第一層子資料夾,遞迴展開
const contentDiv = details.querySelector(':scope > div');
if (contentDiv) {
const childDetails = contentDiv.querySelectorAll(':scope > details');
childDetails.forEach(d => {
const subId = d.id.replace('details-', '');
expandAll(subId); // 遞迴展開
});
}
if (!details) return;
const isFilter = details.id.includes('filter-');
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
if (!cache) return;
// 🌟 讓滑鼠變成讀取狀態,給予使用者即時回饋
document.body.style.cursor = 'wait';
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
if (contentDiv) {
contentDiv.innerHTML = `<span style="color: #f39c12; font-weight: bold; font-size: 13px;">⏳ 正在極速展開中,請稍候...</span>`;
}
// 🌟 使用 setTimeout 讓出一個 Frame讓瀏覽器能渲染出「讀取中」的文字與滑鼠狀態
setTimeout(() => {
const currentPath = details.dataset.path;
const isCommandGroup = details.dataset.isGroup === 'true';
const mode = details.dataset.mode;
// 🚀 核心魔法:在記憶體中一次性遞迴生成所有子節點的 HTML 字串 (傳入 forceExpand = true)
let fullHtml = '';
if (isFilter) {
fullHtml = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode, true);
} else {
fullHtml = buildTree(cache, currentPath, isCommandGroup, mode, true);
}
// 🚀 終極效能:只執行 1 次 DOM 寫入!
if (contentDiv) contentDiv.innerHTML = fullHtml;
details.dataset.loaded = 'true';
details.open = true;
const iconClosed = details.querySelector('.icon-closed');
const iconOpen = details.querySelector('.icon-open');
if (iconClosed) iconClosed.style.display = 'none';
if (iconOpen) iconOpen.style.display = 'inline-block';
document.body.style.cursor = 'default';
}, 20);
}
export function collapseAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = false);
// 🌟 效能優化:只尋找目前是 open 狀態的 details 進行關閉,減少 DOM 操作
const openSubDetails = details.querySelectorAll('details[open]');
openSubDetails.forEach(d => {
d.open = false;
const iconClosed = d.querySelector('.icon-closed');
const iconOpen = d.querySelector('.icon-open');
if (iconClosed) iconClosed.style.display = 'inline-block';
if (iconOpen) iconOpen.style.display = 'none';
});
details.open = false;
const mainIconClosed = details.querySelector('.icon-closed');
const mainIconOpen = details.querySelector('.icon-open');
if (mainIconClosed) mainIconClosed.style.display = 'inline-block';
if (mainIconOpen) mainIconOpen.style.display = 'none';
}
}
// ==========================================
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
// 🌟 修改函數宣告,加入 renderAll 參數
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false, renderAll = false) {
const htmlParts = [];
if (!node || typeof node !== 'object') return htmlParts.join('');
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key;
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
@ -89,20 +141,11 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
const elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
// 🌟 將資料存入快取,供延遲渲染使用
folderDataCache[elementId] = value;
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
@ -118,7 +161,6 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span>
`;
// 🌟 延遲渲染防護:取得當前設備 IP並檢查此節點是否已被鎖定 (跨設備隔離)
const currentHost = document.getElementById('cmtsHost')?.value.trim();
let isLocked = false;
let lockTitle = "鎖定並編輯此群組";
@ -128,37 +170,37 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost];
// 1. 檢查自己是否被鎖定
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true;
lockText = "🔒";
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
: `🔒 您正在另一個視圖編輯此項目`;
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
}
} else {
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true;
lockText = "🔒";
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
break;
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
}
}
}
}
// 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML
const isOpenAttr = forceExpand ? 'open' : '';
const isLoadedAttr = (forceExpand || renderAll) ? 'true' : 'false';
const displayClosed = forceExpand ? 'none' : 'inline-block';
const displayOpen = forceExpand ? 'inline-block' : 'none';
let innerContent = '<!-- 🌟 延遲渲染 -->';
if (forceExpand || renderAll) {
// 🌟 遞迴呼叫時,把 renderAll 傳遞下去
innerContent = buildTree(value, currentPath, isCommandGroup, mode, forceExpand, renderAll);
}
htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
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}"
@ -167,8 +209,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
<span class="icon-open" style="display: none;">${iconOpen}</span>
<span class="icon-closed" style="display: ${displayClosed};">${iconClosed}</span>
<span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
</span>
<b style="color: #2c3e50;">${key}</b>${folderSuffix}
${expandCollapseBtns}
@ -194,7 +236,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</div>
</summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
<!-- 🌟 延遲渲染這裡一開始是空的展開時才會填入 -->
${innerContent}
</div>
</details>
`);
@ -209,13 +251,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
}
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
// 🌟 延遲渲染防護:取得當前設備 IP並檢查此節點是否已被鎖定 (跨設備隔離)
const currentHost = document.getElementById('cmtsHost')?.value.trim();
let isLocked = false;
let lockTitle = "鎖定並編輯此項目";
@ -225,29 +263,17 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost];
// 1. 檢查自己是否被鎖定
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true;
lockText = "🔒";
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
: `🔒 您正在另一個視圖編輯此項目`;
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
}
} else {
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true;
lockText = "🔒";
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
break;
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
}
}
}
@ -318,12 +344,21 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
// ==========================================
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
// ==========================================
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
// 🌟 新增 forceExpand 參數,預設為 false
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running', forceExpand = false) {
if (typeof data !== 'object' || data === null) return '';
const htmlParts = [];
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
for (const key in data) {
const value = data[key];
const currentPath = parentPath ? `${parentPath}::${key}` : key;
@ -331,12 +366,6 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;
@ -346,16 +375,12 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
// 🌟 存入過濾器專用快取
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
@ -371,23 +396,33 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
</span>
`;
// 🌟 加入 lazyLoadFolder 觸發
// 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML
const isOpenAttr = forceExpand ? 'open' : '';
const isLoadedAttr = forceExpand ? 'true' : 'false';
const displayClosed = forceExpand ? 'none' : 'inline-block';
const displayOpen = forceExpand ? 'inline-block' : 'none';
let innerContent = '<!-- 🌟 延遲渲染 -->';
if (forceExpand) {
innerContent = buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup, mode, true);
}
htmlParts.push(`
<details id="details-${elementId}" class="tree-folder"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
${checkboxHtml}
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
<span class="icon-open" style="display: none;">${iconOpen}</span>
<span class="icon-closed" style="display: ${displayClosed};">${iconClosed}</span>
<span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
</span>
<span>${key}${folderSuffix}</span>
${expandCollapseBtns}
</summary>
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
<!-- 🌟 延遲渲染 -->
${innerContent}
</div>
</details>
`);
@ -430,4 +465,23 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
}
htmlParts.push('</div>');
return htmlParts.join('');
}
}
// ==========================================
// 🌟 輔助函數:強制預先渲染資料夾 HTML (供編輯模式使用)
// ==========================================
window.forceRenderFolderHTML = function(elementId) {
const detailsEl = document.getElementById(`details-${elementId}`);
const contentDiv = document.getElementById(`content-${elementId}`);
const cache = folderDataCache[elementId];
if (detailsEl && contentDiv && cache && detailsEl.dataset.loaded !== 'true') {
const currentPath = detailsEl.dataset.path;
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
const mode = detailsEl.dataset.mode;
// 重新渲染,傳入 renderAll = true (生成 HTML 但不展開)
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode, false, true);
detailsEl.dataset.loaded = 'true';
}
};