diff --git a/.continue/rules/new-rule.md b/.continue/rules/new-rule.md index 7f59187..d9b24ad 100644 --- a/.continue/rules/new-rule.md +++ b/.continue/rules/new-rule.md @@ -1,51 +1,71 @@ -# Harmonic CMTS Manager - AI 開發守則與架構白皮書 +# Harmonic CMTS Manager - AI Architect Guidelines & System Blueprint -## 1. 專案架構概覽與技術棧 -- **定位**: 專為有線電視網路終端設備 (CMTS) 設計的企業級 Web 管理系統。 -- **後端**: Python 3.10+, FastAPI (Async-first), AsyncSSH, Netmiko, asyncpg. -- **前端**: 原生 Vanilla JS (ES Modules), HTML5, CSS3, Xterm.js. -- **資料庫**: PostgreSQL (主要), JSON File Cache (高可用性降級備援). +> **⚠️ AI 角色與絕對約束 (AI Persona & Absolute Directives)** +> 你現在是一位資深的系統架構師與全端工程師。 +> 1. **唯一真理**:絕對禁止自行幻想任何不存在的模組、變數或第三方套件。所有修改必須基於現有架構。 +> 2. **語言規範**:註解、對話與 Git Commit 一律使用**繁體中文 (zh-TW)**。 +> 3. **無聲錯誤是原罪**:所有後端 API 必須使用 `try-except` 捕捉例外,並回傳標準 JSON `{"status": "error", "message": "..."}`,嚴禁 FastAPI 直接拋出 HTTP 500 導致前端崩潰。 -### 📂 目錄與檔案結構 -- **進入點**: `main.py` -- **路由管理**: API 路由統一放置於 `routers/` 目錄。包含 backup.py, config.py, leaf_options.py, lock.py, query.py, terminal.py。 -- **前端介面**: `index.html` 與 `static/` 目錄。 包含 api.js, app.js, edit-mode.js, mac-domain.js, style.css, terminal.js, tree-ui.js, utils.js。 -- **核心邏輯**: - - `cmts_scraper.py`: 負責底層爬蟲與資料處理。 - - `shared.py`: 放置共用函式 (如兩階段解析法)。 - - `config.py`: 配置轉譯器 (CLI Generator)。 -- **資料庫**: 連線與 ORM 邏輯在 `database.py`,初始化腳本為 `init_db.py`。 -- **併發控制**: `lock.py` (路徑階層鎖), `leaf_options.py` (SSE 頻道分流)。 +--- -## 2. 核心架構與業務邏輯 (Architecture & Logic) -- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。 -- **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `BATCH_SIZE = 30` 搭配 `asyncio.sleep()` 進行非同步批次處理。 -- **絕對路徑與 Commit 機制 (Absolute Path)**: Harmonic CMTS 支援從 Global 模式直接寫入帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 ...`),無須層層進入子模式。所有變更指令發送完畢後,必須執行 `commit` 才能生效。 -- **智慧差異還原 (Smart Diff Recovery)**: 設備還原不採用盲目覆蓋,而是透過深度比對 (Deep Diff) 當前設備狀態與備份快照,動態生成包含 `no` 的反向刪除指令與新增/修改指令,確保配置精準還原且無殘留。 -- **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。 -- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。 +## 🏗️ 1. 系統總體架構 (System Architecture) -## 3. 🚨 AI 開發絕對約束 (Directives for AI) +本系統為專為 Harmonic CableOS 設計的企業級 Web 管理介面,採用前後端分離架構,並透過 WebSocket 與 SSE 實現即時雙向通訊。 -### ⚙️ 系統與環境規範 -1. **套件管理**: 若需安裝新套件,請提醒我手動在 `cmts_api_env` 中安裝,並更新 `requirements.txt`。 -2. **資料讀取限制**: 請勿隨意讀取 `*_cache.json` 檔案的內容,若需了解資料結構,請參考 `cmts_scraper.py` 中的定義。 -3. **狀態同步**: 完成重大修改或一個 Phase 後,必須主動更新 `PROJECT_STATE.md` 記錄最新進度與待辦事項。 +* **後端 (Backend)**: Python 3.10+, FastAPI (Async-first), `asyncssh` (核心連線引擎), `asyncpg` (資料庫連線池)。 +* **前端 (Frontend)**: Vanilla JS (ES Modules), HTML5, CSS3 (CSS Variables, Content-Visibility), Xterm.js, Chart.js。 +* **資料庫 (Database)**: PostgreSQL (`cmts_nms` 資料庫)。 -### 💻 程式碼風格與後端規範 -4. **語言與風格**: 註解與對話請一律使用**繁體中文**。Python 程式碼請遵循 PEP8 規範,並加上適當的 Type Hints (型別提示)。 -5. **Async-First (非同步絕對優先)**: 嚴禁使用阻塞的同步 I/O。同步函數必須封裝進 `await asyncio.to_thread()`。 -6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。 -7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。 -8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。 -9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`。 -10. **精準 Prompt 偵測**: 透過 SSH 讀取設備回傳時,嚴禁盲目依賴 `timeout`。必須在 `read_until_quiet` 中傳入 `prompt_pattern` (如 `r"\(config\)#"` 或 `r"(?:#|>)"`) 進行正規表達式匹配,以最高速釋放資源。 +--- -### 🎨 前端規範 -11. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。 -12. **UI 狀態安全還原**: 動態修改 DOM 的 CSS 屬性(如 `flex`, `width`)後,若需恢復原狀,嚴禁寫死預設值,必須將屬性設為空字串 `''`,讓瀏覽器自然退回 HTML 定義的樣式,避免改 A 壞 B。 -13. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時,必須透過 SSE 或 ReadableStream 即時回報進度。 -14. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy(保留策略/定期清理機制),不可只寫入不刪除。 -15. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。 -16. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()` 與 `?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。 +## 🧩 2. 模組化切割與檔案關聯 (Module Map) + +### 🟢 後端核心模組 (Backend Core) +* `main.py`: 系統進入點。負責掛載靜態檔案、初始化 DB Pool (`lifespan`),並將所有 API 路由統一掛載於 `/api/v1` 前綴之下。 +* `database.py`: PostgreSQL 非同步連線池管理。嚴格區分 `running` 與 `full` 的 `config_type` 雙軌隔離設計。 +* `cmts_scraper.py`: 底層 SSH 爬蟲引擎。負責發送 `?` 探測設備選項、解析終端機分頁 (`--More--`),並清理 ANSI 控制碼。 +* `shared.py`: 純邏輯共用區。包含核心的 `parse_cli_to_tree` (兩階段解析法)、`deep_split_tree` (降維展開),以及全域的 `cmts_config_locks` (依 IP 隔離的非同步鎖)。 +* `logger.py`: 具備 ANSI 色彩的自訂日誌系統,支援透過 API 動態調整各模組的 Log Level。 + +### 🔵 後端路由模組 (Routers - `/routers/`) +* `config.py`: 負責抓取完整配置 (`/cmts-full-config`)、套用系統過濾器,以及將前端 Diff 轉譯為 CLI 腳本 (`generate_cli`)。 +* `leaf_options.py`: 負責選項快取的背景掃描,並透過 `asyncio.Queue` 實作 SSE (Server-Sent Events) 頻道分流,即時推播掃描進度。 +* `lock.py`: 實作 In-Memory 的路徑階層鎖 (`ACTIVE_LOCKS`),支援 Heartbeat 續命與過期自動清理。 +* `backup.py`: 設備快照與還原中心。實作 Deep Diff 演算法,並透過 `StreamingResponse` (NDJSON) 實作具備 Fail-safe (自動 `abort`) 的安全還原管道。 +* `query.py`: 處理標準 `show` 指令查詢,以及 MAC Domain 的互動式解析。 +* `diagnostics.py`: 深度解析 CM 狀態,包含 PHY 功率、SNR 與 OFDM MER 陣列。 +* `terminal.py`: WebSocket 代理,將前端 Xterm.js 的輸入轉發至 `asyncssh` 的 PTY。 + +### 🟡 前端模組 (Frontend - `/static/`) +* `app.js`: 主協調器 (Orchestrator)。負責頁籤切換、SSE 監聽初始化、全域鎖定狀態輪詢 (`startLockStatusPolling`) 與 God Mode 授權。 +* `api.js`: 純粹的 Fetch API 封裝層,負責與後端 `/api/v1` 溝通。 +* `tree-ui.js`: **效能核心**。負責將 JSON 轉換為 HTML 樹狀圖。採用「記憶體遞迴渲染」與「延遲載入 (`lazyLoadFolder`)」。 +* `edit-mode.js`: 編輯狀態機。處理鎖定獲取、UI 狀態切換 (✏️ -> 🔒 -> ⏳)、生成 Diff 陣列,以及右側 CLI 預覽面板的控制。 +* `mac-domain.js`: 獨立的 MAC Domain 狀態感知配置精靈邏輯。 +* `terminal.js`: Xterm.js 實例化、WebSocket 連線管理與終端機字串上色 (`colorizeTerminalStream`)。 + +--- + +## 🚀 3. 核心演算法與開發規範 (Core Mechanisms & Rules) + +### ⚡ 3.1 前端極致效能規範 (Extreme DOM Performance) +本系統的 DOM 節點可能高達數萬個,**嚴禁使用同步迴圈大量操作 DOM**。 +1. **記憶體遞迴渲染 (In-Memory Rendering)**:在 `tree-ui.js` 中,必須先在 JS 記憶體中將 HTML 字串完全組裝完畢,最後只執行 **1 次** `innerHTML` 寫入。 +2. **非同步 UI 保護**:任何大型渲染(如展開全部、初始載入),必須先顯示 `⏳ 載入中...` 並將游標設為 `wait`,接著使用 `setTimeout(..., 20)` 讓出主執行緒,確保瀏覽器不卡死。 +3. **CSS 渲染隔離**:依賴 `style.css` 中的 `content-visibility: auto;`,嚴禁在 JS 中破壞 `.tree-folder-content` 的結構。 + +### 🔒 3.2 併發與鎖定機制 (Concurrency & Locking) +1. **IP 隔離原則**:所有的鎖定 Key 必須是 `host@@path` 格式,確保不同設備間的鎖定互不干擾。 +2. **父子階層鎖 (Cascading Lock)**:前端在輪詢鎖定狀態時,必須檢查「自身」、「父節點」與「子節點」的鎖定衝突。 +3. **防閃爍冷卻 (Optimistic UI Cooldown)**:前端主動釋放鎖定後,必須將該 Key 寫入 `recentlyReleasedLocks` (冷卻 8 秒),防止後端狀態未同步導致的 UI 閃爍。 + +### 🛡️ 3.3 設備寫入與安全還原 (Safe SSH Execution) +1. **絕對路徑策略 (Absolute Path)**:寫入設備時,一律生成帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 admin-state down`),嚴禁依賴傳統的層層進入模式。 +2. **Fail-safe 撤銷機制**:在 `backup.py` 與 `config.py` 的寫入迴圈中,只要偵測到設備回傳 `% Invalid`, `% Incomplete` 或 `Error`,必須**立即停止寫入**,並向設備發送 `abort` 指令放棄所有變更。 +3. **精準 Prompt 偵測**:使用 `asyncssh` 讀取輸出時,嚴禁盲目等待 Timeout。必須在 `read_until_quiet` 中傳入精準的 `prompt_pattern` (如 `r"\(config.*\)#"` 或 `r"(?:#|>)"`)。 + +### 🌳 3.4 樹狀圖解析與資料結構 (Tree Parsing) +1. **兩階段解析**:`shared.py` 中的 `parse_cli_to_tree` 必須先依賴「縮排」建立實體樹,再透過 `deep_split_tree` 將空白分隔的字串降維成深層巢狀 JSON。 +2. **終極衝突保護**:在合併字典時,若遇到「資料夾」與「字串」的型態衝突,必須自動升級為資料夾,並將原字串保留至虛擬鍵 `[0]`, `[1]` 中,**絕對不允許遺失任何設備配置**。 +3. **雙軌記憶體**:前端 `window.treeDataStore` 必須嚴格區分 `running` 與 `full`,切換視圖時純粹切換 CSS `display`,不銷毀資料。 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b2caf2a --- /dev/null +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6109495..f2e2c84 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ cmts_api_env/ *_cache.json filters_*.json *.bk + +# Environments +.env +.env.* +!.env.example diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 390b7ea..1b5f0a2 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -6,37 +6,54 @@ ## ✅ 已完成開發階段 (Completed Phases) -### Phase 1: PostgreSQL 高可用性架構升級 (Completed) +### Phase 1: PostgreSQL 高可用性架構升級 - [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。 - [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。 - [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。 -- [x] 調整 `init_db.py` 為非同步啟動腳本。 -### Phase 2: 設備配置備份與快照機制 (Completed) +### Phase 2: 設備配置備份與快照機制 - [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。 -- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。 -- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。 -- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。 +- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表。 +- [x] **前端 UI 優化與防禦性編程**:實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。 -### Phase 3: 智慧差異還原與歷史預覽 (Completed) -- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。 +### Phase 3: 智慧差異還原與歷史預覽 - [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。 - [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。 -- [x] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 `ReadableStream` 即時渲染後端傳來的 SSH 逐行執行 Log,並具備自動捲動功能。 -- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**: - - 實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應)。 - - 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止、發送 `abort` 撤銷變更,並回傳錯誤 Chunk。 -- [x] **效能與體驗優化**:完成 DOM 陣列渲染優化、Terminal 資源回收 (防殭屍連線)、精準 Prompt 偵測,以及 UI 視窗連動防呆機制。 +- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應),並具備 Fail-safe `abort` 撤銷機制。 + +### Phase 3.5: 企業級前端效能重構 (Extreme Performance Optimization) +- [x] **記憶體遞迴渲染 (In-Memory Recursive Rendering)**:徹底重構 `tree-ui.js`,將數千次 DOM 寫入壓縮為單次 `innerHTML` 寫入,解決「展開全部」導致瀏覽器卡死的問題。 +- [x] **非同步 UI 保護機制**:在所有大型渲染場景 (初始載入、單點展開、全部展開) 導入 `setTimeout` 讓出主執行緒,並搭配沙漏游標與橘色讀取提示,確保 UI 絕對滑順。 +- [x] **CSS 渲染隔離**:導入 `content-visibility: auto`,讓不在可視範圍內的 DOM 節點暫停渲染計算。 +- [x] **精準 DOM 查詢**:將鎖定狀態輪詢 (`startLockStatusPolling`) 的搜尋範圍限縮於當前啟用的視圖內,消除全域搜尋造成的卡頓 (Jank)。 --- -## 🚧 目前開發階段 (Current Phase) +## 🚀 即將到來的里程碑 (Upcoming Milestones Summary) -### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management) -- [ ] **自動備份攔截 (Auto-Backup Interceptor)**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制,確保每次變更前都有還原點。 -- [ ] **備份保留策略 (Retention Policy)**:實作定期清理過期或過多歷史快照的機制,防止資料庫無限膨脹。 +### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management) +- [x] **智能視覺診斷 (Visual Diagnostics)**: + - CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。 +- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**: + - 重新 Review `generate_diff_commands` 演算法。 + - 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。 +- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)**: + - 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。 + - 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。 +- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)**: + - **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。 + - **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。 + +### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard) +- [ ] **RPD 樣板克隆引擎 (Template Cloning)**: + - 於「MAC Domain 狀態感知配置精靈」中新增 RPD 擴容模式。允許使用者選擇現有設備上已配置完成的 RPD (支援 FDX, FDD, D3.1+) 作為基準樣板。 +- [ ] **Tree 節點複製與參數替換 (Node Duplication & Modification)**: + - 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。 +- [ ] **安全寫入與校驗 (Safe Provisioning)**: + - 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。 --- -## 🐛 已知問題與未來計畫 (Known Issues & Backlog) -- 目前系統運行穩定,各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。 +## 🐛 已知問題與技術債 (Known Issues & Tech Debt) +- 目前系統運行極度穩定,前端效能瓶頸已徹底消除,各項併發鎖定與 UI 狀態連動皆已完善。準備進入 Phase 4 的 Diff 引擎強化開發。 + diff --git a/all_code.txt b/all_code.txt index 550aad8..b365aac 100644 --- a/all_code.txt +++ b/all_code.txt @@ -64,148 +64,95 @@ async def get_pool() -> Optional[asyncpg.Pool]: # CRUD Functions for cmts_options # ------------------------------------------ -async def upsert_leaf_option(host: str, config_type: str, path: str, data: dict) -> bool: - """將選項寫入或更新至資料庫""" +async def upsert_leaf_option(host: str, path: str, data: dict) -> bool: pool = await get_pool() - if not pool: - return False - + if not pool: return False query = """ - INSERT INTO cmts_options (host, config_type, path, data) - VALUES ($1, $2, $3, $4) - ON CONFLICT (host, config_type, path) + INSERT INTO cmts_options (host, path, data) + VALUES ($1, $2, $3) + ON CONFLICT (host, path) DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP; """ try: async with pool.acquire() as conn: - await conn.execute(query, host, config_type, path, json.dumps(data)) + await conn.execute(query, host, path, json.dumps(data)) return True - except asyncpg.PostgresError as e: + except Exception as e: logger.error(f"❌ DB Error (upsert_leaf_option): {e}") return False - except Exception as e: - logger.error(f"❌ Unknown Error (upsert_leaf_option): {e}") - return False -async def get_all_leaf_options(host: str, config_type: str) -> Optional[Dict[str, Any]]: - """取得特定 host 與 config_type 的所有選項""" +async def get_all_leaf_options(host: str) -> Optional[Dict[str, Any]]: pool = await get_pool() - if not pool: - return None - - query = """ - SELECT path, data FROM cmts_options - WHERE host = $1 AND config_type = $2; - """ + if not pool: return None + query = "SELECT path, data FROM cmts_options WHERE host = $1;" try: async with pool.acquire() as conn: - records = await conn.fetch(query, host, config_type) + records = await conn.fetch(query, host) result = {} for record in records: - # asyncpg returns strings for JSON if not explicitly configured with type mapping, - # but usually it's fine to just json.loads it. data_val = record['data'] - if isinstance(data_val, str): - result[record['path']] = json.loads(data_val) - else: - result[record['path']] = data_val + result[record['path']] = json.loads(data_val) if isinstance(data_val, str) else data_val return result - except asyncpg.PostgresError as e: + except Exception as e: logger.error(f"❌ DB Error (get_all_leaf_options): {e}") return None - except Exception as e: - logger.error(f"❌ Unknown Error (get_all_leaf_options): {e}") - return None -async def delete_leaf_options(host: str, config_type: str, paths: List[str]) -> int: - """刪除特定路徑的選項快取""" +async def delete_leaf_options(host: str, paths: List[str]) -> int: pool = await get_pool() - if not pool or not paths: - return -1 - - query = """ - DELETE FROM cmts_options - WHERE host = $1 AND config_type = $2 AND path = ANY($3); - """ + if not pool or not paths: return -1 + query = "DELETE FROM cmts_options WHERE host = $1 AND path = ANY($2);" try: async with pool.acquire() as conn: - # Execute returns the command tag, e.g., 'DELETE 5' - status = await conn.execute(query, host, config_type, paths) - deleted_count = int(status.split()[-1]) - return deleted_count - except asyncpg.PostgresError as e: - logger.error(f"❌ DB Error (delete_leaf_options): {e}") - return -1 + status = await conn.execute(query, host, paths) + return int(status.split()[-1]) except Exception as e: - logger.error(f"❌ Unknown Error (delete_leaf_options): {e}") + logger.error(f"❌ DB Error (delete_leaf_options): {e}") return -1 # ------------------------------------------ # CRUD Functions for device_status # ------------------------------------------ -async def upsert_device_status(host: str, config_type: str, metadata: dict) -> bool: - """更新設備的 metadata""" +async def upsert_device_status(host: str, metadata: dict) -> bool: pool = await get_pool() - if not pool: - return False - + if not pool: return False cmts_version = metadata.get("cmts_version") last_scanned = metadata.get("last_scanned", None) - try: async with pool.acquire() as conn: - # 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號! if cmts_version and cmts_version != "unknown": query = """ - INSERT INTO device_status (host, config_type, cmts_version, last_scanned) - VALUES ($1, $2, $3, $4) - ON CONFLICT (host, config_type) - DO UPDATE SET - cmts_version = EXCLUDED.cmts_version, - last_scanned = EXCLUDED.last_scanned, - updated_at = CURRENT_TIMESTAMP; + INSERT INTO device_status (host, cmts_version, last_scanned) + VALUES ($1, $2, $3) + ON CONFLICT (host) + DO UPDATE SET cmts_version = EXCLUDED.cmts_version, last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP; """ - await conn.execute(query, host, config_type, cmts_version, last_scanned) + await conn.execute(query, host, cmts_version, last_scanned) else: query = """ - INSERT INTO device_status (host, config_type, last_scanned) - VALUES ($1, $2, $3) - ON CONFLICT (host, config_type) - DO UPDATE SET - last_scanned = EXCLUDED.last_scanned, - updated_at = CURRENT_TIMESTAMP; + INSERT INTO device_status (host, last_scanned) + VALUES ($1, $2) + ON CONFLICT (host) + DO UPDATE SET last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP; """ - await conn.execute(query, host, config_type, last_scanned) + await conn.execute(query, host, last_scanned) return True except Exception as e: logger.error(f"❌ DB Error (upsert_device_status): {e}") return False -async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]: - """獲取設備 metadata""" +async def get_device_status(host: str) -> Optional[Dict[str, Any]]: pool = await get_pool() - if not pool: - return None - - query = """ - SELECT cmts_version, last_scanned FROM device_status - WHERE host = $1 AND config_type = $2; - """ + if not pool: return None + query = "SELECT cmts_version, last_scanned FROM device_status WHERE host = $1;" try: async with pool.acquire() as conn: - record = await conn.fetchrow(query, host, config_type) + record = await conn.fetchrow(query, host) if record: - return { - "cmts_version": record["cmts_version"], - "last_scanned": record["last_scanned"] - } + return {"cmts_version": record["cmts_version"], "last_scanned": record["last_scanned"]} return None - except asyncpg.PostgresError as e: - logger.error(f"❌ DB Error (get_device_status): {e}") - return None except Exception as e: - logger.error(f"❌ Unknown Error (get_device_status): {e}") + logger.error(f"❌ DB Error (get_device_status): {e}") return None # ------------------------------------------ @@ -419,7 +366,6 @@ import re from collections import defaultdict DEBUG_MODE = False -USE_DB = True # PostgreSQL Feature Toggle def debug_print(msg: str): if DEBUG_MODE: @@ -1018,10 +964,8 @@ FILE: index.html
- - - - + +
@@ -1293,6 +1237,7 @@ FILE: init_db.py import asyncio import asyncpg import logging +import sys logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -1306,7 +1251,7 @@ DB_CONFIG = { "port": "5432" } -async def init_database(): +async def init_database(force_reset: bool = False): try: logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...") conn = await asyncpg.connect( @@ -1317,34 +1262,46 @@ async def init_database(): port=int(DB_CONFIG["port"]) ) - # 1. 建立選項快取表 cmts_options - logger.info("🛠️ 正在建立 cmts_options 資料表...") + # ========================================== + # 💣 核彈模式:清除舊有資料表 + # ========================================== + if force_reset: + logger.warning("⚠️ 警告:啟動強制重建模式,正在刪除現有資料表...") + await conn.execute("DROP TABLE IF EXISTS cmts_options;") + await conn.execute("DROP TABLE IF EXISTS device_status;") + await conn.execute("DROP TABLE IF EXISTS system_filters;") + # 💡 備份表通常極度重要,即使 reset 也不建議輕易 DROP,除非你確定要連備份一起砍 + # await conn.execute("DROP TABLE IF EXISTS config_backups;") + logger.info("🗑️ 舊資料表已清除完畢。") + + # ========================================== + # 🏗️ 安全建置模式:建立資料表 (IF NOT EXISTS) + # ========================================== + # 1. 建立選項快取表 cmts_options (無 config_type) + logger.info("🛠️ 正在檢查/建立 cmts_options 資料表...") await conn.execute(""" CREATE TABLE IF NOT EXISTS cmts_options ( host VARCHAR(255) NOT NULL, - config_type VARCHAR(50) NOT NULL, path VARCHAR(500) NOT NULL, data JSONB NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (host, config_type, path) + PRIMARY KEY (host, path) ); """) - # 2. 建立設備狀態表 device_status - logger.info("🛠️ 正在建立 device_status 資料表...") + # 2. 建立設備狀態表 device_status (無 config_type) + logger.info("🛠️ 正在檢查/建立 device_status 資料表...") await conn.execute(""" CREATE TABLE IF NOT EXISTS device_status ( - host VARCHAR(255) NOT NULL, - config_type VARCHAR(50) NOT NULL, + host VARCHAR(255) NOT NULL PRIMARY KEY, cmts_version VARCHAR(100) DEFAULT 'unknown', last_scanned VARCHAR(100), - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (host, config_type) + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """) # 3. 建立過濾器設定表 system_filters - logger.info("🛠️ 正在建立 system_filters 資料表...") + logger.info("🛠️ 正在檢查/建立 system_filters 資料表...") await conn.execute(""" CREATE TABLE IF NOT EXISTS system_filters ( config_type VARCHAR(50) PRIMARY KEY, @@ -1353,8 +1310,8 @@ async def init_database(): ); """) - # 4. 建立設備配置備份表 config_backups (Phase 1 新增) - logger.info("🛠️ 正在建立 config_backups 資料表與索引...") + # 4. 建立設備配置備份表 config_backups + logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...") await conn.execute(""" CREATE TABLE IF NOT EXISTS config_backups ( id UUID PRIMARY KEY, @@ -1362,6 +1319,7 @@ async def init_database(): config_type VARCHAR(50) NOT NULL DEFAULT 'running', timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, snapshot_name VARCHAR(255), + description TEXT DEFAULT '', is_auto BOOLEAN NOT NULL DEFAULT FALSE, raw_cli TEXT, parsed_tree JSONB, @@ -1369,7 +1327,6 @@ async def init_database(): ); """) - # 建立複合索引以加速列表查詢與排序 await conn.execute(""" CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts ON config_backups (host, config_type, timestamp DESC); @@ -1382,7 +1339,21 @@ async def init_database(): logger.error(f"❌ 資料庫初始化失敗: {e}") if __name__ == "__main__": - asyncio.run(init_database()) + # 偵測命令列參數是否包含 --reset + is_reset = "--reset" in sys.argv + + if is_reset: + print("\n" + "="*50) + print("🚨 你正在執行資料庫重置 (--reset) 🚨") + print("這將會清空所有的選項快取與系統設定!") + print("="*50 + "\n") + confirm = input("確定要繼續嗎?(輸入 yes 繼續): ") + if confirm.lower() != "yes": + print("已取消操作。") + sys.exit(0) + + asyncio.run(init_database(force_reset=is_reset)) + ================================================================================ @@ -1396,113 +1367,99 @@ import json import os import time import database -from shared import USE_DB from logger import get_logger logger = get_logger("app.scraper") def parse_question_mark_output(output: str) -> dict: - """解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位""" + """解析 '?' 回傳內容,無差別掃描支援所有標題排列組合""" hint_lines = [] options = [] current_value = None format_desc = None is_format_only = False - - state = "INIT" - has_description = False has_bracket_value = False - # 🌟 Case-5 特殊處理:dhcp-relay 混合型欄位 (選項 + IP輸入) - # 直接在開頭掃描完整輸出,若包含 dhcp-relay,強制轉為純文字輸入框 if "dhcp-relay" in output.lower(): is_format_only = True format_desc = "IP address or options" for line in output.splitlines(): line = line.strip() - # 過濾雜訊與終端機提示字元 - if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'): + + # 1. 過濾雜訊與 Echo (包含 prompt 和 ?) + if not line or line.startswith('%') or line.startswith('^'): continue - - if line.startswith('Description:'): - state = "DESC" - has_description = True - hint_lines.append(line) + if '?' in line and ('#' in line or '>' in line): continue - - if line.startswith('Possible completions:'): - state = "COMPLETIONS" - # 🌟 解除限制:無論有沒有 Description,都把這行加進 hint - hint_lines.append(line) - continue - - if state == "DESC": - hint_lines.append(line) - - elif state == "COMPLETIONS": - # Case 1~4: 無 Description 時,將後續選項說明也納入提示 - # 🌟 解除限制:無論有沒有 Description,都把這行加進 hint - hint_lines.append(line) - - # 規則 1: <格式說明>[當前值] - match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line) - if match_format: - is_format_only = True - format_desc = match_format.group(1).strip() - if match_format.group(2): - current_value = match_format.group(2).strip() - continue - - # 規則 1.5: 型態, 最小值 .. 最大值 - match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line) - if match_range: - is_format_only = True - format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})" - continue - - # 規則 1.6: IP address 等純文字關鍵字 - match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE) - if match_keyword: - is_format_only = True - format_desc = match_keyword.group(1) - continue - - # 規則 2: 選項列表處理 - match_current = re.search(r"^\[([^\]]+)\]", line) - if match_current: - has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單 - if not current_value: - current_value = match_current.group(1).strip() - clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip() - else: - clean_line = line.strip() - # 🌟 Case-6 防呆:過濾垂直列表的說明文字 - # 利用「3 個以上的連續空白」作為選項與說明文字的分界線 - if re.search(r"\s{3,}", clean_line): - clean_line = re.split(r"\s{3,}", clean_line)[0] - - parts = clean_line.split() - for p in parts: - if p and p not in ["|", ".."]: - options.append(p) + # 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看) + hint_lines.append(line) - # 🌟 Case 2, 3, 4: 子命令防呆機制 - # 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字 - if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options: + # 3. 略過純標題行,不進行選項解析 + if line.startswith('Description:') or line.startswith('Possible completions:'): + continue + + # 4. 解析格式與選項 (無差別掃描每一行) + + # 格式 A: <格式說明>[當前值] (例如: [Cold Start]) + match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line) + if match_format: + is_format_only = True + format_desc = match_format.group(1).strip() + if match_format.group(2): + current_value = match_format.group(2).strip() + continue + + # 格式 B: 型態, 最小值 .. 最大值 (例如: unsignedInt, 1 .. 3000) + match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line) + if match_range: + is_format_only = True + format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})" + continue + + # 格式 C: 純文字關鍵字 + match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE) + if match_keyword: + is_format_only = True + format_desc = match_keyword.group(1) + continue + + # 格式 D: 選項列表 [現值] 選項1 選項2 + match_current = re.search(r"^\[([^\]]+)\]", line) + if match_current: + has_bracket_value = True + if not current_value: + current_value = match_current.group(1).strip() + clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip() + else: + clean_line = line.strip() + + # 分離選項與說明 (完美支援水平列表,如 severity size-mb) + parts = re.split(r'\s{2,}', clean_line) + valid_options = [] + for p in parts: + p = p.strip() + if not p: continue + # 如果這個片段包含空白,代表它是說明文字 (Description),停止解析後續片段 + if ' ' in p: + break + valid_options.append(p) + + for p in valid_options: + if p not in ["|", ".."]: + options.append(p) + + # 子命令防呆:如果抓到一堆單字,但沒有 [現值],且不是已知格式,很可能是子命令列表 + if not has_bracket_value and not is_format_only and options: is_format_only = True options = [] - # 確保現值一定包含在選項中(如果它是一般的下拉選單) if current_value and options and current_value not in options: options.append(current_value) - hint_text = "\n".join(hint_lines).strip() - return { - "hint": hint_text, - # 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input + "hint": "\n".join(hint_lines).strip(), "options": list(dict.fromkeys(options)) if not is_format_only else [], "current_value": current_value, "format_desc": format_desc, @@ -1510,9 +1467,8 @@ def parse_question_mark_output(output: str) -> dict: } def parse_device_response(output: str) -> dict: - """支援多種編輯狀態格式解析""" + # 完美支援 () (Cold Start): 格式 match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output) - if match: enum_content = match.group(1) desc_content = match.group(2) @@ -1534,11 +1490,9 @@ def parse_device_response(output: str) -> dict: "current_value": current_value, "format_desc": desc_content.strip() } - return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()} -# 🌟 1. 函數簽名加上 config_type 參數,預設為 "running" -async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"): +async def sync_cmts_leaves_async(host, username, password, leaf_paths: list): try: total_paths = len(leaf_paths) processed_count = 0 @@ -1546,13 +1500,12 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con BATCH_SIZE = 30 batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)] - cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本 + cmts_version = "unknown" for batch_idx, batch in enumerate(batches): logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") try: - # 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放 async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process: @@ -1566,43 +1519,44 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con if "--More--" in chunk or "More" in chunk: process.stdin.write(" ") await process.stdin.drain() - # 🌟 精準 Prompt 偵測 if prompt_pattern and re.search(prompt_pattern, output): break except asyncio.TimeoutError: break return output - # 🌟 新增:在第一批次連線時,先抓取設備版本 + # 🌟 關鍵修復 1:等待登入歡迎詞 (MOTD) 結束,確保設備準備好接收指令 + await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)") + if cmts_version == "unknown": - # 🌟 補上 | nomore,並稍微延長等待時間,確保不會被分頁卡住 process.stdin.write("show version | nomore\n") await process.stdin.drain() version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)") - match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output) + match = re.search(r"(?:infra|vcmts-cd-0|CableOS)\s+([\w\.\-]+)", version_output, re.IGNORECASE) if match: cmts_version = match.group(1) + else: + cmts_version = "parse_failed" # 🌟 避免正則失敗導致每批次都重查 + # 🌟 關鍵修復 2:確保成功進入 config 模式 process.stdin.write("config\n") await process.stdin.drain() - await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") + await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config.*\)#") for path in batch: - # 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求 await asyncio.sleep(0.01) - # --- 步驟 1:發送 '?' 查詢 --- command_to_send = f"{path} ?" process.stdin.write(command_to_send) await process.stdin.drain() - output_question = await read_until_quiet(timeout=1.0) + output_question = await read_until_quiet(timeout=0.8) q_data = parse_question_mark_output(output_question) - backspaces = "\x08" * (len(command_to_send) + 5) - process.stdin.write(backspaces) + # 🌟 頂級優雅解法:使用 Ctrl+U (\x15) 瞬間清空整行輸入緩衝區 + process.stdin.write("\x15") await process.stdin.drain() - await read_until_quiet(timeout=0.5) + await read_until_quiet(timeout=0.2) parsed_data = { "hint": q_data["hint"], @@ -1611,7 +1565,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con "current_value": q_data.get("current_value") } - # --- 步驟 2:判斷是否需要發送 Enter --- if q_data.get("is_format_only"): parsed_data["type"] = "string_or_number" @@ -1619,7 +1572,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con process.stdin.write(f"{path}\n") await process.stdin.drain() - output_enter = await read_until_quiet(timeout=1.0) + output_enter = await read_until_quiet(timeout=0.8) enter_data = parse_device_response(output_enter) parsed_data["type"] = enter_data["type"] @@ -1627,25 +1580,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con parsed_data["current_value"] = enter_data["current_value"] if enter_data["type"] != "unknown": - process.stdin.write("\x03") + # 如果進入了互動式輸入 (例如 prompt 變成 (val): ),按 Enter 接受預設值並退出 + process.stdin.write("\n") await process.stdin.drain() else: - logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}") - process.stdin.write("\n") - await process.stdin.drain() + # 如果只是印出錯誤,我們不需要做什麼 + pass await read_until_quiet(timeout=0.5) result_data[path] = parsed_data processed_count += 1 - event_data = { + yield json.dumps({ "event": "progress", "current": processed_count, "total": total_paths, "current_path": path - } - yield json.dumps(event_data) + "\n" + }) + "\n" process.stdin.write("exit\n") await process.stdin.drain() @@ -1655,75 +1607,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n" continue - # --- 步驟 3:寫入快取 (DB or JSON) --- try: - db_success = False current_ts = int(time.time()) formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - # 1. 嘗試寫入資料庫 - if USE_DB: - try: - # 寫入 metadata - if cmts_version != "unknown": - await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time}) - else: - await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time}) - - # 寫入 options - db_write_count = 0 - for p in batch: - if p in result_data: - result_data[p]["updated_at"] = current_ts - success = await database.upsert_leaf_option(host, config_type, p, result_data[p]) - if success: - db_write_count += 1 - - if db_write_count > 0: - db_success = True - logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!") - else: - logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...") - except Exception as e: - logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...") - - # 2. 如果 DB 寫入失敗或 USE_DB=False,則 Fallback 寫入 JSON - if not db_success: - # 🌟 動態決定快取檔名 (加入 IP 隔離) - safe_host = host.replace(".", "_") - cache_file = f"{safe_host}_{config_type}_cache.json" + # 寫入資料庫 + if cmts_version not in ["unknown", "parse_failed"]: + await database.upsert_device_status(host, {"cmts_version": cmts_version, "last_scanned": formatted_time}) + else: + await database.upsert_device_status(host, {"last_scanned": formatted_time}) - def read_json_from_file(filepath): - if os.path.exists(filepath): - with open(filepath, "r", encoding="utf-8") as f: - return json.load(f) - return {} + for p in batch: + if p in result_data: + result_data[p]["updated_at"] = current_ts + await database.upsert_leaf_option(host, p, result_data[p]) - cache_data = await asyncio.to_thread(read_json_from_file, cache_file) - - # 🌟 新增:寫入 Metadata (版本與掃描時間) - if "__metadata__" not in cache_data: - cache_data["__metadata__"] = {} - - if cmts_version != "unknown": - cache_data["__metadata__"]["cmts_version"] = cmts_version - - cache_data["__metadata__"]["last_scanned"] = formatted_time - - for p in batch: - if p in result_data: - result_data[p]["updated_at"] = current_ts - cache_data[p] = result_data[p] - - def write_json_to_file(filepath, data): - with open(filepath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - - await asyncio.to_thread(write_json_to_file, cache_file, cache_data) - logger.info(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})") - + logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已寫入資料庫!") except Exception as e: - logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") + logger.error(f"⚠️ 資料庫寫入失敗: {e}") if batch_idx < len(batches) - 1: await asyncio.sleep(2) @@ -1734,9 +1635,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n" async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str: - """連線至設備並抓取完整的純文字設定檔""" try: - # 🧹 [穩定性修復] 全面改用 async with 管理生命週期 async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process: @@ -1747,65 +1646,46 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type: chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) if not chunk: break output += chunk - # 自動翻頁 if "--More--" in chunk or "More" in chunk: process.stdin.write(" ") await process.stdin.drain() - # 🌟 精準 Prompt 偵測 if prompt_pattern and re.search(prompt_pattern, output): break except asyncio.TimeoutError: break return output - # 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊 await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") - # 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁 if config_type == "full": - # 1. 先進入 config 模式 process.stdin.write("config\n") await process.stdin.drain() - await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)# + await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") - # 2. 下達 full-configuration 指令 (🌟 加上 | nomore) cmd = "show full-configuration | nomore" process.stdin.write(f"{cmd}\n") await process.stdin.drain() raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#") - # 3. 抓完後退回上一層 (保持良好習慣) process.stdin.write("exit\n") await process.stdin.drain() else: - # running-config 在一般模式即可下達 (🌟 加上 | nomore) cmd = "show running-config | nomore" process.stdin.write(f"{cmd}\n") await process.stdin.drain() raw_output = await read_until_quiet(timeout=3.0) - # 最終退出設備 process.stdin.write("exit\n") await process.stdin.drain() - # 簡單清理頭尾的雜訊 (例如指令本身的 echo) - # 🌟 關鍵修正 1:強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h) cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output) - - # 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K) cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output) - - # 3. 清除純文字的 --More-- (防呆) cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output) - - # 🌟 新增 4:清除 CableOS 終端機特有的 (END) 結尾標記 cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output) - # 關鍵修正:這裡改用 cleaned_output 來切行! lines = cleaned_output.splitlines() - # 🌟 關鍵修正 2:加入 strip() 避免空白干擾,確保精準踢掉提示字元 clean_lines = [ line for line in lines if not line.strip().startswith(cmd) @@ -1816,18 +1696,10 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type: except Exception as e: raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}") - finally: - if conn: - conn.close() - def parse_config_to_tree(raw_cli: str) -> dict: - """將純文字設定檔轉換為簡單的階層式 JSON 樹狀圖 (Phase 3 會用到)""" - # 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化 - tree = {} - # 暫時回傳空字典,確保 Phase 1 & 2 能順利走通 - # 真正的樹狀解析邏輯我們可以在 Phase 3 完善 return {"_raw_length": len(raw_cli), "status": "pending_parser"} + ================================================================================ FILE: main.py ================================================================================ @@ -1991,40 +1863,57 @@ FILE: PROJECT_STATE.md ## ✅ 已完成開發階段 (Completed Phases) -### Phase 1: PostgreSQL 高可用性架構升級 (Completed) +### Phase 1: PostgreSQL 高可用性架構升級 - [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。 - [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。 - [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。 -- [x] 調整 `init_db.py` 為非同步啟動腳本。 -### Phase 2: 設備配置備份與快照機制 (Completed) +### Phase 2: 設備配置備份與快照機制 - [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。 -- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。 -- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。 -- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。 +- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表。 +- [x] **前端 UI 優化與防禦性編程**:實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。 -### Phase 3: 智慧差異還原與歷史預覽 (Completed) -- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。 +### Phase 3: 智慧差異還原與歷史預覽 - [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。 - [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。 -- [x] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 `ReadableStream` 即時渲染後端傳來的 SSH 逐行執行 Log,並具備自動捲動功能。 -- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**: - - 實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應)。 - - 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止、發送 `abort` 撤銷變更,並回傳錯誤 Chunk。 -- [x] **效能與體驗優化**:完成 DOM 陣列渲染優化、Terminal 資源回收 (防殭屍連線)、精準 Prompt 偵測,以及 UI 視窗連動防呆機制。 +- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應),並具備 Fail-safe `abort` 撤銷機制。 + +### Phase 3.5: 企業級前端效能重構 (Extreme Performance Optimization) +- [x] **記憶體遞迴渲染 (In-Memory Recursive Rendering)**:徹底重構 `tree-ui.js`,將數千次 DOM 寫入壓縮為單次 `innerHTML` 寫入,解決「展開全部」導致瀏覽器卡死的問題。 +- [x] **非同步 UI 保護機制**:在所有大型渲染場景 (初始載入、單點展開、全部展開) 導入 `setTimeout` 讓出主執行緒,並搭配沙漏游標與橘色讀取提示,確保 UI 絕對滑順。 +- [x] **CSS 渲染隔離**:導入 `content-visibility: auto`,讓不在可視範圍內的 DOM 節點暫停渲染計算。 +- [x] **精準 DOM 查詢**:將鎖定狀態輪詢 (`startLockStatusPolling`) 的搜尋範圍限縮於當前啟用的視圖內,消除全域搜尋造成的卡頓 (Jank)。 --- -## 🚧 目前開發階段 (Current Phase) +## 🚀 即將到來的里程碑 (Upcoming Milestones Summary) -### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management) -- [ ] **自動備份攔截 (Auto-Backup Interceptor)**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制,確保每次變更前都有還原點。 -- [ ] **備份保留策略 (Retention Policy)**:實作定期清理過期或過多歷史快照的機制,防止資料庫無限膨脹。 +### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management) +- [x] **智能視覺診斷 (Visual Diagnostics)**: + - CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。 +- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**: + - 重新 Review `generate_diff_commands` 演算法。 + - 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。 +- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)**: + - 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。 + - 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。 +- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)**: + - **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。 + - **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。 + +### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard) +- [ ] **RPD 樣板克隆引擎 (Template Cloning)**: + - 於「MAC Domain 狀態感知配置精靈」中新增 RPD 擴容模式。允許使用者選擇現有設備上已配置完成的 RPD (支援 FDX, FDD, D3.1+) 作為基準樣板。 +- [ ] **Tree 節點複製與參數替換 (Node Duplication & Modification)**: + - 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。 +- [ ] **安全寫入與校驗 (Safe Provisioning)**: + - 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。 --- -## 🐛 已知問題與未來計畫 (Known Issues & Backlog) -- 目前系統運行穩定,各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。 +## 🐛 已知問題與技術債 (Known Issues & Tech Debt) +- 目前系統運行極度穩定,前端效能瓶頸已徹底消除,各項併發鎖定與 UI 狀態連動皆已完善。準備進入 Phase 4 的 Diff 引擎強化開發。 + ================================================================================ @@ -2080,24 +1969,21 @@ export async function apiGetFullConfig(host, username, password, skipFilter = fa return response.json(); } -// 🌟 1. 取得選項快取 (加上 configType 查詢參數) -export async function apiGetLeafOptions(host, configType = 'running') { - const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`); +export async function apiGetLeafOptions(host) { + const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}`); return response.json(); } -// 🌟 2. 同步選項快取 (將 config_type 塞入 Body) -export async function apiSyncLeafOptions(host, paths, configType = 'running') { +export async function apiSyncLeafOptions(host, paths) { return fetch('/api/v1/cmts-leaf-options/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ host, leaf_paths: paths, config_type: configType }) + body: JSON.stringify({ host, leaf_paths: paths }) }); } -// 🌟 3. 清除快取 (加上 configType 查詢參數) -export async function apiClearCache(host, paths, configType = 'running') { - const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, { +export async function apiClearCache(host, paths) { + const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(paths) @@ -2112,9 +1998,9 @@ export async function apiGetCmtsVersion(host, username, password) { return response.json(); } -export async function apiGetScanStatus(host, configType = 'running') { +export async function apiGetScanStatus(host) { try { - const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`); + const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}`); const data = await response.json(); return data.is_scanning; } catch (e) { @@ -2199,37 +2085,25 @@ export async function apiListRpds(host, username, password) { // ========================================== // 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新) // ========================================== -// 🌟 4. 串流掃描 API (將 config_type 塞入 Body) -export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') { +export async function apiSyncLeafOptionsStream(host, paths, onProgress) { const response = await fetch('/api/v1/cmts-leaf-options/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ host, leaf_paths: paths, config_type: configType }) + body: JSON.stringify({ host, leaf_paths: paths }) }); - if (!response.body) throw new Error("瀏覽器不支援 ReadableStream"); - - // 🌟 核心修改:讀取串流資料 const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); - while (true) { const { done, value } = await reader.read(); - if (done) break; // 伺服器斷開連線 (任務完成) - - // 解碼二進位資料為文字 + if (done) break; const chunk = decoder.decode(value, { stream: true }); - - // 因為一次可能收到多行 JSON,我們用換行符號切開 const lines = chunk.split("\n").filter(line => line.trim() !== ""); - for (const line of lines) { try { const data = JSON.parse(line); - onProgress(data); // 將解析後的資料傳給 UI 介面 - } catch (e) { - console.error("JSON 解析錯誤:", e, line); - } + onProgress(data); + } catch (e) {} } } } @@ -2297,7 +2171,9 @@ import { apiGetCmDiagnostics, apiListCms, apiListRpds, apiGetLogLevels, apiSetLogLevel, - apiVerifyGodMode + apiVerifyGodMode, + apiGetLeafOptions, // 🌟 補上這個 + apiSyncLeafOptions // 🌟 補上這個 } from './api.js'; // 3. 共用工具模組 (Utils) @@ -2321,6 +2197,11 @@ import { } from './mac-domain.js'; +// ============================================================================ +// 🌟 全域記憶體 (Dual-Track Memory) +// ============================================================================ +window.treeDataStore = { running: null, full: null }; + // ============================================================================ // 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽 // ============================================================================ @@ -2329,18 +2210,28 @@ let idleTimer; const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) let globalSSE = null; -// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道 -function initGlobalSSE(mode = 'running') { +// 初始化全域 SSE 監聽器 (設備級別) +function initGlobalSSE() { + // 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出 + if (!isConnected) { + if (globalSSE) { + globalSSE.close(); + globalSSE = null; + } + return; + } + const connInfo = getGlobalConnectionInfo(); - if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE + if (!connInfo || !connInfo.host) return; if (globalSSE) globalSSE.close(); - globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); + + // 🌟 拔除 config_type,純粹監聽該設備的廣播 + globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}`); globalSSE.onmessage = (event) => { const data = JSON.parse(event.data); const statusEl = document.getElementById('scan-status'); - // 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定) if (data.event === 'scan_start' || data.event === 'progress') { setAdminButtonsState(true, "⏳ 系統更新中..."); } @@ -2363,7 +2254,6 @@ function initGlobalSSE(mode = 'running') { statusEl.style.color = "#27ae60"; setTimeout(() => statusEl.textContent = "", 5000); } - // 掃描完成才解鎖 setAdminButtonsState(false); localStorage.removeItem('scanLockUntil'); } @@ -2372,7 +2262,6 @@ function initGlobalSSE(mode = 'running') { statusEl.innerHTML = `❌ 錯誤: ${data.message}`; statusEl.style.color = "#e74c3c"; } - // 發生錯誤也要解鎖 setAdminButtonsState(false); localStorage.removeItem('scanLockUntil'); } @@ -2398,6 +2287,8 @@ function startLockStatusPolling() { if (lockPollingTimer) clearInterval(lockPollingTimer); lockPollingTimer = setInterval(async () => { + if (!isConnected) return; // 🌟 新增防線:未連線時不進行輪詢 + const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; const host = connInfo.host; @@ -2420,82 +2311,84 @@ function startLockStatusPolling() { for (const [path, lockInfo] of Object.entries(currentLocks)) { const lockKey = `${host}@@${path}`; - // 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒) + // 🛡️ 防閃爍機制 if (window.recentlyReleasedLocks[lockKey]) { if (now - window.recentlyReleasedLocks[lockKey] < 8000) { - continue; // 跳過,不當作鎖定 + continue; } else { - delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶 + delete window.recentlyReleasedLocks[lockKey]; } } - newLockState.add(lockKey); // 記憶體加入 Host 標籤 + newLockState.add(lockKey); const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 - const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); - targetBtns.forEach(btn => { - // 略過目前正在編輯的那個實體按鈕 - if (btn.id === `edit-btn-${currentEditElementId}`) return; + // 🌟 效能優化:只在當前啟用的視圖中搜尋,避免掃描隱藏的龐大 DOM + const currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running'; + const activeContainer = document.getElementById(`tree-container-${currentMode}`); + + if (activeContainer) { + const targetBtns = activeContainer.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); + + targetBtns.forEach(btn => { + if (btn.id === `edit-btn-${currentEditElementId}`) return; - if (btn.innerText !== "🔒") { - btn.style.pointerEvents = 'none'; - btn.style.opacity = '0.2'; - - const btnPath = btn.getAttribute('data-path'); - if (btnPath === path) { - // 判斷是別人鎖的,還是自己在另一個視圖鎖的 - if (lockInfo.user_id !== SESSION_USER_ID) { - btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; + if (btn.innerText !== "🔒") { + btn.style.pointerEvents = 'none'; + btn.style.opacity = '0.2'; + + const btnPath = btn.getAttribute('data-path'); + if (btnPath === path) { + btn.title = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`; } else { - btn.title = `🔒 您正在另一個視圖編輯此項目`; + btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; } - } else { - // 子節點被父節點鎖定 - btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; + btn.innerText = "🔒"; } - btn.innerText = "🔒"; - } - }); + }); + } } // 🔓 任務 2:處理「解除鎖定」的節點 activeLockState.forEach(oldKey => { const [oldHost, oldPath] = oldKey.split('@@'); - // 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點 if (oldHost === host && !newLockState.has(oldKey)) { const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 - const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); - targetBtns.forEach(btn => { - if (btn.innerText === "🔒") { - const btnPath = btn.getAttribute('data-path'); - let stillLockedByOther = false; - - // 檢查自己是否還在鎖定清單中 - if (currentLocks[btnPath]) { - stillLockedByOther = true; - } else { - // 檢查是否有其他父節點鎖定它 - for (const lockedPath of Object.keys(currentLocks)) { - if (btnPath.startsWith(lockedPath + "::")) { - stillLockedByOther = true; - break; + // 🌟 效能優化:同樣只在當前啟用的視圖中搜尋 + const currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running'; + const activeContainer = document.getElementById(`tree-container-${currentMode}`); + + if (activeContainer) { + const targetBtns = activeContainer.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); + + targetBtns.forEach(btn => { + if (btn.innerText === "🔒") { + const btnPath = btn.getAttribute('data-path'); + let stillLockedByOther = false; + + if (currentLocks[btnPath]) { + stillLockedByOther = true; + } else { + for (const lockedPath of Object.keys(currentLocks)) { + if (btnPath.startsWith(lockedPath + "::")) { + stillLockedByOther = true; + break; + } } } + + if (!stillLockedByOther) { + btn.style.pointerEvents = 'auto'; + btn.style.opacity = '0.3'; + btn.title = "鎖定並編輯此項目"; + btn.innerText = "✏️"; + } } - - if (!stillLockedByOther) { - btn.style.pointerEvents = 'auto'; - btn.style.opacity = '0.3'; - btn.title = "鎖定並編輯此項目"; - btn.innerText = "✏️"; - } - } - }); + }); + } } }); @@ -2512,7 +2405,7 @@ function startLockStatusPolling() { window.onload = () => { initTerminal(); resetIdleTimer(); // 頁面載入時啟動閒置計時器 - initGlobalSSE(); // 啟動全域廣播監聽 + // initGlobalSSE(); <--- 🌟 刪除這行!不要在網頁剛開時就偷聽 startLockStatusPolling(); // 啟動鎖定狀態輪詢 // 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步 @@ -2701,7 +2594,7 @@ function switchConfigTask() { } } - initGlobalSSE(currentMode); + initGlobalSSE(); } } @@ -2877,7 +2770,7 @@ async function fetchFullConfig(configType = 'running') { const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); if (versionRes.status === 'success') { const currentVersion = versionRes.version; - const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`); + const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}`); const optData = await optRes.json(); const cachedVersion = optData.__metadata__?.cmts_version; @@ -2959,7 +2852,8 @@ async function fetchFullConfig(configType = 'running') { if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。"); - window.currentTreeData = finalTreeData; + // 🌟 1. 存入雙軌記憶體,不再污染全域變數 + window.treeDataStore[configType] = finalTreeData; const currentSelectedTask = document.getElementById('configTask').value; const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; @@ -2967,19 +2861,29 @@ async function fetchFullConfig(configType = 'running') { if (currentSelectedTask !== expectedTask) return; if (treeContainer) { - treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType); - if (loadingMsg) loadingMsg.style.display = 'none'; - - const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType); - if (isSomeoneScanning) { - alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。"); - lockScanButton(60000); - } else { - enableGlobalScanButton(); - if (needAutoScan) { - setTimeout(() => scanGlobalMissingOptions(configType), 500); + // 🌟 統一渲染作法:先顯示 UI 提示,讓出主執行緒 + treeContainer.innerHTML = `
⏳ 正在極速建構樹狀圖,請稍候...
`; + document.body.style.cursor = 'wait'; + + // 因為內部有 await apiGetScanStatus,所以這裡使用 async () => {} + setTimeout(async () => { + treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType); + if (loadingMsg) loadingMsg.style.display = 'none'; + document.body.style.cursor = 'default'; + + // 🌟 拔除 configType,檢查設備級別的掃描狀態 + const isSomeoneScanning = await apiGetScanStatus(connInfo.host); + if (isSomeoneScanning) { + alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。"); + lockScanButton(60000); + } else { + enableGlobalScanButton(); + if (needAutoScan) { + // 🌟 呼叫新的全域掃描函數 + setTimeout(() => scanMissingOptions(), 500); + } } - } + }, 20); } } catch (error) { @@ -2988,7 +2892,7 @@ async function fetchFullConfig(configType = 'running') { if (progressInterval) clearInterval(progressInterval); if (loadingMsg) { loadingMsg.style.display = 'none'; - loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色 + loadingMsg.style.color = "#f39c12"; } } } @@ -3194,16 +3098,14 @@ function renderMerChart(channelName, histogramData) { // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning) // ============================================================================ -// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數 -async function enhanceInputWithOptions(path, elementId, mode = 'running') { - // 🌟 1. 取得當前連線的 IP +// 為現有的 input 加上下拉選項 (不破壞原有結構) +async function enhanceInputWithOptions(path, elementId) { const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; try { - // 🌟 2. GET 請求:網址加上 host 參數 - const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); - const optData = await optRes.json(); + // 🌟 拔除 config_type 參數 + const optData = await apiGetLeafOptions(connInfo.host); const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); const leafCache = optData[cacheKey]; @@ -3225,11 +3127,11 @@ async function enhanceInputWithOptions(path, elementId, mode = 'running') { dataList.innerHTML = leafCache.options.map(opt => `