Compare commits

...

9 Commits
v2.4.3 ... main

Author SHA1 Message Date
swpa 4198d17e42 chore: 暫停開發,準備啟動自動化測試專案 2026-06-17 17:10:18 +08:00
swpa 10d53db467 fix: 深度代碼審查與並發加固 (Deep Code Review & Concurrency Hardening) 2026-06-17 16:59:17 +08:00
swpa 69447981bc feat: implement WebSocket Log Streamer with UI toggle switch 2026-06-15 18:04:20 +08:00
swpa d4721b163f feat: 手動備份 + 釘選防護 + 背景滾動淘汰 2026-06-15 14:45:54 +08:00
swpa 050936668d chore: 優化「日誌的信噪比 (Signal-to-Noise Ratio)」 2026-06-11 10:57:57 +08:00
swpa 387d1d600a fix: 修復 Single Page Application (SPA) 「狀態殘留 (State Leakage)」問題
fix: 修復「延遲渲染 (Lazy Rendering)」與「編輯模式」之間的一個極度邊角的情境 (Edge Case) 衝突。
2026-06-11 10:32:21 +08:00
swpa 8f026aac9e fix: 處理系統代理連線 (Proxy Connection) 狀態落差問題 - 導入「自訂斷線碼 (Custom Close Code)」。 2026-06-10 15:13:21 +08:00
swpa 7a49ef9833 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` 重新建表。
2026-06-10 14:37:16 +08:00
swpa 061ee3435a refactor: God Mode 安全防護 2026-06-08 17:01:55 +08:00
27 changed files with 4058 additions and 3829 deletions

View File

@ -1,51 +1,71 @@
# Harmonic CMTS Manager - AI 開發守則與架構白皮書 # Harmonic CMTS Manager - AI Architect Guidelines & System Blueprint
## 1. 專案架構概覽與技術棧 > **⚠️ AI 角色與絕對約束 (AI Persona & Absolute Directives)**
- **定位**: 專為有線電視網路終端設備 (CMTS) 設計的企業級 Web 管理系統 > 你現在是一位資深的系統架構師與全端工程師
- **後端**: Python 3.10+, FastAPI (Async-first), AsyncSSH, Netmiko, asyncpg. > 1. **唯一真理**:絕對禁止自行幻想任何不存在的模組、變數或第三方套件。所有修改必須基於現有架構。
- **前端**: 原生 Vanilla JS (ES Modules), HTML5, CSS3, Xterm.js. > 2. **語言規範**:註解、對話與 Git Commit 一律使用**繁體中文 (zh-TW)**。
- **資料庫**: PostgreSQL (主要), JSON File Cache (高可用性降級備援). > 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) ## 🏗️ 1. 系統總體架構 (System Architecture)
- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `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` 的生命週期防呆機制。
## 3. 🚨 AI 開發絕對約束 (Directives for AI) 本系統為專為 Harmonic CableOS 設計的企業級 Web 管理介面,採用前後端分離架構,並透過 WebSocket 與 SSE 實現即時雙向通訊。
### ⚙️ 系統與環境規範 * **後端 (Backend)**: Python 3.10+, FastAPI (Async-first), `asyncssh` (核心連線引擎), `asyncpg` (資料庫連線池)。
1. **套件管理**: 若需安裝新套件,請提醒我手動在 `cmts_api_env` 中安裝,並更新 `requirements.txt` * **前端 (Frontend)**: Vanilla JS (ES Modules), HTML5, CSS3 (CSS Variables, Content-Visibility), Xterm.js, Chart.js。
2. **資料讀取限制**: 請勿隨意讀取 `*_cache.json` 檔案的內容,若需了解資料結構,請參考 `cmts_scraper.py` 中的定義。 * **資料庫 (Database)**: PostgreSQL (`cmts_nms` 資料庫)。
3. **狀態同步**: 完成重大修改或一個 Phase 後,必須主動更新 `PROJECT_STATE.md` 記錄最新進度與待辦事項。
### 💻 程式碼風格與後端規範 ---
4. **語言與風格**: 註解與對話請一律使用**繁體中文**。Python 程式碼請遵循 PEP8 規範,並加上適當的 Type Hints (型別提示)。
5. **Async-First (非同步絕對優先)**: 嚴禁使用阻塞的同步 I/O。同步函數必須封裝進 `await asyncio.to_thread()`
6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。
9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`
10. **精準 Prompt 偵測**: 透過 SSH 讀取設備回傳時,嚴禁盲目依賴 `timeout`。必須在 `read_until_quiet` 中傳入 `prompt_pattern` (如 `r"\(config\)#"``r"(?:#|>)"`) 進行正規表達式匹配,以最高速釋放資源。
### 🎨 前端規範 ## 🧩 2. 模組化切割與檔案關聯 (Module Map)
11. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`
12. **UI 狀態安全還原**: 動態修改 DOM 的 CSS 屬性(如 `flex`, `width`)後,若需恢復原狀,嚴禁寫死預設值,必須將屬性設為空字串 `''`,讓瀏覽器自然退回 HTML 定義的樣式,避免改 A 壞 B。 ### 🟢 後端核心模組 (Backend Core)
13. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時必須透過 SSE 或 ReadableStream 即時回報進度。 * `main.py`: 系統進入點。負責掛載靜態檔案、初始化 DB Pool (`lifespan`),並將所有 API 路由統一掛載於 `/api/v1` 前綴之下。
14. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy保留策略/定期清理機制),不可只寫入不刪除。 * `database.py`: PostgreSQL 非同步連線池管理。嚴格區分 `running``full``config_type` 雙軌隔離設計。
15. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。 * `cmts_scraper.py`: 底層 SSH 爬蟲引擎。負責發送 `?` 探測設備選項、解析終端機分頁 (`--More--`),並清理 ANSI 控制碼。
16. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()``?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。 * `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=

7
.gitignore vendored
View File

@ -4,3 +4,10 @@ cmts_api_env/
*_cache.json *_cache.json
filters_*.json filters_*.json
*.bk *.bk
# Environments
.env
.env.*
!.env.example
target_code.txt
all_code.txt

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -6,56 +6,51 @@ import json
import os import os
import time import time
import database import database
from shared import USE_DB
from logger import get_logger from logger import get_logger
logger = get_logger("app.scraper") logger = get_logger("app.scraper")
def parse_question_mark_output(output: str) -> dict: def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位""" """解析 '?' 回傳內容,無差別掃描支援所有標題排列組合"""
hint_lines = [] hint_lines = []
options = [] options = []
current_value = None current_value = None
format_desc = None format_desc = None
is_format_only = False is_format_only = False
state = "INIT"
has_description = False
has_bracket_value = False has_bracket_value = False
# 🌟 Case-5 特殊處理dhcp-relay 混合型欄位 (選項 + IP輸入)
# 直接在開頭掃描完整輸出,若包含 dhcp-relay強制轉為純文字輸入框
if "dhcp-relay" in output.lower(): if "dhcp-relay" in output.lower():
is_format_only = True is_format_only = True
format_desc = "IP address or options" format_desc = "IP address or options"
for line in output.splitlines(): for line in output.splitlines():
line = line.strip() 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 continue
if line.startswith('Description:'): # 攔截 Echo 的問號指令 (例如 "logging buffered ?")
state = "DESC" if line.endswith('?'):
has_description = True
hint_lines.append(line)
continue continue
if line.startswith('Possible completions:'): # 攔截終端機 Prompt (例如 "admin@SERCOMM-COS-02(config)# ..." 或 "admin@SERCOMM-COS-02>")
state = "COMPLETIONS" if re.search(r"[a-zA-Z0-9_.@-]+\(.*\)#", line) or re.search(r"^[a-zA-Z0-9_.@-]+>", line):
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint continue
# ==========================================
# 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看)
hint_lines.append(line) hint_lines.append(line)
# 3. 略過純標題行,不進行選項解析
if line.startswith('Description:') or line.startswith('Possible completions:'):
continue continue
if state == "DESC": # 4. 解析格式與選項 (無差別掃描每一行)
hint_lines.append(line)
elif state == "COMPLETIONS": # 格式 A: <格式說明>[當前值] (例如: <string, min: 0 chars, max: 128 chars>[Cold Start])
# Case 1~4: 無 Description 時,將後續選項說明也納入提示
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
# 規則 1: <格式說明>[當前值]
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line) match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
if match_format: if match_format:
is_format_only = True is_format_only = True
@ -64,55 +59,55 @@ def parse_question_mark_output(output: str) -> dict:
current_value = match_format.group(2).strip() current_value = match_format.group(2).strip()
continue continue
# 規則 1.5: 型態, 最小值 .. 最大值 # 格式 B: 型態, 最小值 .. 最大值 (例如: unsignedInt, 1 .. 3000)
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line) match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
if match_range: if match_range:
is_format_only = True is_format_only = True
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})" format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
continue continue
# 規則 1.6: IP address 等純文字關鍵字 # 格式 C: 純文字關鍵字
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE) match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
if match_keyword: if match_keyword:
is_format_only = True is_format_only = True
format_desc = match_keyword.group(1) format_desc = match_keyword.group(1)
continue continue
# 規則 2: 選項列表處理 # 格式 D: 選項列表 [現值] 選項1 選項2
match_current = re.search(r"^\[([^\]]+)\]", line) match_current = re.search(r"^\[([^\]]+)\]", line)
if match_current: if match_current:
has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單 has_bracket_value = True
if not current_value: if not current_value:
current_value = match_current.group(1).strip() current_value = match_current.group(1).strip()
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip() clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
else: else:
clean_line = line.strip() clean_line = line.strip()
# 🌟 Case-6 防呆:過濾垂直列表的說明文字 # 分離選項與說明 (完美支援水平列表,如 severity size-mb)
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線 parts = re.split(r'\s{2,}', clean_line)
if re.search(r"\s{3,}", clean_line): valid_options = []
clean_line = re.split(r"\s{3,}", clean_line)[0]
parts = clean_line.split()
for p in parts: for p in parts:
if p and p not in ["|", ".."]: 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) options.append(p)
# 🌟 Case 2, 3, 4: 子命令防呆機制 # 子命令防呆:如果抓到一堆單字,但沒有 [現值],且不是已知格式,很可能是子命令列表
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字 if not has_bracket_value and not is_format_only and options:
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
is_format_only = True is_format_only = True
options = [] options = []
# 確保現值一定包含在選項中(如果它是一般的下拉選單)
if current_value and options and current_value not in options: if current_value and options and current_value not in options:
options.append(current_value) options.append(current_value)
hint_text = "\n".join(hint_lines).strip()
return { return {
"hint": hint_text, "hint": "\n".join(hint_lines).strip(),
# 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
"options": list(dict.fromkeys(options)) if not is_format_only else [], "options": list(dict.fromkeys(options)) if not is_format_only else [],
"current_value": current_value, "current_value": current_value,
"format_desc": format_desc, "format_desc": format_desc,
@ -120,9 +115,8 @@ def parse_question_mark_output(output: str) -> dict:
} }
def parse_device_response(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) match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
if match: if match:
enum_content = match.group(1) enum_content = match.group(1)
desc_content = match.group(2) desc_content = match.group(2)
@ -144,11 +138,9 @@ def parse_device_response(output: str) -> dict:
"current_value": current_value, "current_value": current_value,
"format_desc": desc_content.strip() "format_desc": desc_content.strip()
} }
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.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):
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
try: try:
total_paths = len(leaf_paths) total_paths = len(leaf_paths)
processed_count = 0 processed_count = 0
@ -156,14 +148,19 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
BATCH_SIZE = 30 BATCH_SIZE = 30
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)] batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本 cmts_version = "unknown"
for batch_idx, batch in enumerate(batches): for batch_idx, batch in enumerate(batches):
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
try: try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放 # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: conn = await asyncio.wait_for(
asyncssh.connect(host, username=username, password=password, known_hosts=None),
timeout=10.0
)
# 再進入 async with 確保資源會被自動關閉
async with conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None): async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
@ -176,42 +173,44 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output): if prompt_pattern and re.search(prompt_pattern, output):
break break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
# 🌟 新增:在第一批次連線時,先抓取設備版本 # 🌟 關鍵修復 1等待登入歡迎詞 (MOTD) 結束,確保設備準備好接收指令
await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
if cmts_version == "unknown": if cmts_version == "unknown":
process.stdin.write("show version\n") process.stdin.write("show version | nomore\n")
await process.stdin.drain() await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)") 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: if match:
cmts_version = match.group(1) cmts_version = match.group(1)
else:
cmts_version = "parse_failed" # 🌟 避免正則失敗導致每批次都重查
# 🌟 關鍵修復 2確保成功進入 config 模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config.*\)#")
for path in batch: for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?" command_to_send = f"{path} ?"
process.stdin.write(command_to_send) process.stdin.write(command_to_send)
await process.stdin.drain() 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) q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5) # 🌟 頂級優雅解法:使用 Ctrl+U (\x15) 瞬間清空整行輸入緩衝區
process.stdin.write(backspaces) process.stdin.write("\x15")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=0.5) await read_until_quiet(timeout=0.2)
parsed_data = { parsed_data = {
"hint": q_data["hint"], "hint": q_data["hint"],
@ -220,7 +219,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
"current_value": q_data.get("current_value") "current_value": q_data.get("current_value")
} }
# --- 步驟 2判斷是否需要發送 Enter ---
if q_data.get("is_format_only"): if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number" parsed_data["type"] = "string_or_number"
@ -228,7 +226,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
process.stdin.write(f"{path}\n") process.stdin.write(f"{path}\n")
await process.stdin.drain() 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) enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"] parsed_data["type"] = enter_data["type"]
@ -236,25 +234,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
parsed_data["current_value"] = enter_data["current_value"] parsed_data["current_value"] = enter_data["current_value"]
if enter_data["type"] != "unknown": if enter_data["type"] != "unknown":
process.stdin.write("\x03") # 如果進入了互動式輸入 (例如 prompt 變成 (val): ),按 Enter 接受預設值並退出
await process.stdin.drain()
else:
logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n") process.stdin.write("\n")
await process.stdin.drain() await process.stdin.drain()
else:
# 如果只是印出錯誤,我們不需要做什麼
pass
await read_until_quiet(timeout=0.5) await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data result_data[path] = parsed_data
processed_count += 1 processed_count += 1
event_data = { yield json.dumps({
"event": "progress", "event": "progress",
"current": processed_count, "current": processed_count,
"total": total_paths, "total": total_paths,
"current_path": path "current_path": path
} }) + "\n"
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
@ -264,75 +261,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n" yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue continue
# --- 步驟 3寫入快取 (DB or JSON) ---
try: try:
db_success = False
current_ts = int(time.time()) current_ts = int(time.time())
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
# 1. 嘗試寫入資料庫 # 寫入資料庫
if USE_DB: if cmts_version not in ["unknown", "parse_failed"]:
try: await database.upsert_device_status(host, {"cmts_version": cmts_version, "last_scanned": formatted_time})
# 寫入 metadata
if cmts_version != "unknown":
await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
else: else:
await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time}) await database.upsert_device_status(host, {"last_scanned": formatted_time})
# 寫入 options
db_write_count = 0
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
if success:
db_write_count += 1
if db_write_count > 0:
db_success = True
logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
else:
logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
except Exception as e:
logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
# 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON
if not db_success:
# 🌟 動態決定快取檔名 (加入 IP 隔離)
safe_host = host.replace(".", "_")
cache_file = f"{safe_host}_{config_type}_cache.json"
def read_json_from_file(filepath):
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
return {}
cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
# 🌟 新增:寫入 Metadata (版本與掃描時間)
if "__metadata__" not in cache_data:
cache_data["__metadata__"] = {}
if cmts_version != "unknown":
cache_data["__metadata__"]["cmts_version"] = cmts_version
cache_data["__metadata__"]["last_scanned"] = formatted_time
for p in batch: for p in batch:
if p in result_data: if p in result_data:
result_data[p]["updated_at"] = current_ts result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p] await database.upsert_leaf_option(host, 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: except Exception as e:
logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") logger.error(f"⚠️ 資料庫寫入失敗: {e}")
if batch_idx < len(batches) - 1: if batch_idx < len(batches) - 1:
await asyncio.sleep(2) await asyncio.sleep(2)
@ -343,10 +289,14 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n" 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: async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
"""連線至設備並抓取完整的純文字設定檔"""
try: try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期 # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: conn = await asyncio.wait_for(
asyncssh.connect(host, username=username, password=password, known_hosts=None),
timeout=10.0
)
# 再進入 async with 確保資源會被自動關閉
async with conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=2.0, prompt_pattern: str = None): async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
@ -356,65 +306,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) chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break if not chunk: break
output += chunk output += chunk
# 自動翻頁
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain() await process.stdin.drain()
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, output): if prompt_pattern and re.search(prompt_pattern, output):
break break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return output return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
if config_type == "full": if config_type == "full":
# 1. 先進入 config 模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5, 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" cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n") process.stdin.write(f"{cmd}\n")
await process.stdin.drain() await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#") raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
# 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
else: else:
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore" cmd = "show running-config | nomore"
process.stdin.write(f"{cmd}\n") process.stdin.write(f"{cmd}\n")
await process.stdin.drain() await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0) raw_output = await read_until_quiet(timeout=3.0)
# 最終退出設備
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output) 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) 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) 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 = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines() lines = cleaned_output.splitlines()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [ clean_lines = [
line for line in lines line for line in lines
if not line.strip().startswith(cmd) if not line.strip().startswith(cmd)
@ -425,14 +356,5 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
except Exception as e: except Exception as e:
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}") raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
finally:
if conn:
conn.close()
def parse_config_to_tree(raw_cli: str) -> dict: 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 asyncpg
import json import json
import uuid import uuid
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from logger import get_logger from logger import get_logger
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
# ========================================== # ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版) # 💡 PostgreSQL 連線與操作 (高可用性版)
# ========================================== # ==========================================
logger = get_logger("app.database") logger = get_logger("app.database")
# DB 設定 (從您的 init_db.py 中提取) # 🌟 2. 同步改用 os.getenv 讀取環境變數
DB_CONFIG = { DB_CONFIG = {
"database": "cmts_nms", "database": os.getenv("DB_NAME", "cmts_nms"),
"user": "swpa", "user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": "swpa4920", "password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": "127.0.0.1", "host": os.getenv("DB_HOST", "127.0.0.1"),
"port": "5432" "port": os.getenv("DB_PORT", "5432")
} }
_pool: Optional[asyncpg.Pool] = None _pool: Optional[asyncpg.Pool] = None
@ -56,140 +61,95 @@ async def get_pool() -> Optional[asyncpg.Pool]:
# CRUD Functions for cmts_options # 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() pool = await get_pool()
if not pool: if not pool: return False
return False
query = """ query = """
INSERT INTO cmts_options (host, config_type, path, data) INSERT INTO cmts_options (host, path, data)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3)
ON CONFLICT (host, config_type, path) ON CONFLICT (host, path)
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP; DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
""" """
try: try:
async with pool.acquire() as conn: 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 return True
except asyncpg.PostgresError as e: except Exception as e:
logger.error(f"❌ DB Error (upsert_leaf_option): {e}") logger.error(f"❌ DB Error (upsert_leaf_option): {e}")
return False 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]]: async def get_all_leaf_options(host: str) -> Optional[Dict[str, Any]]:
"""取得特定 host 與 config_type 的所有選項"""
pool = await get_pool() pool = await get_pool()
if not pool: if not pool: return None
return None query = "SELECT path, data FROM cmts_options WHERE host = $1;"
query = """
SELECT path, data FROM cmts_options
WHERE host = $1 AND config_type = $2;
"""
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
records = await conn.fetch(query, host, config_type) records = await conn.fetch(query, host)
result = {} result = {}
for record in records: 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'] data_val = record['data']
if isinstance(data_val, str): result[record['path']] = json.loads(data_val) if isinstance(data_val, str) else data_val
result[record['path']] = json.loads(data_val)
else:
result[record['path']] = data_val
return result return result
except asyncpg.PostgresError as e: except Exception as e:
logger.error(f"❌ DB Error (get_all_leaf_options): {e}") logger.error(f"❌ DB Error (get_all_leaf_options): {e}")
return None 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() pool = await get_pool()
if not pool or not paths: if not pool or not paths: return -1
return -1 query = "DELETE FROM cmts_options WHERE host = $1 AND path = ANY($2);"
query = """
DELETE FROM cmts_options
WHERE host = $1 AND config_type = $2 AND path = ANY($3);
"""
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
# Execute returns the command tag, e.g., 'DELETE 5' status = await conn.execute(query, host, paths)
status = await conn.execute(query, host, config_type, paths) return int(status.split()[-1])
deleted_count = int(status.split()[-1])
return deleted_count
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (delete_leaf_options): {e}")
return -1
except Exception as e: except Exception as e:
logger.error(f"Unknown Error (delete_leaf_options): {e}") logger.error(f"❌ DB Error (delete_leaf_options): {e}")
return -1 return -1
# ------------------------------------------ # ------------------------------------------
# CRUD Functions for device_status # CRUD Functions for device_status
# ------------------------------------------ # ------------------------------------------
async def upsert_device_status(host: str, config_type: str, metadata: dict) -> bool: async def upsert_device_status(host: str, metadata: dict) -> bool:
"""更新設備的 metadata"""
pool = await get_pool() pool = await get_pool()
if not pool: if not pool: return False
return False cmts_version = metadata.get("cmts_version")
# Extract known fields
cmts_version = metadata.get("cmts_version", "unknown")
last_scanned = metadata.get("last_scanned", None) last_scanned = metadata.get("last_scanned", None)
query = """
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
VALUES ($1, $2, $3, $4)
ON CONFLICT (host, config_type)
DO UPDATE SET
cmts_version = EXCLUDED.cmts_version,
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
"""
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute(query, host, config_type, cmts_version, last_scanned) if cmts_version and cmts_version != "unknown":
query = """
INSERT INTO device_status (host, cmts_version, last_scanned)
VALUES ($1, $2, $3)
ON CONFLICT (host)
DO UPDATE SET cmts_version = EXCLUDED.cmts_version, last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, cmts_version, last_scanned)
else:
query = """
INSERT INTO device_status (host, last_scanned)
VALUES ($1, $2)
ON CONFLICT (host)
DO UPDATE SET last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, last_scanned)
return True return True
except asyncpg.PostgresError as e: except Exception as e:
logger.error(f"❌ DB Error (upsert_device_status): {e}") logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
return False
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]: async def get_device_status(host: str) -> Optional[Dict[str, Any]]:
"""獲取設備 metadata"""
pool = await get_pool() pool = await get_pool()
if not pool: if not pool: return None
return None query = "SELECT cmts_version, last_scanned FROM device_status WHERE host = $1;"
query = """
SELECT cmts_version, last_scanned FROM device_status
WHERE host = $1 AND config_type = $2;
"""
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
record = await conn.fetchrow(query, host, config_type) record = await conn.fetchrow(query, host)
if record: if record:
return { return {"cmts_version": record["cmts_version"], "last_scanned": record["last_scanned"]}
"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 return None
except Exception as e: except Exception as e:
logger.error(f"Unknown Error (get_device_status): {e}") logger.error(f"❌ DB Error (get_device_status): {e}")
return None return None
# ------------------------------------------ # ------------------------------------------
@ -291,7 +251,7 @@ async def insert_config_backup(
return None return None
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]: async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)""" """取得歷史快照列表 (支援 config_type='all' 撈取全部,包含 is_pinned 狀態)"""
pool = await get_pool() pool = await get_pool()
if not pool: if not pool:
return None return None
@ -299,18 +259,18 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
if config_type == "all": if config_type == "all":
# 🟢 SELECT 加入 description # 🌟 SQL 查詢補上 is_pinned
query = """ query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
FROM config_backups FROM config_backups
WHERE host = $1 WHERE host = $1
ORDER BY timestamp DESC; ORDER BY timestamp DESC;
""" """
records = await conn.fetch(query, host) records = await conn.fetch(query, host)
else: else:
# 🟢 SELECT 加入 description # 🌟 SQL 查詢補上 is_pinned
query = """ query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
FROM config_backups FROM config_backups
WHERE host = $1 AND config_type = $2 WHERE host = $1 AND config_type = $2
ORDER BY timestamp DESC; ORDER BY timestamp DESC;
@ -324,8 +284,9 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
"config_type": r["config_type"], "config_type": r["config_type"],
"timestamp": r["timestamp"].isoformat(), "timestamp": r["timestamp"].isoformat(),
"snapshot_name": r["snapshot_name"], "snapshot_name": r["snapshot_name"],
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典 "description": r["description"],
"is_auto": r["is_auto"] "is_auto": r["is_auto"],
"is_pinned": r["is_pinned"] # 🌟 放入回傳字典
} }
for r in records for r in records
] ]
@ -390,3 +351,111 @@ async def delete_config_backup(backup_id: str) -> bool:
except Exception as e: except Exception as e:
logger.error(f"❌ Unknown Error (delete_config_backup): {e}") logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
return False return False
# ============================================================================
# 🧹 備份滾動淘汰機制 (Retention Policy)
# ============================================================================
async def cleanup_old_backups(pool, host: str) -> None:
"""
執行手動備份的滾動淘汰機制 (Retention Policy)
- 每個設備 (host) 保留最新 20 筆未釘選 (is_pinned=FALSE) 的手動備份
- 釘選的備份 (is_pinned=True) 永遠不刪除
"""
if not pool:
logger.warning("⚠️ [Retention] 無法執行備份清理Database Pool 未初始化。")
return
# 使用 PostgreSQL CTE (Common Table Expression) 語法
# 先選出該設備最新 20 筆需要保留的 ID再將其餘未釘選的舊備份一次性刪除
query = """
WITH kept_backups AS (
SELECT id FROM config_backups
WHERE host = $1 AND is_pinned = FALSE
ORDER BY timestamp DESC
LIMIT 20
)
DELETE FROM config_backups
WHERE host = $1 AND is_pinned = FALSE
AND id NOT IN (SELECT id FROM kept_backups)
RETURNING id;
"""
try:
async with pool.acquire() as conn:
deleted_records = await conn.fetch(query, host)
total_deleted = len(deleted_records)
if total_deleted > 0:
logger.info(f"🧹 [Retention Policy] 設備 {host} 清理完畢:已自動淘汰 {total_deleted} 筆過期手動備份。")
else:
logger.debug(f" [Retention Policy] 設備 {host} 備份數量未達 20 筆上限,無需清理。")
except Exception as e:
logger.error(f"❌ [Retention Policy] 清理設備 {host} 過期備份時發生錯誤: {e}", exc_info=True)
# ============================================================================
# 📌 釘選防護與容量指標計算 (Pinning & Metrics)
# ============================================================================
async def toggle_config_backup_pin(backup_id: str) -> Optional[bool]:
"""
切換特定備份的釘選狀態 (True -> False, False -> True)
回傳更新後的 is_pinned 狀態若失敗則回傳 None
"""
pool = await get_pool()
if not pool:
return None
# 使用 PostgreSQL 的 UPDATE ... RETURNING 語法,一步完成切換與讀取,保證原子性
query = """
UPDATE config_backups
SET is_pinned = NOT is_pinned
WHERE id = $1
RETURNING is_pinned;
"""
try:
async with pool.acquire() as conn:
new_state = await conn.fetchval(query, backup_id)
return new_state
except Exception as e:
logger.error(f"❌ DB Error (toggle_config_backup_pin): {e}")
return None
async def get_backup_metrics(pool, host: str) -> dict:
"""
計算特定設備的備份容量指標
- total: 總備份數 (含釘選與未釘選)
- pinned: 已釘選保護的數量
- unpinned: 未釘選的數量 (上限為 20 )
- remaining: 剩餘可用手動備份額度 (20 - unpinned)
"""
if not pool:
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
query = """
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE is_pinned = TRUE) as pinned,
COUNT(*) FILTER (WHERE is_pinned = FALSE) as unpinned
FROM config_backups
WHERE host = $1;
"""
try:
async with pool.acquire() as conn:
r = await conn.fetchrow(query, host)
total = r["total"] or 0
pinned = r["pinned"] or 0
unpinned = r["unpinned"] or 0
remaining = max(0, 20 - unpinned)
return {
"total": total,
"pinned": pinned,
"unpinned": unpinned,
"remaining": remaining
}
except Exception as e:
logger.error(f"❌ DB Error (get_backup_metrics): {e}")
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}

View File

@ -20,9 +20,10 @@
<div class="global-settings"> <div class="global-settings">
<label><strong>🌐 目標設備:</strong></label> <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="text" id="cmtsHost" aria-label="IP 地址" placeholder="IP 地址" style="width: 130px;">
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;"> <input type="text" id="cmtsUser" aria-label="帳號" placeholder="帳號" style="width: 100px;">
<input type="password" id="cmtsPass" aria-label="密碼" placeholder="密碼" style="width: 100px;">
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;"> <div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button> <button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
@ -45,7 +46,7 @@
<div id="cli-tab" class="tab-content active"> <div id="cli-tab" class="tab-content active">
<div class="control-group"> <div class="control-group">
<label><strong>快捷指令:</strong></label> <label><strong>快捷指令:</strong></label>
<select id="fixedCmd"> <select id="fixedCmd" aria-label="選擇快捷指令" >
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option> <option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option> <option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option> <option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
@ -66,7 +67,7 @@
<div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;"> <div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label> <label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
<select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;"> <select id="queryTask" onchange="switchQueryTask()" aria-label="選擇查詢任務" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option> <option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option> <option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option> <option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
@ -78,13 +79,13 @@
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3> <h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
<div class="form-grid"> <div class="form-grid">
<div class="form-row"> <div class="form-row">
<label>目標設備 (Cable Modem MAC)</label> <label for="queryTargetCm">目標設備 (Cable Modem MAC)</label>
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)"> <input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
<datalist id="cm-mac-list"></datalist> <datalist id="cm-mac-list"></datalist>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>查詢動作 (Action)</label> <label>查詢動作 (Action)</label>
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')"> <select id="queryTypeCm" onchange="toggleQueryInputs('Cm')" aria-label="選擇 Cable Modem 查詢動作">
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)"> <optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
<option value="base">基本狀態 (show cable modem)</option> <option value="base">基本狀態 (show cable modem)</option>
<option value="cpe">CPE 資訊 (cpe)</option> <option value="cpe">CPE 資訊 (cpe)</option>
@ -128,13 +129,13 @@
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3> <h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
<div class="form-grid"> <div class="form-grid">
<div class="form-row"> <div class="form-row">
<label>目標設備 (RPD VC:VS)</label> <label for="queryTargetRpd">目標設備 (RPD VC:VS)</label>
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)"> <input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
<datalist id="rpd-vcvs-list"></datalist> <datalist id="rpd-vcvs-list"></datalist>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>查詢動作 (Action)</label> <label>查詢動作 (Action)</label>
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')"> <select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')" aria-label="選擇 RPD 查詢動作">
<option value="rpd_base">基本狀態 (show cable rpd)</option> <option value="rpd_base">基本狀態 (show cable rpd)</option>
<option value="rpd_verbose">詳細資訊 (verbose)</option> <option value="rpd_verbose">詳細資訊 (verbose)</option>
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option> <option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
@ -165,7 +166,7 @@
<!-- 搜尋區塊 --> <!-- 搜尋區塊 -->
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;"> <div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC</label> <label for="diagMacInput" style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC</label>
<input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;"> <input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
<datalist id="diag-cm-mac-list"></datalist> <datalist id="diag-cm-mac-list"></datalist>
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button> <button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
@ -236,7 +237,7 @@
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;"> <div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label> <label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;"> <select id="configTask" aria-label="選擇配置任務" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
<!-- 🌟 拆分為兩個獨立的選項 --> <!-- 🌟 拆分為兩個獨立的選項 -->
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option> <option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option> <option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
@ -250,7 +251,7 @@
<h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3> <h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;"> <div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label> <label for="cfgMacDomain" style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label>
<input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;"> <input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<datalist id="mac-domain-list"></datalist> <datalist id="mac-domain-list"></datalist>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button> <button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
@ -261,39 +262,39 @@
<!-- Common Settings --> <!-- Common Settings -->
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4> <h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
<div class="form-grid"> <div class="form-grid">
<div class="form-row"><label>IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div> <div class="form-row"><label for="g_ip_prov">IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
<div class="form-row"><label>Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div> <div class="form-row"><label for="g_diplexer">Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div> <div class="form-row"><label for="g_bat31">CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div> <div class="form-row"><label for="g_bat30">CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div> <div class="form-row"><label for="g_docsis40">DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div> <div class="form-row"><label for="g_ds_dyn">DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div> <div class="form-row"><label for="g_us_dyn">US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
</div> </div>
<!-- [Basic] DS/US Channel Sets --> <!-- [Basic] DS/US Channel Sets -->
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4> <h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
<div class="form-grid"> <div class="form-grid">
<div class="form-row"><label>Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div> <div class="form-row"><label for="b_admin">Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
<div class="form-row"><label>DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div> <div class="form-row"><label for="b_ds_pri">DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
<div class="form-row"><label>DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div> <div class="form-row"><label for="b_ds_non_pri">DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
<div class="form-row"><label>US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div> <div class="form-row"><label for="b_us_phy">US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
<div class="form-row"><label>DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div> <div class="form-row"><label for="b_ds_ofdm">DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
<div class="form-row"><label>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div> <div class="form-row"><label for="b_us_ofdma">US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
</div> </div>
<!-- [Static] Downstream Bonding Groups --> <!-- [Static] Downstream Bonding Groups -->
<div id="section_group_ds" style="margin-top: 30px;"> <div id="section_group_ds" style="margin-top: 30px;">
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4> <h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;"> <div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
<label>選擇要編輯的 DS Group</label> <label for="select_ds_group">選擇要編輯的 DS Group</label>
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select> <select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
<input type="text" id="input_new_ds_group" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;"> <input type="text" id="input_new_ds_group" aria-label="輸入新 DS Group 名稱" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div class="form-row"><label>Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div> <div class="form-row"><label for="ds_g_admin">Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
<div class="form-row"><label>Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div> <div class="form-row"><label for="ds_g_down">Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
<div class="form-row"><label>OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div> <div class="form-row"><label for="ds_g_ofdm">OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
<div class="form-row"><label>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div> <div class="form-row"><label for="ds_g_fdx">FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
</div> </div>
</div> </div>
@ -301,15 +302,15 @@
<div id="section_group_us" style="margin-top: 30px;"> <div id="section_group_us" style="margin-top: 30px;">
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4> <h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;"> <div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
<label>選擇要編輯的 US Group</label> <label for="select_us_group">選擇要編輯的 US Group</label>
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select> <select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
<input type="text" id="input_new_us_group" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;"> <input type="text" id="input_new_us_group" aria-label="輸入新 US Group 名稱" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div class="form-row"><label>Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div> <div class="form-row"><label for="us_g_admin">Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
<div class="form-row"><label>US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div> <div class="form-row"><label for="us_g_us">US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
<div class="form-row"><label>OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div> <div class="form-row"><label for="us_g_ofdma">OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
<div class="form-row"><label>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div> <div class="form-row"><label for="us_g_fdx">FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
</div> </div>
</div> </div>
@ -338,10 +339,8 @@
</span> </span>
</div> </div>
<div style="display: flex; gap: 10px;"> <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-scan-options" onclick="scanMissingOptions()" 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-clear-options" onclick="clearOptionsCache()" class="btn-modern btn-clear" style="display: none;">🗑️ 清除選項快取</button>
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
</div> </div>
</div> </div>
@ -408,7 +407,7 @@
<!-- 🌟 新增:過濾器模式切換下拉選單 --> <!-- 🌟 新增:過濾器模式切換下拉選單 -->
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;"> <div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
<label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label> <label for="filter-mode-select" style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;"> <select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
<option value="running">Running 配置過濾器</option> <option value="running">Running 配置過濾器</option>
<option value="full">Full 配置過濾器</option> <option value="full">Full 配置過濾器</option>
@ -450,6 +449,21 @@
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span> <span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
</div> </div>
</div> </div>
<!-- 🌟 新增:🎙️ 系統實時日誌面板 (System Live Logs) -->
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<span>🎙️ 系統實時日誌 (System Live Logs)</span>
<div style="display: flex; align-items: center; gap: 15px;">
<span id="log-ws-status" style="font-size: 13px; color: #7f8c8d; font-weight: normal;">狀態:已暫停 ⚪</span>
<button id="btnToggleLog" onclick="toggleLogStream()" class="btn-modern btn-disconnect" style="padding: 4px 12px; font-size: 12px;">🔌 啟動監聽</button>
</div>
</h3>
<!-- 日誌終端機容器 -->
<div id="log-terminal-container" style="width: 100%; height: 300px; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); overflow: hidden; position: relative; padding: 10px; box-sizing: border-box;"></div>
</div>
</div> </div>
</div> </div>
@ -478,13 +492,13 @@
<!-- 表單網格區 --> <!-- 表單網格區 -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;"> <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
<div> <div>
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;"> <label for="snapshotName" style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
快照名稱 <span style="color: #e74c3c;">*</span> 快照名稱 <span style="color: #e74c3c;">*</span>
</label> </label>
<input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;"> <input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
</div> </div>
<div> <div>
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;"> <label for="backupConfigType" style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
配置類型 配置類型
</label> </label>
<select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;"> <select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
@ -514,9 +528,9 @@
</h3> </h3>
<div style="display: flex; gap: 10px; align-items: center;"> <div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="filter-keyword" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()"> <input type="text" id="filter-keyword" aria-label="搜尋名稱或描述" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
<select id="filter-type" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()"> <select id="filter-type" aria-label="選擇過濾類型" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
<option value="">所有類型</option> <option value="">所有類型</option>
<option value="running">running</option> <option value="running">running</option>
<option value="full">full</option> <option value="full">full</option>
@ -524,9 +538,9 @@
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 --> <!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
<div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;"> <div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
<input type="date" id="filter-date-start" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()"> <input type="date" id="filter-date-start" aria-label="開始日期" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
<span style="color: #7f8c8d; font-size: 13px;"></span> <span style="color: #7f8c8d; font-size: 13px;"></span>
<input type="date" id="filter-date-end" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()"> <input type="date" id="filter-date-end" aria-label="結束日期" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
</div> </div>
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;"> <button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
@ -540,10 +554,11 @@
<thead> <thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;"> <tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th> <th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
<th style="padding: 10px; color: #34495e; width: 25%;">快照名稱</th> <th style="padding: 10px; color: #34495e; width: 8%; text-align: center;">保護</th> <!-- 🌟 新增:獨立的保護欄位 -->
<th style="padding: 10px; color: #34495e; width: 25%;">描述</th> <th style="padding: 10px; color: #34495e; width: 22%;">快照名稱</th>
<th style="padding: 10px; color: #34495e; width: 22%;">描述</th>
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th> <th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th> <th style="padding: 10px; color: #34495e; text-align: right; width: 18%;">操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="backup-history-tbody"> <tbody id="backup-history-tbody">
@ -575,6 +590,31 @@
</div> </div>
</div> </div>
<!-- 🌟 新增God Mode 授權視窗 (Modal) -->
<div id="godModeModal" class="modal-overlay">
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
<div class="modal-title" style="justify-content: center; width: 100%;">
<span>🔐 系統進階授權</span>
</div>
</div>
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
請輸入維護者密碼以解鎖隱藏功能
</p>
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
<div style="display: flex; gap: 15px; justify-content: center;">
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
</div>
</div>
</div>
</div>
<!-- 引入獨立的 JavaScript 邏輯 --> <!-- 引入獨立的 JavaScript 邏輯 -->
<script type="module" src="/static/app.js"></script> <script type="module" src="/static/app.js"></script>

View File

@ -1,20 +1,26 @@
import asyncio import asyncio
import asyncpg import asyncpg
import logging import logging
import sys
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# DB 設定 # 🌟 2. 改用 os.getenv 讀取環境變數,並保留原本的設定作為安全預設值
DB_CONFIG = { DB_CONFIG = {
"database": "cmts_nms", "database": os.getenv("DB_NAME", "cmts_nms"),
"user": "swpa", "user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": "swpa4920", "password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": "127.0.0.1", "host": os.getenv("DB_HOST", "127.0.0.1"),
"port": "5432" "port": os.getenv("DB_PORT", "5432")
} }
async def init_database(): async def init_database(force_reset: bool = False):
try: try:
logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...") logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...")
conn = await asyncpg.connect( conn = await asyncpg.connect(
@ -25,34 +31,46 @@ async def init_database():
port=int(DB_CONFIG["port"]) 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(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS cmts_options ( CREATE TABLE IF NOT EXISTS cmts_options (
host VARCHAR(255) NOT NULL, host VARCHAR(255) NOT NULL,
config_type VARCHAR(50) NOT NULL,
path VARCHAR(500) NOT NULL, path VARCHAR(500) NOT NULL,
data JSONB NOT NULL, data JSONB NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (host, config_type, path) PRIMARY KEY (host, path)
); );
""") """)
# 2. 建立設備狀態表 device_status # 2. 建立設備狀態表 device_status (無 config_type)
logger.info("🛠️ 正在建立 device_status 資料表...") logger.info("🛠️ 正在檢查/建立 device_status 資料表...")
await conn.execute(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS device_status ( CREATE TABLE IF NOT EXISTS device_status (
host VARCHAR(255) NOT NULL, host VARCHAR(255) NOT NULL PRIMARY KEY,
config_type VARCHAR(50) NOT NULL,
cmts_version VARCHAR(100) DEFAULT 'unknown', cmts_version VARCHAR(100) DEFAULT 'unknown',
last_scanned VARCHAR(100), last_scanned VARCHAR(100),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
PRIMARY KEY (host, config_type)
); );
""") """)
# 3. 建立過濾器設定表 system_filters # 3. 建立過濾器設定表 system_filters
logger.info("🛠️ 正在建立 system_filters 資料表...") logger.info("🛠️ 正在檢查/建立 system_filters 資料表...")
await conn.execute(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS system_filters ( CREATE TABLE IF NOT EXISTS system_filters (
config_type VARCHAR(50) PRIMARY KEY, config_type VARCHAR(50) PRIMARY KEY,
@ -61,8 +79,8 @@ async def init_database():
); );
""") """)
# 4. 建立設備配置備份表 config_backups (Phase 1 新增) # 4. 建立設備配置備份表 config_backups (手動備份 + 釘選防護版)
logger.info("🛠️ 正在建立 config_backups 資料表與索引...") logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
await conn.execute(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS config_backups ( CREATE TABLE IF NOT EXISTS config_backups (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@ -70,19 +88,27 @@ async def init_database():
config_type VARCHAR(50) NOT NULL DEFAULT 'running', config_type VARCHAR(50) NOT NULL DEFAULT 'running',
timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
snapshot_name VARCHAR(255), snapshot_name VARCHAR(255),
description TEXT DEFAULT '',
is_auto BOOLEAN NOT NULL DEFAULT FALSE, is_auto BOOLEAN NOT NULL DEFAULT FALSE,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- 🌟 新增釘選防護欄位
raw_cli TEXT, raw_cli TEXT,
parsed_tree JSONB, parsed_tree JSONB,
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name) CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
); );
""") """)
# 建立複合索引以加速列表查詢與排序 # 建立基礎查詢索引
await conn.execute(""" await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
ON config_backups (host, config_type, timestamp DESC); ON config_backups (host, config_type, timestamp DESC);
""") """)
# 🌟 新增:建立滾動淘汰專用索引
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_retention
ON config_backups (host, is_pinned, timestamp DESC);
""")
await conn.close() await conn.close()
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。") logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
@ -90,4 +116,18 @@ async def init_database():
logger.error(f"❌ 資料庫初始化失敗: {e}") logger.error(f"❌ 資料庫初始化失敗: {e}")
if __name__ == "__main__": 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

@ -26,7 +26,8 @@ MANAGED_LOGGERS = [
"app.scraper", # 爬蟲與快取 "app.scraper", # 爬蟲與快取
"app.diagnostics", # CM 診斷 "app.diagnostics", # CM 診斷
"app.backup", # 備份與還原 "app.backup", # 備份與還原
"app.ssh" # SSH 連線引擎 "app.ssh", # 🔌 SSH 連線引擎 (注意這裡要有逗號!)
"app.auth" # 🔐 上帝模式與安全授權
] ]
def setup_logger(name: str, level=logging.INFO): def setup_logger(name: str, level=logging.INFO):
@ -61,3 +62,10 @@ def set_log_level(name: str, level_str: str):
logging.getLogger(name).setLevel(level) logging.getLogger(name).setLevel(level)
return True return True
return False return False
def register_websocket_handler(handler: logging.Handler):
"""🌟 新增:註冊自訂 Handler 到所有受管控的 Logger並防止重複添加"""
for name in MANAGED_LOGGERS:
logger = logging.getLogger(name)
if handler not in logger.handlers:
logger.addHandler(handler)

28
main.py
View File

@ -4,21 +4,45 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入
import database import database
import asyncio
# 引入我們剛剛拆分出來的路由模組 # 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth, logs
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
await database.init_db_pool() await database.init_db_pool()
# 🌟 新增:初始化 Log Broadcaster 的 Event Loop 參考,確保跨執行緒排程安全
logs.log_broadcaster.loop = asyncio.get_running_loop()
yield yield
# Shutdown # Shutdown
await database.close_db_pool() await database.close_db_pool()
app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan) app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan)
# 🌟 新增:全域安全與快取 Middleware
class SecurityAndCacheMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
# 1. 補上安全性標頭 (圖片 7 的警告)
response.headers["X-Content-Type-Options"] = "nosniff"
# 2. 針對靜態檔案補上 Cache-Control (圖片 2 的警告)
# 讓 JS/CSS 快取 1 小時 (3600秒),提升前端載入效能
if request.url.path.startswith("/static/"):
response.headers["Cache-Control"] = "public, max-age=3600"
else:
# API 請求不快取,確保拿到最新資料
response.headers["Cache-Control"] = "no-store"
return response
app.add_middleware(SecurityAndCacheMiddleware) # 🌟 掛載 Middleware
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js) # 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
@ -29,9 +53,11 @@ app.include_router(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1") app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1") app.include_router(backup.router, prefix="/api/v1")
app.include_router(diagnostics.router, prefix="/api/v1") app.include_router(diagnostics.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴 # WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router) app.include_router(terminal.router)
app.include_router(logs.router) # 🌟 新增:掛載日誌 WebSocket 路由
# 根目錄路由:回傳前端 UI # 根目錄路由:回傳前端 UI
@app.get("/", response_class=HTMLResponse, tags=["UI"]) @app.get("/", response_class=HTMLResponse, tags=["UI"])

25
routers/auth.py Normal file
View File

@ -0,0 +1,25 @@
import os
import secrets
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from logger import get_logger
logger = get_logger("app.auth")
router = APIRouter(tags=["Auth"])
# 定義請求本體格式
class GodModeRequest(BaseModel):
password: str
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
async def verify_god_mode(req: GodModeRequest):
# 從環境變數讀取正確密碼,預設為 "19760107@Serc0mm"
expected_password = os.getenv("GOD_MODE_SECRET", "19760107@Serc0mm")
# 使用 compare_digest 防禦計時攻擊
if secrets.compare_digest(req.password, expected_password):
logger.info("🔓 上帝模式解鎖成功!")
return {"status": "success"}
logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
raise HTTPException(status_code=401, detail="Invalid authorization code")

View File

@ -3,7 +3,7 @@ import asyncssh
import asyncio import asyncio
import re import re
import json import json
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
@ -11,15 +11,19 @@ from logger import get_logger
# 引入 DB 函數 # 引入 DB 函數
from database import ( from database import (
get_pool,
insert_config_backup, insert_config_backup,
get_config_backup_list, get_config_backup_list,
get_config_backup_detail, get_config_backup_detail,
delete_config_backup delete_config_backup,
cleanup_old_backups,
toggle_config_backup_pin, # 🌟 新增
get_backup_metrics # 🌟 新增
) )
from cmts_scraper import fetch_raw_config from cmts_scraper import fetch_raw_config
# 🌟 [Priority 1 修復] 引入 cmts_config_locks # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks from shared import parse_cli_to_tree, get_cmts_lock
logger = get_logger("app.backup") logger = get_logger("app.backup")
@ -149,23 +153,35 @@ async def delete_backup(backup_id: str):
return {"status": "error", "message": "刪除失敗或找不到該筆資料"} return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
return {"status": "success", "message": "快照已成功刪除"} return {"status": "success", "message": "快照已成功刪除"}
@router.post("/{backup_id}/toggle-pin", summary="切換快照的釘選防護狀態")
async def toggle_backup_pin(backup_id: str):
new_state = await toggle_config_backup_pin(backup_id)
if new_state is None:
raise HTTPException(status_code=500, detail="資料庫更新失敗")
status_str = "已啟用釘選保護,系統絕不自動淘汰。" if new_state else "已取消釘選,該備份將納入滾動淘汰範圍。"
return {
"status": "success",
"is_pinned": new_state,
"message": f"快照狀態更新成功!{status_str}"
}
@router.post("/snapshot", summary="手動建立設備快照 (串流版)") @router.post("/snapshot", summary="手動建立設備快照 (串流版)")
async def create_snapshot(req: SnapshotRequest): async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
async def backup_streamer(): async def backup_streamer():
try: try:
# 1. 廣播:正在連線 pool = await get_pool()
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n" yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# 2. 廣播:正在下載
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n" yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
# 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n" yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1)
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli) parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
# 4. 廣播:寫入資料庫
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n" yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
backup_id = await insert_config_backup( backup_id = await insert_config_backup(
host=req.host, host=req.host,
@ -181,12 +197,23 @@ async def create_snapshot(req: SnapshotRequest):
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n" yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
return return
# 5. 廣播:成功 # 🌟 關鍵變更:在同一個串流中「立即執行」清理與指標計算,確保回傳最新數據
yield json.dumps({"status": "progress", "message": "🧹 正在執行備份滾動淘汰與容量計算..."}) + "\n"
if pool:
# 1. 立即執行清理
await cleanup_old_backups(pool, req.host)
# 2. 立即計算最新指標
metrics = await get_backup_metrics(pool, req.host)
else:
metrics = {"total": 1, "pinned": 0, "unpinned": 1, "remaining": 19}
# 🌟 3. 在成功訊息中,將 metrics 字典一併回傳給前端
yield json.dumps({ yield json.dumps({
"status": "success", "status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!", "message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id, "backup_id": backup_id,
"host": req.host "host": req.host,
"metrics": metrics # 🌟 包含容量指標
}) + "\n" }) + "\n"
except Exception as e: except Exception as e:
@ -215,8 +242,9 @@ async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
if not raw_current: if not raw_current:
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"} return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
current_tree = parse_cli_to_tree(raw_current) # ✅ 安全升級:將 CPU 密集運算移交給 ThreadPool保護 Event Loop 不卡死
diff_commands = generate_diff_commands(current_tree, snapshot_tree) current_tree = await asyncio.to_thread(parse_cli_to_tree, raw_current)
diff_commands = await asyncio.to_thread(generate_diff_commands, current_tree, snapshot_tree)
return { return {
"status": "success", "status": "success",
@ -242,7 +270,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
async def restore_generator(): async def restore_generator():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖 # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = cmts_config_locks[req.host] host_lock = get_cmts_lock[req.host]
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令) # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
if host_lock.locked(): if host_lock.locked():
@ -254,7 +282,13 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n" yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
# 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放 # 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn: # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
conn = await asyncio.wait_for(
asyncssh.connect(host, username=username, password=password, known_hosts=None),
timeout=10.0
)
# 再進入 async with 確保資源會被自動關閉
async with conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None): async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):

View File

@ -10,7 +10,7 @@ from pydantic import BaseModel
from typing import List from typing import List
from netmiko import ConnectHandler from netmiko import ConnectHandler
# 🌟 [Priority 1 修復] 引入 cmts_config_locks # 🌟 [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, get_cmts_lock, parse_cli_to_tree, deep_split_tree
from collections import defaultdict from collections import defaultdict
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from logger import get_all_log_levels, set_log_level 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" return f"filters_{safe_type}.json"
async def load_tree_filters(config_type: str) -> list: async def load_tree_filters(config_type: str) -> list:
if USE_DB:
try: try:
db_filters = await database.get_tree_filters(config_type) db_filters = await database.get_tree_filters(config_type)
if db_filters is not None: return db_filters if db_filters is not None else []
return db_filters
else:
print("⚠️ [Fallback] 資料庫無法取得過濾器,自動切換至 JSON 快取讀取...")
except Exception as e: except Exception as e:
print(f"⚠️ [Fallback] 資料庫讀取過濾器發生例外: {e},自動切換至 JSON 快取讀取...") print(f"資料庫讀取過濾器發生例外: {e}")
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 [] return []
async def save_tree_filters(config_type: str, hidden_keys: list): async def save_tree_filters(config_type: str, hidden_keys: list):
db_success = False
if USE_DB:
try: try:
if await database.upsert_tree_filters(config_type, hidden_keys): await database.upsert_tree_filters(config_type, hidden_keys)
db_success = True
else:
print("⚠️ [Fallback] 資料庫儲存過濾器失敗,自動切換至 JSON 快取寫入...")
except Exception as e: except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入過濾器發生例外: {e},自動切換至 JSON 快取寫入...") print(f"資料庫寫入過濾器發生例外: {e}")
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}")
# ========================================== # ==========================================
@ -91,18 +64,18 @@ async def execute_cmts_config(req: ConfigRequest):
async def config_streamer(): async def config_streamer():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖 # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = cmts_config_locks[req.host] host_lock = get_cmts_lock[req.host]
async with host_lock: async with host_lock:
try: try:
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n" yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
# 使用 asyncssh 建立非同步連線 # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
async with asyncssh.connect( conn = await asyncio.wait_for(
req.host, asyncssh.connect(host, username=username, password=password, known_hosts=None),
username=req.username, timeout=10.0
password=req.password, )
known_hosts=None # 再進入 async with 確保資源會被自動關閉
) as conn: async with conn:
async with conn.create_process() as process: async with conn.create_process() as process:
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死) # 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)

View File

@ -1,4 +1,5 @@
# --- routers/diagnostics.py --- # --- routers/diagnostics.py ---
import asyncio
import re import re
import asyncssh import asyncssh
from logger import get_logger from logger import get_logger
@ -34,7 +35,14 @@ async def get_cm_diagnostics(
} }
try: try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
conn = await asyncio.wait_for(
asyncssh.connect(host, username=username, password=password, known_hosts=None),
timeout=10.0
)
# 再進入 async with 確保資源會被自動關閉
async with conn:
logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}") logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")
# ========================================== # ==========================================

View File

@ -3,31 +3,19 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
import json, os, asyncio, re import json, asyncio, re
import database import database
from cmts_scraper import sync_cmts_leaves_async from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE, USE_DB from shared import CMTS_DEVICE
router = APIRouter(tags=["Options"]) 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 = {} SCAN_STATUS = {}
active_clients = {} active_clients = {}
async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"): async def broadcast_message(message_dict: dict, host: str):
"""將訊息推播給特定頻道的連線前端""" channel_key = host
channel_key = f"{host}_{config_type}"
dead_clients = set() dead_clients = set()
# SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
message_str = f"data: {json.dumps(message_dict)}\n\n" message_str = f"data: {json.dumps(message_dict)}\n\n"
for client_queue in active_clients.get(channel_key, set()): 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) active_clients[channel_key].discard(dead)
@router.get("/cmts-leaf-options/stream") @router.get("/cmts-leaf-options/stream")
async def sse_stream(request: Request, host: str, config_type: str = "running"): async def sse_stream(request: Request, host: str):
"""前端一載入就會連上這個路由,持續監聽特定頻道的廣播""" channel_key = host
channel_key = f"{host}_{config_type}"
client_queue = asyncio.Queue() client_queue = asyncio.Queue()
if channel_key not in active_clients: if channel_key not in active_clients:
@ -51,92 +38,61 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
async def event_generator(): async def event_generator():
try: try:
while True: while True:
# 主動檢查斷線,避免死鎖
if await request.is_disconnected(): if await request.is_disconnected():
break break
try: try:
# [修復 Leak] 延長 timeout 降低輪詢負擔
message = await asyncio.wait_for(client_queue.get(), timeout=5.0) message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
yield message yield message
except asyncio.TimeoutError: except asyncio.TimeoutError:
yield ": keepalive\n\n" yield ": keepalive\n\n"
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
# [修復 Leak] 確保斷線時 Queue 絕對會被移出 active_clients 釋放記憶體
if channel_key in active_clients: if channel_key in active_clients:
active_clients[channel_key].discard(client_queue) active_clients[channel_key].discard(client_queue)
# ✅ 安全升級:如果該設備已經沒有任何客戶端監聽,徹底刪除 Key 釋放記憶體
if not active_clients[channel_key]:
del active_clients[channel_key]
return StreamingResponse(event_generator(), media_type="text/event-stream") return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/scan-status") @router.get("/scan-status")
async def get_scan_status(host: str, config_type: str = "running"): async def get_scan_status(host: str):
channel_key = f"{host}_{config_type}" return {"is_scanning": SCAN_STATUS.get(host, False)}
return {"is_scanning": SCAN_STATUS.get(channel_key, False)}
# ==========================================
# 🌟 3. 快取讀取與背景掃描任務
# ==========================================
class SyncOptionsRequest(BaseModel): class SyncOptionsRequest(BaseModel):
host: str # 🌟 新增 host 參數 host: str
username: str = "" # 🌟 新增 username 參數 username: str = ""
password: str = "" # 🌟 新增 password 參數 password: str = ""
leaf_paths: List[str] leaf_paths: List[str]
config_type: str = "running"
@router.get("/cmts-leaf-options") @router.get("/cmts-leaf-options")
async def get_leaf_options(host: str, config_type: str = "running"): async def get_leaf_options(host: str):
if USE_DB:
try: try:
db_options = await database.get_all_leaf_options(host, config_type) db_options = await database.get_all_leaf_options(host)
if db_options is not None: if db_options is not None:
# 取得 metadata metadata = await database.get_device_status(host)
metadata = await database.get_device_status(host, config_type)
if metadata: if metadata:
db_options["__metadata__"] = metadata db_options["__metadata__"] = metadata
return db_options return db_options
else: raise HTTPException(status_code=500, detail="資料庫連線異常")
# 若回傳 None 表示 DB 連線異常,執行 Fallback
print("⚠️ [Fallback] 資料庫無法取得資料,自動切換至 JSON 快取讀取...")
except Exception as e: except Exception as e:
print(f"⚠️ [Fallback] 資料庫讀取發生例外: {e},自動切換至 JSON 快取讀取...") raise HTTPException(status_code=500, detail=f"資料庫讀取失敗: {e}")
cache_file = get_cache_file(host, config_type) async def run_scan_task(host: str, username: str, password: str, paths: List[str]):
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 global SCAN_STATUS
channel_key = f"{host}_{config_type}" channel_key = host
try: try:
await broadcast_message({"event": "scan_start"}, host, config_type) await broadcast_message({"event": "scan_start"}, host)
async for chunk in sync_cmts_leaves_async(host=host, username=username, password=password, leaf_paths=paths):
# 呼叫爬蟲 (帶入設備連線資訊與 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): if isinstance(chunk, str):
await broadcast_message(json.loads(chunk), host, config_type) await broadcast_message(json.loads(chunk), host)
else: else:
await broadcast_message(chunk, host, config_type) await broadcast_message(chunk, host)
await broadcast_message({"event": "done"}, host)
await broadcast_message({"event": "done"}, host, config_type)
except Exception as e: except Exception as e:
await broadcast_message({"event": "error", "message": str(e)}, host, config_type) await broadcast_message({"event": "error", "message": str(e)}, host)
finally: finally:
# 解除特定頻道的鎖定
SCAN_STATUS[channel_key] = False SCAN_STATUS[channel_key] = False
@router.post("/cmts-leaf-options/sync") @router.post("/cmts-leaf-options/sync")
@ -145,14 +101,12 @@ async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: Backg
if not request.leaf_paths: if not request.leaf_paths:
raise HTTPException(status_code=400) raise HTTPException(status_code=400)
channel_key = f"{request.host}_{request.config_type}" channel_key = request.host
if SCAN_STATUS.get(channel_key, False): if SCAN_STATUS.get(channel_key, False):
return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"} return {"status": "busy", "message": "⚠️ 該設備的掃描任務正在執行中,請勿重複點擊!"}
SCAN_STATUS[channel_key] = True SCAN_STATUS[channel_key] = True
# 保留您原本完美的路徑清理邏輯
cmts_query_paths = [] cmts_query_paths = []
for p in request.leaf_paths: for p in request.leaf_paths:
clean_p = p.replace("::", " ") clean_p = p.replace("::", " ")
@ -160,60 +114,18 @@ async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: Backg
cmts_query_paths.append(clean_p) cmts_query_paths.append(clean_p)
cmts_query_paths = list(dict.fromkeys(cmts_query_paths)) 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 "" user = request.username or CMTS_DEVICE.get("username") or ""
pwd = request.password or CMTS_DEVICE.get("password") 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"} return {"status": "started"}
# ==========================================
# 🌟 4. 清除快取 API
# ==========================================
@router.post("/clear_cache") @router.post("/clear_cache")
async def clear_specific_cache(host: str, config_type: str = "running", paths_to_clear: list = Body(...)): async def clear_specific_cache(host: str, paths_to_clear: list = Body(...)):
cleared_count = 0
# 1. 嘗試清除 DB
if USE_DB:
try: try:
db_cleared = await database.delete_leaf_options(host, config_type, paths_to_clear) cleared_count = await database.delete_leaf_options(host, paths_to_clear)
if db_cleared >= 0: if cleared_count >= 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": cleared_count}
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"} raise HTTPException(status_code=500, detail="資料庫清除失敗")
try:
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
json_cleared_count = 0
for path in paths_to_clear:
if path in cache_data:
del cache_data[path]
json_cleared_count += 1
if json_cleared_count > 0:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=4)
# 回傳較大的那個數值
final_count = max(cleared_count, json_cleared_count)
return {"status": "success", "cleared_count": final_count}
except Exception as e: except Exception as e:
# 確保回傳標準 JSON 格式 raise HTTPException(status_code=500, detail=f"資料庫清除失敗: {str(e)}")
return {"status": "error", "message": f"資料庫連線與快取存取皆失敗: {str(e)}"}

79
routers/logs.py Normal file
View File

@ -0,0 +1,79 @@
# --- routers/logs.py ---
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from logger import ColoredFormatter, register_websocket_handler
router = APIRouter()
class LogBroadcaster:
"""管理所有活躍 WebSocket 連線的廣播器 (Connection Manager)"""
def __init__(self):
self.active_connections: set[WebSocket] = set()
self.loop: asyncio.AbstractEventLoop = None
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.add(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.discard(websocket)
async def broadcast(self, message: str):
"""非同步推播日誌給所有連線中的客戶端"""
if not self.active_connections:
return
# 併發發送,並透過 return_exceptions=True 確保單一連線異常不影響其他客戶端
tasks = [self._safe_send(conn, message) for conn in self.active_connections]
await asyncio.gather(*tasks, return_exceptions=True)
async def _safe_send(self, websocket: WebSocket, message: str):
try:
await websocket.send_text(message)
except Exception:
self.disconnect(websocket)
# 建立全域廣播器實例
log_broadcaster = LogBroadcaster()
class WebSocketLogHandler(logging.Handler):
"""自訂 Log Handler攔截系統日誌並安全地派發至非同步廣播器"""
def __init__(self, broadcaster: LogBroadcaster):
super().__init__()
self.broadcaster = broadcaster
# 沿用系統 ColoredFormatter完美保留 ANSI 色碼
self.setFormatter(ColoredFormatter())
def emit(self, record):
try:
msg = self.format(record)
loop = self.broadcaster.loop
# 確保在 Event Loop 處於執行狀態時,安全地跨執行緒派發任務
if loop and loop.is_running():
loop.call_soon_threadsafe(
lambda: asyncio.create_task(self.broadcaster.broadcast(msg))
)
except Exception:
self.handleError(record)
# 建立 Handler 實例並註冊至所有受管控的 Logger
websocket_log_handler = WebSocketLogHandler(log_broadcaster)
register_websocket_handler(websocket_log_handler)
@router.websocket("/ws/logs")
async def websocket_logs(websocket: WebSocket):
"""WebSocket 實時日誌串流端點"""
# 若尚未綁定 Event Loop則於首次連線時動態綁定
if not log_broadcaster.loop:
log_broadcaster.loop = asyncio.get_running_loop()
await log_broadcaster.connect(websocket)
try:
# 保持連線,監聽客戶端斷線狀態
while True:
await websocket.receive_text()
except WebSocketDisconnect:
log_broadcaster.disconnect(websocket)
except Exception:
log_broadcaster.disconnect(websocket)

View File

@ -17,7 +17,13 @@ async def execute_single_command(host, username, password, command, timeout=15.0
自動防呆確保指令帶有取消分頁的後綴防止 Event Loop 卡死 自動防呆確保指令帶有取消分頁的後綴防止 Event Loop 卡死
""" """
try: try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
conn = await asyncio.wait_for(
asyncssh.connect(host, username=username, password=password, known_hosts=None),
timeout=10.0
)
# 再進入 async with 確保資源會被自動關閉
async with conn:
# 確保指令包含取消分頁的參數 # 確保指令包含取消分頁的參數
if "| nomore" not in command.lower(): if "| nomore" not in command.lower():
command += " | nomore" command += " | nomore"
@ -38,7 +44,13 @@ async def execute_interactive_command(host, username, password, commands: list,
動態處理終端機的 --More-- 提示 動態處理終端機的 --More-- 提示
""" """
try: try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
conn = await asyncio.wait_for(
asyncssh.connect(host, username=username, password=password, known_hosts=None),
timeout=10.0
)
# 再進入 async with 確保資源會被自動關閉
async with conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
async def read_until_quiet(wait_time): async def read_until_quiet(wait_time):

View File

@ -15,7 +15,10 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
ssh_task = None ssh_task = None
try: try:
# 建立連線 # 建立連線
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) conn = await asyncio.wait_for(
asyncssh.connect(host, username=username, password=password, known_hosts=None),
timeout=10.0
)
# 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height) # 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height)
process = await conn.create_process( process = await conn.create_process(
@ -37,7 +40,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True) logger.error(f"❌ [WS Forward Error] 讀取設備畫面時發生錯誤: {str(e)}")
async def forward_to_ssh(): async def forward_to_ssh():
try: try:
@ -53,11 +56,14 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
await process.stdin.drain() await process.stdin.drain()
except WebSocketDisconnect: except WebSocketDisconnect:
# 主動拋出,讓外層捕捉以進行資源回收 # 主動拋出,讓外層捕捉以進行資源回收
raise # raise
# 🌟 修正:不要再 raise 拋出去了,直接 return 結束任務,讓外層自然回收
logger.debug("前端 WebSocket 正常斷開 (使用者重整或關閉網頁)")
return
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True) logger.error(f"❌ [SSH Forward Error] 寫入指令到設備時發生錯誤: {str(e)}")
ws_task = asyncio.create_task(forward_to_ws()) ws_task = asyncio.create_task(forward_to_ws())
ssh_task = asyncio.create_task(forward_to_ssh()) ssh_task = asyncio.create_task(forward_to_ssh())
@ -77,12 +83,19 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
if ssh_task and not ssh_task.done(): if ssh_task and not ssh_task.done():
ssh_task.cancel() ssh_task.cancel()
except Exception as e: except Exception as e:
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n" err_str = str(e)
error_msg = f"\r\n\x1b[31mSSH Connection Error: {err_str}\x1b[0m\r\n"
try: try:
# 先把錯誤印在終端機畫面上
await websocket.send_text(error_msg) await websocket.send_text(error_msg)
# 🌟 關鍵修復:使用自訂斷線碼 4001並附上錯誤原因 (WebSocket 規範 reason 最長 123 bytes)
safe_reason = err_str[:120] if err_str else "Authentication or Connection Failed"
await websocket.close(code=4001, reason=safe_reason)
except: except:
pass pass
logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True) logger.error(f"❌ [Connection Error] 建立連線或執行過程中發生錯誤: {str(e)}")
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process: if process:

View File

@ -6,18 +6,17 @@ import re
from collections import defaultdict from collections import defaultdict
DEBUG_MODE = False DEBUG_MODE = False
USE_DB = True # PostgreSQL Feature Toggle
def debug_print(msg: str): def debug_print(msg: str):
if DEBUG_MODE: if DEBUG_MODE:
print(msg) print(msg)
# 預設的 CMTS 連線樣板 # 🌟 2. 淨化預設的 CMTS 連線樣板
CMTS_DEVICE = { CMTS_DEVICE = {
'device_type': 'cisco_ios', 'device_type': 'cisco_ios',
'host': '10.14.110.4', 'host': os.getenv("DEFAULT_CMTS_HOST", ""),
'username': 'admin', 'username': os.getenv("DEFAULT_CMTS_USER", ""),
'password': 'nsgadmin', 'password': os.getenv("DEFAULT_CMTS_PASS", ""),
'port': 22, 'port': 22,
'fast_cli': False, 'fast_cli': False,
'global_delay_factor': 2 'global_delay_factor': 2
@ -258,5 +257,10 @@ def save_settings(settings_data):
with open(SETTINGS_FILE, "w", encoding="utf-8") as f: with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings_data, f, indent=4, ensure_ascii=False) json.dump(settings_data, f, indent=4, ensure_ascii=False)
# 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖 # 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖 (安全動態獲取版)
cmts_config_locks = defaultdict(asyncio.Lock) _cmts_config_locks = {}
def get_cmts_lock(host: str) -> asyncio.Lock:
if host not in _cmts_config_locks:
_cmts_config_locks[host] = asyncio.Lock()
return _cmts_config_locks[host]

View File

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

View File

@ -15,7 +15,11 @@ import {
apiGetScanStatus, apiGetLockStatus, apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics, apiGetCmDiagnostics,
apiListCms, apiListRpds, apiListCms, apiListRpds,
apiGetLogLevels, apiSetLogLevel apiGetLogLevels, apiSetLogLevel,
apiVerifyGodMode,
apiGetLeafOptions, // 🌟 補上這個
apiSyncLeafOptions, // 🌟 補上這個
apiToggleBackupPin // 🌟 新增這行
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -39,6 +43,11 @@ import {
} from './mac-domain.js'; } from './mac-domain.js';
// ============================================================================
// 🌟 全域記憶體 (Dual-Track Memory)
// ============================================================================
window.treeDataStore = { running: null, full: null };
// ============================================================================ // ============================================================================
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽 // 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
// ============================================================================ // ============================================================================
@ -47,18 +56,32 @@ let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null; let globalSSE = null;
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道 let logTerm = null;
function initGlobalSSE(mode = 'running') { let logWs = null;
let isLogEnabled = false; // 預設關閉,不主動消耗資源
// 初始化全域 SSE 監聽器 (設備級別)
function initGlobalSSE() {
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
if (!isConnected) {
if (globalSSE) {
globalSSE.close();
globalSSE = null;
}
return;
}
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE if (!connInfo || !connInfo.host) return;
if (globalSSE) globalSSE.close(); 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) => { globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
const statusEl = document.getElementById('scan-status'); const statusEl = document.getElementById('scan-status');
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
if (data.event === 'scan_start' || data.event === 'progress') { if (data.event === 'scan_start' || data.event === 'progress') {
setAdminButtonsState(true, "⏳ 系統更新中..."); setAdminButtonsState(true, "⏳ 系統更新中...");
} }
@ -81,7 +104,6 @@ function initGlobalSSE(mode = 'running') {
statusEl.style.color = "#27ae60"; statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000); setTimeout(() => statusEl.textContent = "", 5000);
} }
// 掃描完成才解鎖
setAdminButtonsState(false); setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil'); localStorage.removeItem('scanLockUntil');
} }
@ -90,7 +112,6 @@ function initGlobalSSE(mode = 'running') {
statusEl.innerHTML = `❌ 錯誤: ${data.message}`; statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c"; statusEl.style.color = "#e74c3c";
} }
// 發生錯誤也要解鎖
setAdminButtonsState(false); setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil'); localStorage.removeItem('scanLockUntil');
} }
@ -116,6 +137,8 @@ function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer); if (lockPollingTimer) clearInterval(lockPollingTimer);
lockPollingTimer = setInterval(async () => { lockPollingTimer = setInterval(async () => {
if (!isConnected) return; // 🌟 新增防線:未連線時不進行輪詢
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; if (!connInfo || !connInfo.host) return;
const host = connInfo.host; const host = connInfo.host;
@ -138,23 +161,27 @@ function startLockStatusPolling() {
for (const [path, lockInfo] of Object.entries(currentLocks)) { for (const [path, lockInfo] of Object.entries(currentLocks)) {
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒) // 🛡️ 防閃爍機制
if (window.recentlyReleasedLocks[lockKey]) { if (window.recentlyReleasedLocks[lockKey]) {
if (now - window.recentlyReleasedLocks[lockKey] < 8000) { if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
continue; // 跳過,不當作鎖定 continue;
} else { } else {
delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶 delete window.recentlyReleasedLocks[lockKey];
} }
} }
newLockState.add(lockKey); // 記憶體加入 Host 標籤 newLockState.add(lockKey);
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); // 🌟 效能優化:只在當前啟用的視圖中搜尋,避免掃描隱藏的龐大 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 => { targetBtns.forEach(btn => {
// 略過目前正在編輯的那個實體按鈕
if (btn.id === `edit-btn-${currentEditElementId}`) return; if (btn.id === `edit-btn-${currentEditElementId}`) return;
if (btn.innerText !== "🔒") { if (btn.innerText !== "🔒") {
@ -163,41 +190,38 @@ function startLockStatusPolling() {
const btnPath = btn.getAttribute('data-path'); const btnPath = btn.getAttribute('data-path');
if (btnPath === path) { if (btnPath === path) {
// 判斷是別人鎖的,還是自己在另一個視圖鎖的 btn.title = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
if (lockInfo.user_id !== SESSION_USER_ID) {
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
} else { } else {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
}
} else {
// 子節點被父節點鎖定
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
} }
btn.innerText = "🔒"; btn.innerText = "🔒";
} }
}); });
} }
}
// 🔓 任務 2處理「解除鎖定」的節點 // 🔓 任務 2處理「解除鎖定」的節點
activeLockState.forEach(oldKey => { activeLockState.forEach(oldKey => {
const [oldHost, oldPath] = oldKey.split('@@'); const [oldHost, oldPath] = oldKey.split('@@');
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
if (oldHost === host && !newLockState.has(oldKey)) { if (oldHost === host && !newLockState.has(oldKey)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); // 🌟 效能優化:同樣只在當前啟用的視圖中搜尋
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 => { targetBtns.forEach(btn => {
if (btn.innerText === "🔒") { if (btn.innerText === "🔒") {
const btnPath = btn.getAttribute('data-path'); const btnPath = btn.getAttribute('data-path');
let stillLockedByOther = false; let stillLockedByOther = false;
// 檢查自己是否還在鎖定清單中
if (currentLocks[btnPath]) { if (currentLocks[btnPath]) {
stillLockedByOther = true; stillLockedByOther = true;
} else { } else {
// 檢查是否有其他父節點鎖定它
for (const lockedPath of Object.keys(currentLocks)) { for (const lockedPath of Object.keys(currentLocks)) {
if (btnPath.startsWith(lockedPath + "::")) { if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true; stillLockedByOther = true;
@ -215,6 +239,7 @@ function startLockStatusPolling() {
} }
}); });
} }
}
}); });
// 💾 任務 3更新特務的記憶 // 💾 任務 3更新特務的記憶
@ -230,7 +255,7 @@ function startLockStatusPolling() {
window.onload = () => { window.onload = () => {
initTerminal(); initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器 resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監 // initGlobalSSE(); <--- 🌟 刪除這行!不要在網頁剛開時就偷
startLockStatusPolling(); // 啟動鎖定狀態輪詢 startLockStatusPolling(); // 啟動鎖定狀態輪詢
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步 // 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
@ -419,7 +444,7 @@ function switchConfigTask() {
} }
} }
initGlobalSSE(currentMode); initGlobalSSE();
} }
} }
@ -468,19 +493,26 @@ function toggleQueryInputs(mode) {
function resetAllManagerData() { function resetAllManagerData() {
resetMacDomainData(); resetMacDomainData();
// 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓 // 🧹 1. 清空動態下拉選單的快
if (typeof cachedDeviceLists !== 'undefined') { if (typeof cachedDeviceLists !== 'undefined') {
cachedDeviceLists = { cms: null, rpds: null, host: null }; cachedDeviceLists = { cms: null, rpds: null, host: null };
} }
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM // 🧹 2. 清空前端樹狀圖快取與 DOM (設備配置樹狀圖)
clearTreeCache(); clearTreeCache();
window.treeDataStore = { running: null, full: null }; // 🌟 清空雙軌記憶體
// 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體 const treeRunning = document.getElementById('tree-container-running');
const treeFull = document.getElementById('tree-container-full');
if (treeRunning) treeRunning.innerHTML = '<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>';
if (treeFull) treeFull.innerHTML = '<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>';
// 🛡️ 3. 跨設備防護:清空鎖定輪詢的記憶體
if (typeof activeLockState !== 'undefined' && activeLockState.clear) { if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
activeLockState.clear(); activeLockState.clear();
} }
// 🧹 4. 清空 MAC Domain 配置精靈
const configArea = document.getElementById('macDomainConfigArea'); const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none'; if (configArea) configArea.style.display = 'none';
@ -510,10 +542,39 @@ function resetAllManagerData() {
fetchStatus.style.color = "#7f8c8d"; fetchStatus.style.color = "#7f8c8d";
} }
// 🧹 5. 清空 CMTS 狀態查詢輸入框
document.getElementById('queryTargetCm').value = ''; document.getElementById('queryTargetCm').value = '';
document.getElementById('queryTargetRpd').value = ''; document.getElementById('queryTargetRpd').value = '';
// 🌟 新增:切換設備連線後,自動重整備份歷史紀錄 // 🧹 6. 清空 CM 深度診斷與 MER 分析
const diagMacInput = document.getElementById('diagMacInput');
if (diagMacInput) diagMacInput.value = '';
const diagResultArea = document.getElementById('diagResultArea');
if (diagResultArea) diagResultArea.style.display = 'none';
if (typeof merChartInstance !== 'undefined' && merChartInstance) {
merChartInstance.destroy();
merChartInstance = null;
}
// 🧹 7. 清空系統設定 (God Mode Filters) 的樹狀圖
const filterContainer = document.getElementById('filter-checkboxes');
if (filterContainer) {
filterContainer.innerHTML = '';
filterContainer.style.display = 'none';
}
// 🧹 8. 清空設備備份與還原歷史紀錄
if (typeof allBackupsData !== 'undefined') {
allBackupsData = []; // 清空記憶體中的快照陣列
}
const backupTbody = document.getElementById('backup-history-tbody');
if (backupTbody) {
backupTbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td></tr>';
}
// 🌟 觸發重新載入備份歷史 (因為已經連上新設備)
if (typeof loadBackupHistory === 'function') { if (typeof loadBackupHistory === 'function') {
loadBackupHistory(); loadBackupHistory();
} }
@ -595,11 +656,12 @@ async function fetchFullConfig(configType = 'running') {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') { if (versionRes.status === 'success') {
const currentVersion = versionRes.version; 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 optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version; const cachedVersion = optData.__metadata__?.cmts_version;
if (cachedVersion && cachedVersion !== currentVersion) { // 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾
if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`); const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
if (doClear) { if (doClear) {
const allKeys = Object.keys(optData); const allKeys = Object.keys(optData);
@ -676,7 +738,8 @@ async function fetchFullConfig(configType = 'running') {
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。"); if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
window.currentTreeData = finalTreeData; // 🌟 1. 存入雙軌記憶體,不再污染全域變數
window.treeDataStore[configType] = finalTreeData;
const currentSelectedTask = document.getElementById('configTask').value; const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
@ -684,19 +747,29 @@ async function fetchFullConfig(configType = 'running') {
if (currentSelectedTask !== expectedTask) return; if (currentSelectedTask !== expectedTask) return;
if (treeContainer) { if (treeContainer) {
// 🌟 統一渲染作法:先顯示 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); treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
if (loadingMsg) loadingMsg.style.display = 'none'; if (loadingMsg) loadingMsg.style.display = 'none';
document.body.style.cursor = 'default';
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType); // 🌟 拔除 configType檢查設備級別的掃描狀態
const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
if (isSomeoneScanning) { if (isSomeoneScanning) {
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。"); alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000); lockScanButton(60000);
} else { } else {
enableGlobalScanButton(); enableGlobalScanButton();
if (needAutoScan) { if (needAutoScan) {
setTimeout(() => scanGlobalMissingOptions(configType), 500); // 🌟 呼叫新的全域掃描函數
setTimeout(() => scanMissingOptions(), 500);
} }
} }
}, 20);
} }
} catch (error) { } catch (error) {
@ -705,7 +778,7 @@ async function fetchFullConfig(configType = 'running') {
if (progressInterval) clearInterval(progressInterval); if (progressInterval) clearInterval(progressInterval);
if (loadingMsg) { if (loadingMsg) {
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色 loadingMsg.style.color = "#f39c12";
} }
} }
} }
@ -911,16 +984,14 @@ function renderMerChart(channelName, histogramData) {
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning) // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================ // ============================================================================
// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數 // 為現有的 input 加上下拉選項 (不破壞原有結構)
async function enhanceInputWithOptions(path, elementId, mode = 'running') { async function enhanceInputWithOptions(path, elementId) {
// 🌟 1. 取得當前連線的 IP
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; if (!connInfo || !connInfo.host) return;
try { try {
// 🌟 2. GET 請求:網址加上 host 參數 // 🌟 拔除 config_type 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); const optData = await apiGetLeafOptions(connInfo.host);
const optData = await optRes.json();
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey]; const leafCache = optData[cacheKey];
@ -942,11 +1013,11 @@ async function enhanceInputWithOptions(path, elementId, mode = 'running') {
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join(''); dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
} }
} else { } else {
// 🌟 3. POST 請求:把 host 塞進 body 裡面 // 🌟 拔除 config_type 參數
fetch('/api/v1/cmts-leaf-options/sync', { fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode }) body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey] })
}); });
} }
} catch (e) { } catch (e) {
@ -977,58 +1048,38 @@ function extractAllCacheKeys(node, path = '') {
return keys; return keys;
} }
async function performScan(isGlobal, mode = 'running') { // 🌟 全新:設備級全域差異掃描
async function scanMissingOptions() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先連線至設備!"); if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode); const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
if (isSomeoneScanning) { if (isSomeoneScanning) {
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!"); alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
return; 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'); const statusEl = document.getElementById('scan-status');
try { try {
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); const optData = await apiGetLeafOptions(connInfo.host);
const optData = await optRes.json();
const pathsToSync = []; const pathsToSync = [];
const allKeys = extractAllCacheKeys(targetTreeData);
if (isGlobal) { // 差異比對:只挑出資料庫沒有的 Key
// 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
if (!window.currentTreeData) return alert("請先載入設備配置!");
const allKeys = extractAllCacheKeys(window.currentTreeData);
allKeys.forEach(cacheKey => { allKeys.forEach(cacheKey => {
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) { if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey); pathsToSync.push(cacheKey);
} }
}); });
} else {
// 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的)
const allContainers = treeContainer.querySelectorAll('.leaf-container');
allContainers.forEach(container => {
const isHiddenByFolder = container.closest('details:not([open])') !== null;
if (!isHiddenByFolder) {
const path = container.getAttribute('data-path');
if (path) {
const origVal = container.getAttribute('data-original') || '';
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
}
}
});
}
if (pathsToSync.length === 0) { if (pathsToSync.length === 0) {
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!"; statusEl.textContent = "✅ 所有欄位都已有最新選項,無需掃描!";
statusEl.style.color = "#27ae60"; statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 3000); setTimeout(() => statusEl.textContent = "", 3000);
return; return;
@ -1037,7 +1088,7 @@ async function performScan(isGlobal, mode = 'running') {
const response = await fetch('/api/v1/cmts-leaf-options/sync', { const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode }) body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync })
}); });
const result = await response.json(); const result = await response.json();
@ -1052,6 +1103,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 參數 // 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
// ============================================================================ // ============================================================================
@ -1151,9 +1243,102 @@ async function switchFilterMode() {
await openSystemSettings(); await openSystemSettings();
} }
// 開啟系統設定並讀取目前的過濾器清單 function initLogTerminal() {
const container = document.getElementById('log-terminal-container');
if (!container || logTerm) return;
logTerm = new Terminal({
cursorBlink: false,
disableStdin: true,
theme: { background: '#1e1e1e', foreground: '#e0e0e0' },
fontFamily: 'Courier New, monospace',
fontSize: 13,
scrollback: 1000,
cols: 250 // 確保不折行
});
logTerm.open(container);
logTerm.writeln('\x1b[33m[System] Live Log Terminal Ready. Click "啟動監聽" to start streaming.\x1b[0m\r\n');
}
function toggleLogStream() {
isLogEnabled = !isLogEnabled;
const btn = document.getElementById('btnToggleLog');
if (isLogEnabled) {
initLogTerminal();
connectLogWebSocket();
if (btn) {
btn.textContent = '⏸️ 暫停監聽';
btn.className = 'btn-modern btn-clear';
}
} else {
disconnectLogWebSocket();
if (btn) {
btn.textContent = '🔌 啟動監聽';
btn.className = 'btn-modern btn-connect';
}
}
}
function connectLogWebSocket() {
if (!isLogEnabled) return;
if (logWs && logWs.readyState === WebSocket.OPEN) return;
const statusEl = document.getElementById('log-ws-status');
if (statusEl) {
statusEl.textContent = '狀態:正在連線... 🟡';
statusEl.style.color = '#f39c12';
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
logWs = new WebSocket(wsUrl);
logWs.onopen = () => {
if (statusEl) {
statusEl.textContent = '狀態:實時監聽中 🟢';
statusEl.style.color = '#27ae60';
}
};
logWs.onmessage = (event) => {
if (logTerm && isLogEnabled) {
const formattedLog = event.data.replace(/\n/g, '\r\n') + '\r\n';
logTerm.write(formattedLog);
}
};
logWs.onclose = () => {
const statusEl = document.getElementById('log-ws-status');
if (isLogEnabled) {
if (statusEl) {
statusEl.textContent = '狀態:連線中斷,嘗試重連... 🔴';
statusEl.style.color = '#c0392b';
}
setTimeout(connectLogWebSocket, 5000);
} else {
if (statusEl) {
statusEl.textContent = '狀態:已暫停 ⚪';
statusEl.style.color = '#7f8c8d';
}
}
};
}
function disconnectLogWebSocket() {
if (logWs) {
logWs.close();
logWs = null;
}
}
// --- 3. 確保 openSystemSettings 正常初始化 ---
async function openSystemSettings() { async function openSystemSettings() {
// 🌟 動態抓取過濾器模式 setTimeout(() => {
initLogTerminal();
}, 50);
const modeSelect = document.getElementById('filter-mode-select'); const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running'; const mode = modeSelect ? modeSelect.value : 'running';
@ -1164,7 +1349,6 @@ async function openSystemSettings() {
console.error("讀取設定失敗:", error); console.error("讀取設定失敗:", error);
} }
// 🌟 新增:同時載入日誌等級
loadLogLevels(); loadLogLevels();
} }
@ -1181,7 +1365,8 @@ async function loadLogLevels() {
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)', 'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)', 'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
'app.backup': '💾 備份與還原模組 (Backup)', 'app.backup': '💾 備份與還原模組 (Backup)',
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)' 'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)',
'app.auth': '🔐 安全授權模組 (Auth)' // 🌟 新增這行
}; };
for (const [module, level] of Object.entries(res.data)) { for (const [module, level] of Object.entries(res.data)) {
@ -1305,7 +1490,14 @@ async function loadRealConfigForFilters() {
if (finalTreeData) { if (finalTreeData) {
container.style.display = 'block'; container.style.display = 'block';
// 🌟 統一渲染作法:先顯示 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); container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
document.body.style.cursor = 'default';
}, 20);
} else { } else {
throw new Error("未收到完整的配置資料"); throw new Error("未收到完整的配置資料");
} }
@ -1314,7 +1506,7 @@ async function loadRealConfigForFilters() {
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`; container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器 if (progressInterval) clearInterval(progressInterval);
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
} }
} }
@ -1374,7 +1566,7 @@ async function saveSystemSettings() {
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾 // 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = []; let allBackupsData = [];
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫) // 🌟 修正版:手動建立設備快照 (解決容量看板型別解析 Bug)
async function createSnapshot() { async function createSnapshot() {
if (!isConnected) { if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)"); return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
@ -1393,10 +1585,10 @@ async function createSnapshot() {
btn.disabled = true; btn.disabled = true;
msg.style.display = 'inline-block'; msg.style.display = 'inline-block';
msg.style.color = '#f39c12'; // 🌟 確保橘色 msg.style.color = '#f39c12'; // 確保橘色
msg.innerHTML = '⏳ 準備連線至設備...'; msg.innerHTML = '⏳ 準備連線至設備...';
let progressInterval = null; // 🌟 新增:進度條計時器 let progressInterval = null;
try { try {
const response = await fetch('/api/v1/backups/snapshot', { const response = await fetch('/api/v1/backups/snapshot', {
@ -1418,7 +1610,7 @@ async function createSnapshot() {
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let buffer = ""; let buffer = "";
let isSuccess = false; let isSuccess = false;
let finalMessage = ""; let finalData = null; // 🌟 修正:改為儲存完整的 JSON 物件,而非只有字串
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@ -1433,7 +1625,6 @@ async function createSnapshot() {
try { try {
const data = JSON.parse(line); const data = JSON.parse(line);
if (data.status === 'progress') { if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) { if (data.message.includes('正在下載')) {
let progress = 0; let progress = 0;
if (progressInterval) clearInterval(progressInterval); if (progressInterval) clearInterval(progressInterval);
@ -1462,7 +1653,7 @@ async function createSnapshot() {
} }
} else if (data.status === 'success') { } else if (data.status === 'success') {
isSuccess = true; isSuccess = true;
finalMessage = data.message; finalData = data; // 🌟 修正:儲存完整的成功資料物件 (包含 metrics)
} else if (data.status === 'error') { } else if (data.status === 'error') {
throw new Error(data.message); throw new Error(data.message);
} }
@ -1474,7 +1665,23 @@ async function createSnapshot() {
} }
if (isSuccess) { if (isSuccess) {
alert(`${finalMessage}`); // 🌟 修正:從 finalData 中安全提取 metrics並補齊所有預設值
const metrics = finalData?.metrics || { total: 0, pinned: 0, unpinned: 0, remaining: 20 };
const successMsg = finalData?.message || `快照 '${snapshotName}' 建立成功!`;
// 建立精美的容量看板 HTML 內容
const alertMsg =
`🎉 ${successMsg}\n\n` +
`📊 【當前設備備份容量看板】\n` +
`----------------------------------------\n` +
`🗄️ 總備份檔案數:${metrics.total}\n` +
`📌 已釘選保護數:${metrics.pinned}\n` +
`🔓 未釘選備份數:${metrics.unpinned}\n` +
`🔋 剩餘可用空間:${metrics.remaining} 筆 (上限 20 筆)\n\n` +
`💡 提示:當可用空間為 0 時,新備份將會自動淘汰最舊的未釘選備份。您可以點擊 📌 圖示來保護重要備份!`;
alert(alertMsg);
document.getElementById('snapshotName').value = ''; document.getElementById('snapshotName').value = '';
if (document.getElementById('snapshotDescription')) { if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = ''; document.getElementById('snapshotDescription').value = '';
@ -1484,7 +1691,7 @@ async function createSnapshot() {
} catch (error) { } catch (error) {
alert(`❌ 發生錯誤: ${error.message}`); alert(`❌ 發生錯誤: ${error.message}`);
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器 if (progressInterval) clearInterval(progressInterval);
btn.disabled = false; btn.disabled = false;
msg.style.display = 'none'; msg.style.display = 'none';
} }
@ -1500,7 +1707,7 @@ async function loadBackupHistory() {
if (!tbody) return; if (!tbody) return;
// 🌟 修正:將原本的灰色改為橘色並加粗 // 🌟 修正:將原本的灰色改為橘色並加粗
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
try { try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來 // 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
@ -1558,24 +1765,48 @@ window.applyBackupFilters = function() {
// 渲染過濾後的結果 // 渲染過濾後的結果
if (filteredData.length > 0) { if (filteredData.length > 0) {
tbody.innerHTML = filteredData.map(item => ` const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
const displayAdmin = isGodMode ? 'inline-block' : 'none';
tbody.innerHTML = filteredData.map(item => {
// 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
const pinIcon = item.is_pinned
? `<span onclick="togglePin('${item.id}')" style="cursor: pointer; font-size: 16px; display: inline-block; transform: scale(1.2); transition: transform 0.1s;" title="📌 已釘選保護(系統絕不自動淘汰),點擊取消保護">📌</span>`
: `<span onclick="togglePin('${item.id}')" class="unpinned-icon" style="cursor: pointer; font-size: 16px; opacity: 0.2; display: inline-block; transition: all 0.2s;" title="📍 點擊釘選保護,防止被滾動淘汰">📌</span>`;
return `
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'"> <tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
<!-- 1. 時間 -->
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td> <td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
<!-- 🟢 新增描述欄位加上防撐破設計與 title 提示 --> <!-- 2. 🌟 獨立的保護 (釘選) 欄位採水平與垂直置中對齊 -->
<td style="padding: 12px 10px; text-align: center; vertical-align: middle;">
${pinIcon}
</td>
<!-- 3. 快照名稱 (純文字完美對齊) -->
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">
${item.snapshot_name}
</td>
<!-- 4. 描述 -->
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}"> <td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'} ${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
</td> </td>
<!-- 5. 類型 -->
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td> <td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
<!-- 6. 操作 -->
<td style="padding: 12px 10px; text-align: right;"> <td style="padding: 12px 10px; text-align: right;">
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁 檢視</button> <button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁 檢視</button>
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態"> 還原</button> <button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態"> 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑 刪除</button> <button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑 刪除</button>
</td> </td>
</tr> </tr>
`).join(''); `;
}).join('');
} else { } else {
// 🟢 修正沒有資料時colspan 改為 5
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
} }
}; };
@ -1631,6 +1862,21 @@ async function deleteBackup(backupId) {
} }
} }
// 🌟 新增:切換釘選狀態
window.togglePin = async function(backupId) {
try {
const result = await apiToggleBackupPin(backupId);
if (result.status === 'success') {
// 重新載入歷史紀錄,即時更新 📌 狀態
await loadBackupHistory();
} else {
alert(`❌ 操作失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 連線失敗: ${error.message}`);
}
};
// 5. 請求設備差異分析 (安全還原第一步) // 5. 請求設備差異分析 (安全還原第一步)
// 🌟 接收第三個參數 backupHost // 🌟 接收第三個參數 backupHost
async function restoreBackup(snapshotId, snapshotName, backupHost) { async function restoreBackup(snapshotId, snapshotName, backupHost) {
@ -1895,45 +2141,123 @@ function closeModal() {
document.addEventListener("DOMContentLoaded", checkScanButtonState); document.addEventListener("DOMContentLoaded", checkScanButtonState);
// ========================================== // ==========================================
// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯 // 🛡️ 系統管理員安全解鎖邏輯 (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'];
document.addEventListener('DOMContentLoaded', () => { // 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
const appTitle = document.getElementById('app-title'); function unlockAdminFeatures() {
function unlockAdminFeatures() {
ADMIN_BTN_IDS.forEach(id => { ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
if (btn) btn.style.display = 'inline-block'; if (btn) btn.style.display = 'inline-block';
}); });
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
document.querySelectorAll('.debug-preview-btn').forEach(btn => { document.querySelectorAll('.debug-preview-btn').forEach(btn => {
btn.style.display = 'inline-block'; btn.style.display = 'inline-block';
}); });
document.querySelectorAll('.admin-only-action').forEach(btn => {
btn.style.display = 'inline-block';
});
}
document.addEventListener('DOMContentLoaded', () => {
const appTitle = document.getElementById('app-title');
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage關閉網頁即失效)
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
unlockAdminFeatures();
} }
const urlParams = new URLSearchParams(window.location.search); // 🌟 2. 連點標題觸發機制
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
let clickCount = 0; let clickCount = 0;
let clickTimer = null; let clickTimer = null;
if (appTitle) { if (appTitle) {
appTitle.addEventListener('click', () => { appTitle.addEventListener('click', () => {
// 已解鎖則忽略
if (sessionStorage.getItem('godModeUnlocked') === 'true') return;
clickCount++; clickCount++;
if (clickCount === 5) { if (clickCount === 5) {
unlockAdminFeatures(); openGodModeModal(); // 喚出密碼對話框
alert('🔓 已解鎖系統維護者模式!');
clickCount = 0; clickCount = 0;
} }
clearTimeout(clickTimer); clearTimeout(clickTimer);
clickTimer = setTimeout(() => { clickCount = 0; }, 1000); clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
}); });
} }
// 🌟 3. 綁定密碼框的 Enter 快捷鍵
const pwInput = document.getElementById('godModePassword');
if (pwInput) {
pwInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') window.verifyGodMode();
});
}
}); });
// 統一控制 4 顆按鈕的狀態 // 🌟 Modal 操作與 API 驗證邏輯
window.openGodModeModal = function() {
const modal = document.getElementById('godModeModal');
const input = document.getElementById('godModePassword');
const errorSpan = document.getElementById('god-mode-error');
errorSpan.textContent = '';
input.value = '';
input.classList.remove('shake-animation');
modal.classList.add('active');
setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦
};
window.closeGodModeModal = function() {
document.getElementById('godModeModal').classList.remove('active');
};
window.verifyGodMode = async function() {
const input = document.getElementById('godModePassword');
const errorSpan = document.getElementById('god-mode-error');
const btn = document.getElementById('btn-unlock-godmode');
const pwd = input.value.trim();
if (!pwd) {
errorSpan.textContent = '密碼不可為空!';
triggerShake(input);
return;
}
btn.disabled = true;
btn.innerHTML = '⏳ 驗證中...';
errorSpan.textContent = '';
input.classList.remove('shake-animation');
try {
await apiVerifyGodMode(pwd);
// 驗證成功
sessionStorage.setItem('godModeUnlocked', 'true');
closeGodModeModal();
alert('🔓 密碼正確!已解鎖系統維護者模式。');
unlockAdminFeatures();
} catch (err) {
// 驗證失敗
errorSpan.textContent = '❌ 密碼錯誤,請重新輸入';
triggerShake(input);
} finally {
btn.disabled = false;
btn.innerHTML = '🚀 解鎖';
}
};
function triggerShake(element) {
element.classList.remove('shake-animation');
void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發
element.classList.add('shake-animation');
element.focus();
}
// 統一控制 2 顆按鈕的狀態
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") { function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
ADMIN_BTN_IDS.forEach(id => { ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
@ -1941,19 +2265,17 @@ function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
btn.disabled = isBusy; btn.disabled = isBusy;
if (isBusy) { if (isBusy) {
btn.classList.add('is-busy'); btn.classList.add('is-busy');
if (id.includes('scan')) btn.innerText = text; if (id === 'btn-scan-options') btn.innerText = text;
} else { } else {
btn.classList.remove('is-busy'); btn.classList.remove('is-busy');
// 恢復原本的文字 if (id === 'btn-scan-options') btn.innerText = "🚀 掃描缺失選項";
if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失";
if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失";
} }
} }
}); });
} }
function checkScanButtonState() { function checkScanButtonState() {
const btn = document.getElementById('btn-scan-visible'); const btn = document.getElementById('btn-scan-options');
if (!btn) return; if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil'); const scanLockUntil = localStorage.getItem('scanLockUntil');
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) { if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
@ -1982,7 +2304,6 @@ function enableGlobalScanButton() {
} }
} }
// ============================================================================ // ============================================================================
// 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫) // 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫)
// ============================================================================ // ============================================================================
@ -2015,12 +2336,11 @@ window.loadMacDomainList = loadMacDomainList;
window.openSystemSettings = openSystemSettings; window.openSystemSettings = openSystemSettings;
window.loadRealConfigForFilters = loadRealConfigForFilters; window.loadRealConfigForFilters = loadRealConfigForFilters;
window.saveSystemSettings = saveSystemSettings; window.saveSystemSettings = saveSystemSettings;
window.scanVisibleMissingOptions = scanVisibleMissingOptions; window.scanMissingOptions = scanMissingOptions;
window.scanGlobalMissingOptions = scanGlobalMissingOptions; window.clearOptionsCache = clearOptionsCache;
window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI; window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; window.switchFilterMode = switchFilterMode;
window.toggleLogStream = toggleLogStream;
// --- 樹狀圖展開與編輯操作 --- // --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll; window.expandAll = expandAll;
@ -2047,3 +2367,4 @@ window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup; window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定! window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
window.togglePin = togglePin;

View File

@ -35,7 +35,21 @@ export let currentEditDiffs = [];
export async function releaseAllLocks() { export async function releaseAllLocks() {
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS); const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (keysToRelease.length === 0) return;
// 🌟 1. 檢查當前是否處於 God Mode 狀態
const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
// 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限
sessionStorage.removeItem('godModeUnlocked');
if (keysToRelease.length === 0) {
// 如果沒有正在編輯的節點,但剛剛是 God Mode依然要登出並重整
if (wasGodModeUnlocked) {
alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。");
location.reload();
}
return;
}
const releasePromises = keysToRelease.map(key => { const releasePromises = keysToRelease.map(key => {
const [host, path] = key.split('@@'); const [host, path] = key.split('@@');
@ -46,8 +60,8 @@ export async function releaseAllLocks() {
}); });
await Promise.all(releasePromises); await Promise.all(releasePromises);
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。"); alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。");
location.reload(); location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態
} }
async function sendHeartbeat(path, host, intervalId, lockKey) { async function sendHeartbeat(path, host, intervalId, lockKey) {
@ -72,18 +86,14 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
// ========================================== // ==========================================
export async function startEditFolder(path, elementId) { export async function startEditFolder(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return; if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖 activeEditSessions.add(elementId);
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
// 立即讓按鈕呈現讀取中,防止使用者焦慮連點
const editBtn = document.getElementById(`edit-btn-${elementId}`); const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) { if (editBtn) {
editBtn.style.pointerEvents = 'none'; editBtn.style.pointerEvents = 'none';
@ -93,13 +103,11 @@ export async function startEditFolder(path, elementId) {
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) { if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) { if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去 apiReleaseLock(path, SESSION_USER_ID, host);
} }
if (editBtn) { if (editBtn) {
editBtn.style.pointerEvents = 'auto'; editBtn.style.pointerEvents = 'auto';
@ -107,7 +115,7 @@ export async function startEditFolder(path, elementId) {
editBtn.title = "鎖定並編輯此項目"; editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️"; editBtn.innerText = "✏️";
} }
return; // 立即中斷,絕不觸碰 UI return;
} }
if (result.status === 409) { if (result.status === 409) {
@ -125,18 +133,15 @@ export async function startEditFolder(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId; ACTIVE_HEARTBEATS[lockKey] = intervalId;
// 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
if (!window.globalActiveLocks) window.globalActiveLocks = {}; if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {}; if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username }; window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
// 🌟 確保不在冷卻名單中 (防止手速過快)
if (window.recentlyReleasedLocks) { if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`]; delete window.recentlyReleasedLocks[`${host}@@${path}`];
} }
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:連同所有子節點一起秒級鎖定
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => { document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) { if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none'; btn.style.pointerEvents = 'none';
@ -160,27 +165,61 @@ export async function startEditFolder(path, elementId) {
const detailsEl = document.getElementById(`details-${elementId}`); const detailsEl = document.getElementById(`details-${elementId}`);
if (detailsEl) detailsEl.open = true; if (detailsEl) detailsEl.open = true;
const contentDiv = document.getElementById(`content-${elementId}`); // ==========================================
if (!contentDiv) { // 🌟 完美修復:在抓取葉子節點前,強制將該資料夾底下的所有 HTML 預先渲染出來
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`); // 這樣 querySelectorAll 就能一次抓到所有深層的欄位!
return; // ==========================================
if (typeof window.forceRenderFolderHTML === 'function') {
window.forceRenderFolderHTML(elementId);
} }
const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) return;
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {}; let optData = {};
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {} try { optData = await apiGetLeafOptions(host); } catch (e) {}
// 🚨 防護 3如果等待 API 期間使用者按了取消,立刻中斷後續渲染
if (!activeEditSessions.has(elementId)) return; if (!activeEditSessions.has(elementId)) return;
const pathsToSync = []; const pathsToSync = [];
const containersArray = Array.from(leafContainers); const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { // 🌟 第一階段:盤點缺少的快取
// 🚨 防護 4如果分批渲染期間使用者按了取消立刻中斷 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; if (!activeEditSessions.has(elementId)) return;
// 🌟 第三階段:正式渲染輸入框與 ❓
const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
if (!activeEditSessions.has(elementId)) return;
const chunk = containersArray.slice(i, i + CHUNK_SIZE); const chunk = containersArray.slice(i, i + CHUNK_SIZE);
chunk.forEach(container => { chunk.forEach(container => {
@ -192,8 +231,6 @@ export async function startEditFolder(path, elementId) {
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey]; let leafCache = optData[cacheKey];
if (!leafCache) pathsToSync.push(cacheKey);
let inputHtml = ""; let inputHtml = "";
let hintAttr = ""; let hintAttr = "";
let hintIcon = ""; let hintIcon = "";
@ -223,8 +260,6 @@ export async function startEditFolder(path, elementId) {
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
} }
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
} }
} catch (error) { } catch (error) {
activeEditSessions.delete(elementId); activeEditSessions.delete(elementId);
@ -237,7 +272,6 @@ export async function startEditFolder(path, elementId) {
} }
export async function startEditLeaf(path, elementId) { export async function startEditLeaf(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return; if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId); activeEditSessions.add(elementId);
@ -246,8 +280,6 @@ export async function startEditLeaf(path, elementId) {
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const editBtn = document.getElementById(`edit-btn-${elementId}`); const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) { if (editBtn) {
editBtn.style.pointerEvents = 'none'; editBtn.style.pointerEvents = 'none';
@ -257,10 +289,8 @@ export async function startEditLeaf(path, elementId) {
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) { if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) { if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host); apiReleaseLock(path, SESSION_USER_ID, host);
@ -314,30 +344,30 @@ export async function startEditLeaf(path, elementId) {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
// 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22)
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`; container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
try { try {
let optData = await apiGetLeafOptions(host, currentMode); let optData = await apiGetLeafOptions(host);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey]; let leafCache = optData[cacheKey];
if (!leafCache) { if (!leafCache) {
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass); apiSyncLeafOptions(host, [cacheKey]);
let found = false; let found = false;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
if (!activeEditSessions.has(elementId)) return; // 🚨 防護 if (!activeEditSessions.has(elementId)) return;
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host, currentMode); optData = await apiGetLeafOptions(host);
leafCache = optData[cacheKey]; leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
found = true; break; found = true; break;
} }
} }
} }
if (!activeEditSessions.has(elementId)) return; // 🚨 防護 if (!activeEditSessions.has(elementId)) return;
let inputHtml = ""; let inputHtml = "";
let hintAttr = ""; let hintAttr = "";
@ -365,7 +395,7 @@ export async function startEditLeaf(path, elementId) {
} }
container.innerHTML = inputHtml; container.innerHTML = inputHtml;
} catch (e) { } 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;">`; 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;">`;
} }
} }
@ -875,7 +905,7 @@ async function executeGeneratedCLI(script) {
logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`; logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight; if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
} }
}, currentMode); });
// 🌟 修正 3: 再觸發後端同步任務 // 🌟 修正 3: 再觸發後端同步任務
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass); apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);

View File

@ -127,7 +127,7 @@ button:hover { background-color: #3498db; }
.form-actions button { padding: 8px 20px; font-size: 14px; min-width: 120px; } .form-actions button { padding: 8px 20px; font-size: 14px; min-width: 120px; }
/* 8. 彈出式輸出視窗 (Modal) 樣式 */ /* 8. 彈出式輸出視窗 (Modal) 樣式 */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; backdrop-filter: blur(3px); } .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); } /* 🌟 新增這行支援 Safari */
.modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; } .modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; }
.modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; } .modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; }
.modal-overlay.active .modal-container { transform: translateY(0); } .modal-overlay.active .modal-container { transform: translateY(0); }
@ -168,6 +168,7 @@ button:hover { background-color: #3498db; }
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
padding: 6px 8px; padding: 6px 8px;
-webkit-user-select: none; /* 🌟 新增這行支援 Safari */
user-select: none; user-select: none;
color: #2c3e50; /* 保持原本的深色文字 */ color: #2c3e50; /* 保持原本的深色文字 */
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@ -272,3 +273,70 @@ button:hover { background-color: #3498db; }
} }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* =========================================
🚨 密碼輸入錯誤震動動畫 (Shake Effect)
========================================= */
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
20%, 40%, 60%, 80% { transform: translateX(6px); }
}
.shake-animation {
animation: shake 0.4s ease-in-out;
border-color: #e74c3c !important; /* 震動時外框變紅 */
background-color: #fdedec !important;
}
/* =========================================
🌟 效能優化大型 DOM 樹渲染隔離
========================================= */
.tree-folder-content {
/* 讓不在畫面內的子節點跳過渲染計算,極大提升效能 */
content-visibility: auto;
/* 給予一個預設高度,避免捲軸在捲動時瘋狂跳動 */
contain-intrinsic-size: 0 24px;
}
.filter-children-container {
content-visibility: auto;
contain-intrinsic-size: 0 24px;
}
/* ============================================================================
🎙 系統實時日誌終端機 - 滾動條隔離優化 (Scrollbar Isolation - 修正版)
============================================================================ */
#log-terminal-container .xterm {
width: 100% !important;
}
#log-terminal-container .xterm-screen {
/* 🌟 關鍵:限制文字區最大寬度,扣除右側垂直滾動條的寬度 */
max-width: calc(100% - 18px) !important;
/* 🌟 關鍵:只允許水平滾動,強制關閉垂直滾動,消除多餘的藍灰色軌道 */
overflow-x: auto !important;
overflow-y: hidden !important;
}
/* 讓水平滾動條呈現精美的扁平化現代風格 */
#log-terminal-container .xterm-screen::-webkit-scrollbar {
height: 8px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 4px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }

View File

@ -31,6 +31,20 @@ function colorizeTerminalStream(text) {
// 4. 匯出 (export) 所有需要被外部呼叫的函數 // 4. 匯出 (export) 所有需要被外部呼叫的函數
export function initTerminal() { 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 }); term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 });
fitAddon = new FitAddon.FitAddon(); fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
@ -51,6 +65,11 @@ export function connectWebSocket(resetCallback) {
if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數 if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數
// 🌟 [修復] 切換設備時,徹底清空終端機舊有畫面與緩衝區
if (term) {
term.clear();
}
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`); term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`; const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
@ -65,8 +84,9 @@ export function connectWebSocket(resetCallback) {
btnLoadTask.style.backgroundColor = '#c0392b'; btnLoadTask.style.backgroundColor = '#c0392b';
btnLoadTask.style.cursor = 'pointer'; btnLoadTask.style.cursor = 'pointer';
} }
document.getElementById('wsStatus').textContent = `狀態:已連線`; // 🌟 UX 升級:剛連上 WS 時,顯示「驗證中...」而不是直接顯示「已連線」
document.getElementById('wsStatus').style.color = '#27ae60'; document.getElementById('wsStatus').textContent = `狀態:連線與驗證中...`;
document.getElementById('wsStatus').style.color = '#f39c12'; // 橘色
document.getElementById('btnConnect').style.display = 'none'; document.getElementById('btnConnect').style.display = 'none';
document.getElementById('btnDisconnect').style.display = 'inline-block'; document.getElementById('btnDisconnect').style.display = 'inline-block';
document.getElementById('cmtsHost').disabled = true; document.getElementById('cmtsHost').disabled = true;
@ -74,14 +94,26 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsPass').disabled = true; document.getElementById('cmtsPass').disabled = true;
term.focus(); term.focus();
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist // 🌟 原有:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
const queryTaskSelect = document.getElementById('queryTask'); const queryTaskSelect = document.getElementById('queryTask');
if (queryTaskSelect) { if (queryTaskSelect) {
queryTaskSelect.dispatchEvent(new Event('change')); queryTaskSelect.dispatchEvent(new Event('change'));
} }
// 🌟 新增:連線成功後,強制觸發配置任務切換,藉此啟動「正確 IP」的 SSE 監聽
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
// 🌟 UX 升級:只要收到設備傳來的第一個字元,就代表 SSH 驗證成功,正式轉為綠色「已連線」
const statusEl = document.getElementById('wsStatus');
if (statusEl.textContent.includes('驗證中')) {
statusEl.textContent = `狀態:已連線`;
statusEl.style.color = '#27ae60'; // 綠色
}
term.write(colorizeTerminalStream(event.data)); term.write(colorizeTerminalStream(event.data));
}; };
@ -102,6 +134,17 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsPass').disabled = false; document.getElementById('cmtsPass').disabled = false;
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n'); term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
// 🌟 關鍵修復:攔截後端傳來的 4001 錯誤碼,彈出明確的警告視窗
if (event.code === 4001) {
alert(`❌ 連線失敗!\n請檢查目標設備 IP、帳號與密碼是否正確。\n\n系統訊息: ${event.reason}`);
}
// 🌟 新增斷線時強制觸發配置任務切換藉此「關閉」SSE 監聽
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
if (resetCallback) resetCallback(); if (resetCallback) resetCallback();
}; };
} }

View File

@ -29,12 +29,19 @@ window.lazyLoadFolder = function(elementId, isFilter = false) {
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId]; const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
if (contentDiv && cache) { if (contentDiv && cache) {
// 🌟 統一渲染作法:單點展開也套用非同步渲染保護
contentDiv.innerHTML = `<span style="color: #f39c12; font-size: 13px; font-weight: bold; margin-left: 5px;">⏳ 載入中...</span>`;
document.body.style.cursor = 'wait';
setTimeout(() => {
if (isFilter) { if (isFilter) {
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode); contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
} else { } else {
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode); contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
} }
detailsEl.dataset.loaded = 'true'; // 標記為已載入 detailsEl.dataset.loaded = 'true'; // 標記為已載入
document.body.style.cursor = 'default';
}, 10);
} }
}; };
@ -43,42 +50,87 @@ window.lazyLoadFolder = function(elementId, isFilter = false) {
// ========================================== // ==========================================
export function expandAll(elementId) { export function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`); const details = document.getElementById(`details-${elementId}`);
if (details) { if (!details) return;
// 如果還沒載入,先強制載入它
if (!details.dataset.loaded) {
const isFilter = details.id.includes('filter-'); const isFilter = details.id.includes('filter-');
window.lazyLoadFolder(elementId, isFilter); 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; details.open = true;
// 找出剛剛渲染出來的第一層子資料夾,遞迴展開 const iconClosed = details.querySelector('.icon-closed');
const contentDiv = details.querySelector(':scope > div'); const iconOpen = details.querySelector('.icon-open');
if (contentDiv) { if (iconClosed) iconClosed.style.display = 'none';
const childDetails = contentDiv.querySelectorAll(':scope > details'); if (iconOpen) iconOpen.style.display = 'inline-block';
childDetails.forEach(d => {
const subId = d.id.replace('details-', ''); document.body.style.cursor = 'default';
expandAll(subId); // 遞迴展開 }, 20);
});
}
}
} }
export function collapseAll(elementId) { export function collapseAll(elementId) {
const details = document.getElementById(`details-${elementId}`); const details = document.getElementById(`details-${elementId}`);
if (details) { if (details) {
const subDetails = details.querySelectorAll('details'); // 🌟 效能優化:只尋找目前是 open 狀態的 details 進行關閉,減少 DOM 操作
subDetails.forEach(d => d.open = false); 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; 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 版) // 🌟 核心函數:生成完整設備配置樹狀圖 (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 = []; const htmlParts = [];
if (!node || typeof node !== 'object') return htmlParts.join(''); 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)) { for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key; const currentPath = path ? `${path}::${key}` : key;
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); 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 elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : ''; const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
// 🌟 將資料存入快取,供延遲渲染使用
folderDataCache[elementId] = value; 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 iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen; 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 = ` const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;"> <span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')" <span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
@ -118,7 +161,6 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
`; `;
// 🌟 延遲渲染防護:取得當前設備 IP並檢查此節點是否已被鎖定 (跨設備隔離)
const currentHost = document.getElementById('cmtsHost')?.value.trim(); const currentHost = document.getElementById('cmtsHost')?.value.trim();
let isLocked = false; let isLocked = false;
let lockTitle = "鎖定並編輯此群組"; let lockTitle = "鎖定並編輯此群組";
@ -128,37 +170,37 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) { if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost]; const hostLocks = window.globalActiveLocks[currentHost];
// 1. 檢查自己是否被鎖定
if (hostLocks[currentPath]) { if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath]; const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true; isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockText = "🔒"; lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
: `🔒 您正在另一個視圖編輯此項目`;
} }
} else { } else {
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) { for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) { if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true; isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockText = "🔒"; lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
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(` htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
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);"> ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
<summary class="tree-folder-header" <summary class="tree-folder-header"
title="${cliCommand}" title="${cliCommand}"
@ -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;"> 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 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-closed" style="display: ${displayClosed};">${iconClosed}</span>
<span class="icon-open" style="display: none;">${iconOpen}</span> <span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
</span> </span>
<b style="color: #2c3e50;">${key}</b>${folderSuffix} <b style="color: #2c3e50;">${key}</b>${folderSuffix}
${expandCollapseBtns} ${expandCollapseBtns}
@ -194,7 +236,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</div> </div>
</summary> </summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;"> <div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
<!-- 🌟 延遲渲染這裡一開始是空的展開時才會填入 --> ${innerContent}
</div> </div>
</details> </details>
`); `);
@ -209,13 +251,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`; 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 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);`; 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(); const currentHost = document.getElementById('cmtsHost')?.value.trim();
let isLocked = false; let isLocked = false;
let lockTitle = "鎖定並編輯此項目"; let lockTitle = "鎖定並編輯此項目";
@ -225,29 +263,17 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) { if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost]; const hostLocks = window.globalActiveLocks[currentHost];
// 1. 檢查自己是否被鎖定
if (hostLocks[currentPath]) { if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath]; const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) { if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true; isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockText = "🔒"; lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
: `🔒 您正在另一個視圖編輯此項目`;
} }
} else { } else {
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) { for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) { if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true; isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockText = "🔒"; lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
break;
} }
} }
} }
@ -318,12 +344,21 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
// ========================================== // ==========================================
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版) // 🌟 核心函數:生成系統設定的過濾樹狀圖 (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 ''; if (typeof data !== 'object' || data === null) return '';
const htmlParts = []; const htmlParts = [];
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`); 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) { for (const key in data) {
const value = data[key]; const value = data[key];
const currentPath = parentPath ? `${parentPath}::${key}` : 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, ''); 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 isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)'; 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;">`; 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 isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
// 🌟 存入過濾器專用快取
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys }; filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed; const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen; const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : ''; const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
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 = ` const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;"> <span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')" <span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
@ -371,23 +396,33 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
</span> </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(` htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" <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);"> ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;"> <summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style> <style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
${checkboxHtml} ${checkboxHtml}
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;"> <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-closed" style="display: ${displayClosed};">${iconClosed}</span>
<span class="icon-open" style="display: none;">${iconOpen}</span> <span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
</span> </span>
<span>${key}${folderSuffix}</span> <span>${key}${folderSuffix}</span>
${expandCollapseBtns} ${expandCollapseBtns}
</summary> </summary>
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;"> <div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
<!-- 🌟 延遲渲染 --> ${innerContent}
</div> </div>
</details> </details>
`); `);
@ -431,3 +466,26 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
htmlParts.push('</div>'); htmlParts.push('</div>');
return htmlParts.join(''); return htmlParts.join('');
} }
// ==========================================
// 🌟 輔助函數:強制預先渲染資料夾 HTML (供編輯模式使用)
// ==========================================
window.forceRenderFolderHTML = function(elementId) {
const detailsEl = document.getElementById(`details-${elementId}`);
const contentDiv = document.getElementById(`content-${elementId}`);
const cache = folderDataCache[elementId];
// 🌟 關鍵修復:移除 detailsEl.dataset.loaded !== 'true' 的限制!
// 因為如果使用者先手動展開了父資料夾,再點擊編輯,
// 原本的邏輯會因為 loaded === true 而拒絕往下遞迴渲染子資料夾,
// 導致子資料夾內的項目沒有被轉成輸入框。
if (detailsEl && contentDiv && cache) {
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';
}
};

File diff suppressed because it is too large Load Diff