Compare commits

...

19 Commits
v2.4.0 ... 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
swpa bc933e1e39 feat: 導入動態日誌管控機制架構 2026-06-05 18:29:04 +08:00
swpa 19d5a30305 refactor: UX 輸入欄位優化!透過 HTML5 <datalist> 結合背景非同步抓取,保留原本純文字輸入的彈性(支援模糊搜尋與手動輸入) 2026-06-05 13:52:31 +08:00
swpa 8bc68b8bf1 feat: 智能視覺診斷 (Visual Diagnostics) :
- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
2026-06-05 13:52:02 +08:00
swpa 3cd7ce253e refactor: 混合式架構 (Hybrid Approach) 的設定載入訊息設計。
-前端模擬動態流 (Simulated Streaming)
   -真實動態流 (True Streaming)
2026-06-03 17:24:08 +08:00
swpa ecedee49d2 fix: 修復系統中「鎖定狀態未向下繼承」的嚴重 UI/UX 漏洞。 2026-06-02 17:35:33 +08:00
swpa 6698e34292 refactor: 關鍵的 UX 與安全性升級。
- 1. 移除隱含風險的關閉方式,並實作嚴格的「執行中鎖定 (Execution Lock)」,能有效防止幽靈寫入與併發衝突。
- 2. 跨視圖併發防護 (Cross-View Concurrency)。
2026-06-01 16:20:34 +08:00
swpa 2cbbdb7b0c fix: 聯動防呆機制-如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入。 2026-06-01 11:39:58 +08:00
swpa 2168ccf770 perf(core): 實施 Priority 3 效能與體驗極致優化
- 前端渲染優化:將 tree-ui.js 的巨量字串拼接改為 Array.join,大幅降低 V8 引擎 GC 負擔與畫面卡頓。
- Terminal 資源回收:強化 WebSocket 斷線偵測,主動 cancel 背景任務並確保 SSH 連線乾淨釋放 (terminal.py)。
- 智慧 Prompt 偵測:全面導入 Regex 匹配設備 Prompt,取代盲目 Timeout 等待,巨幅提升爬蟲與設定寫入速度 (cmts_scraper.py, backup.py, config.py)。
2026-05-31 18:19:12 +08:00
swpa 60195a735e perf(memory): 實施 Priority 2 記憶體洩漏修復與資源回收優化
- 前端 OOM 防護:實作 clearTreeCache 並於切換設備時觸發,釋放巨量樹狀圖快取 (tree-ui.js, app.js)。
- 後端 SSE 資源回收:延長輪詢 timeout 降低 CPU 負載,並透過 finally 確保斷線時 Queue 絕對釋放 (leaf_options.py)。
- SSH 連線生命週期:全面導入 async with Context Manager,確保爬蟲任務中 AsyncSSH 連線與 Process 乾淨關閉 (cmts_scraper.py)。
2026-05-31 16:15:44 +08:00
swpa 4437e4e3fa refactor(backend): 實施 Priority 1 核心穩定性升級與連線防護
- 實作設備級別鎖 (Per-Host Lock):將全域鎖改為 defaultdict(asyncio.Lock),大幅提升多人併發操作能力 (shared.py, config.py, backup.py)。
- 修復 Netmiko 連線洩漏:全面導入 try...finally 機制,確保發生異常時 disconnect() 絕對會被執行 (query.py, config.py)。
- 解除 Event Loop 阻塞:將解析樹狀圖等 CPU-Bound 任務移至 asyncio.to_thread 背景執行,防止 API 請求卡死 (config.py)。
- 強化 AsyncSSH 生命週期:於備份還原路由導入 async with Context Manager,確保 SSH 連線與 Process 絕對乾淨釋放 (backup.py)。
2026-05-31 14:24:10 +08:00
31 changed files with 7820 additions and 2642 deletions

View File

@ -39,10 +39,13 @@
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。 7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。 8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。
9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`。 9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`。
10. **精準 Prompt 偵測**: 透過 SSH 讀取設備回傳時,嚴禁盲目依賴 `timeout`。必須在 `read_until_quiet` 中傳入 `prompt_pattern` (如 `r"\(config\)#"` 或 `r"(?:#|>)"`) 進行正規表達式匹配,以最高速釋放資源。
### 🎨 前端規範 ### 🎨 前端規範
10. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。 11. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。
11. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時必須透過 SSE 即時回報進度。 12. **UI 狀態安全還原**: 動態修改 DOM 的 CSS 屬性(如 `flex`, `width`)後,若需恢復原狀,嚴禁寫死預設值,必須將屬性設為空字串 `''`,讓瀏覽器自然退回 HTML 定義的樣式,避免改 A 壞 B。
12. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy保留策略/定期清理機制),不可只寫入不刪除。 13. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時必須透過 SSE 或 ReadableStream 即時回報進度。
13. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。 14. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy保留策略/定期清理機制),不可只寫入不刪除。
14. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()` 與 `?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。 15. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。
16. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()` 與 `?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。

View File

@ -1,48 +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()` ## 🧩 2. 模組化切割與檔案關聯 (Module Map)
6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。 ### 🟢 後端核心模組 (Backend Core)
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。 * `main.py`: 系統進入點。負責掛載靜態檔案、初始化 DB Pool (`lifespan`),並將所有 API 路由統一掛載於 `/api/v1` 前綴之下。
9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit` * `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`,不銷毀資料。
### 🎨 前端規範
10. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`
11. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時必須透過 SSE 即時回報進度。
12. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy保留策略/定期清理機制),不可只寫入不刪除。
13. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。
14. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()``?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。

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,32 +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`)。 - [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: 智慧差異還原與歷史預覽
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。
## 🚧 目前開發階段 (Current Phase)
### Phase 3: 智慧差異還原與歷史預覽 (Smart Diff Recovery & Preview)
- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)。
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。 - [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
- [ ] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 ReadableStream 即時渲染後端傳來的 SSH 逐行執行 Log。 - [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:實作 `/api/v1/backups/{id}/restore` API採用 `StreamingResponse` (NDJSON 串流回應),並具備 Fail-safe `abort` 撤銷機制。
- [ ] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**
- 實作 `/api/v1/backups/{id}/restore` API改為 Streaming Response (串流回應)。 ### Phase 3.5: 企業級前端效能重構 (Extreme Performance Optimization)
- 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止並回傳錯誤 Chunk。 - [x] **記憶體遞迴渲染 (In-Memory Recursive Rendering)**:徹底重構 `tree-ui.js`,將數千次 DOM 寫入壓縮為單次 `innerHTML` 寫入,解決「展開全部」導致瀏覽器卡死的問題。
- [x] **非同步 UI 保護機制**:在所有大型渲染場景 (初始載入、單點展開、全部展開) 導入 `setTimeout` 讓出主執行緒,並搭配沙漏游標與橘色讀取提示,確保 UI 絕對滑順。
- [x] **CSS 渲染隔離**:導入 `content-visibility: auto`,讓不在可視範圍內的 DOM 節點暫停渲染計算。
- [x] **精準 DOM 查詢**:將鎖定狀態輪詢 (`startLockStatusPolling`) 的搜尋範圍限縮於當前啟用的視圖內,消除全域搜尋造成的卡頓 (Jank)。
--- ---
## 🐛 已知問題與未來計畫 (Known Issues & Backlog) ## 🚀 即將到來的里程碑 (Upcoming Milestones Summary)
- **[未來計畫] 自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
- [x] **智能視覺診斷 (Visual Diagnostics)**
- 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 & Tech Debt)
- 目前系統運行極度穩定,前端效能瓶頸已徹底消除,各項併發鎖定與 UI 狀態連動皆已完善。準備進入 Phase 4 的 Diff 引擎強化開發。

File diff suppressed because it is too large Load Diff

View File

@ -6,110 +6,108 @@ import json
import os import os
import time import time
import database 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: 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
hint_lines.append(line)
continue 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 防呆:過濾垂直列表的說明文字 # 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看)
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線 hint_lines.append(line)
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)
# 🌟 Case 2, 3, 4: 子命令防呆機制 # 3. 略過純標題行,不進行選項解析
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字 if line.startswith('Description:') or line.startswith('Possible completions:'):
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options: continue
# 4. 解析格式與選項 (無差別掃描每一行)
# 格式 A: <格式說明>[當前值] (例如: <string, min: 0 chars, max: 128 chars>[Cold Start])
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
if match_format:
is_format_only = True
format_desc = match_format.group(1).strip()
if match_format.group(2):
current_value = match_format.group(2).strip()
continue
# 格式 B: 型態, 最小值 .. 最大值 (例如: unsignedInt, 1 .. 3000)
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
if match_range:
is_format_only = True
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
continue
# 格式 C: 純文字關鍵字
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
if match_keyword:
is_format_only = True
format_desc = match_keyword.group(1)
continue
# 格式 D: 選項列表 [現值] 選項1 選項2
match_current = re.search(r"^\[([^\]]+)\]", line)
if match_current:
has_bracket_value = True
if not current_value:
current_value = match_current.group(1).strip()
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
else:
clean_line = line.strip()
# 分離選項與說明 (完美支援水平列表,如 severity size-mb)
parts = re.split(r'\s{2,}', clean_line)
valid_options = []
for p in parts:
p = p.strip()
if not p: continue
# 如果這個片段包含空白,代表它是說明文字 (Description),停止解析後續片段
if ' ' in p:
break
valid_options.append(p)
for p in valid_options:
if p not in ["|", ".."]:
options.append(p)
# 子命令防呆:如果抓到一堆單字,但沒有 [現值],且不是已知格式,很可能是子命令列表
if not has_bracket_value and not is_format_only and options:
is_format_only = True 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,
@ -117,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)
@ -141,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
@ -153,182 +148,137 @@ 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):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
conn = None
try: try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') 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 def read_until_quiet(timeout=1.0): async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
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()
except asyncio.TimeoutError: if prompt_pattern and re.search(prompt_pattern, output):
break break
return output except asyncio.TimeoutError:
break
return output
# 🌟 新增:在第一批次連線時,先抓取設備版本 # 🌟 關鍵修復 1等待登入歡迎詞 (MOTD) 結束,確保設備準備好接收指令
if cmts_version == "unknown": await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
process.stdin.write("show version\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5)
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match:
cmts_version = match.group(1)
process.stdin.write("config\n") if cmts_version == "unknown":
await process.stdin.drain() process.stdin.write("show version | nomore\n")
await read_until_quiet(timeout=1.5) await process.stdin.drain()
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
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" # 🌟 避免正則失敗導致每批次都重查
for path in batch: # 🌟 關鍵修復 2確保成功進入 config 模式
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求 process.stdin.write("config\n")
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)
q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
parsed_data = {
"hint": q_data["hint"],
"options": q_data["options"],
"type": "list" if q_data["options"] else "unknown",
"current_value": q_data.get("current_value")
}
# --- 步驟 2判斷是否需要發送 Enter ---
if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number"
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config.*\)#")
output_enter = await read_until_quiet(timeout=1.0)
enter_data = parse_device_response(output_enter) for path in batch:
await asyncio.sleep(0.01)
parsed_data["type"] = enter_data["type"]
parsed_data["options"] = enter_data["options"] command_to_send = f"{path} ?"
parsed_data["current_value"] = enter_data["current_value"] process.stdin.write(command_to_send)
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=0.5) output_question = await read_until_quiet(timeout=0.8)
q_data = parse_question_mark_output(output_question)
# 🌟 頂級優雅解法:使用 Ctrl+U (\x15) 瞬間清空整行輸入緩衝區
process.stdin.write("\x15")
await process.stdin.drain()
await read_until_quiet(timeout=0.2)
result_data[path] = parsed_data parsed_data = {
"hint": q_data["hint"],
processed_count += 1 "options": q_data["options"],
event_data = { "type": "list" if q_data["options"] else "unknown",
"event": "progress", "current_value": q_data.get("current_value")
"current": processed_count, }
"total": total_paths,
"current_path": path
}
yield json.dumps(event_data) + "\n"
process.stdin.write("exit\n") if q_data.get("is_format_only"):
await process.stdin.drain() parsed_data["type"] = "string_or_number"
await read_until_quiet(timeout=1.0)
elif not q_data["options"]:
process.stdin.write(f"{path}\n")
await process.stdin.drain()
output_enter = await read_until_quiet(timeout=0.8)
enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"]
parsed_data["options"] = enter_data["options"]
parsed_data["current_value"] = enter_data["current_value"]
if enter_data["type"] != "unknown":
# 如果進入了互動式輸入 (例如 prompt 變成 (val): ),按 Enter 接受預設值並退出
process.stdin.write("\n")
await process.stdin.drain()
else:
# 如果只是印出錯誤,我們不需要做什麼
pass
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
processed_count += 1
yield json.dumps({
"event": "progress",
"current": processed_count,
"total": total_paths,
"current_path": path
}) + "\n"
process.stdin.write("exit\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0)
except Exception as e: except Exception as e:
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n" yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
continue continue
finally:
if conn: conn.close()
# --- 步驟 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 else:
if cmts_version != "unknown": await database.upsert_device_status(host, {"last_scanned": formatted_time})
await database.upsert_device_status(host, config_type, {"cmts_version": cmts_version, "last_scanned": formatted_time})
else:
await database.upsert_device_status(host, config_type, {"last_scanned": formatted_time})
# 寫入 options
db_write_count = 0
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
success = await database.upsert_leaf_option(host, config_type, p, result_data[p])
if success:
db_write_count += 1
if db_write_count > 0:
db_success = True
print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
else:
print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
# 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON
if not db_success:
# 🌟 動態決定快取檔名 (加入 IP 隔離)
safe_host = host.replace(".", "_")
cache_file = f"{safe_host}_{config_type}_cache.json"
def read_json_from_file(filepath): for p in batch:
if os.path.exists(filepath): if p in result_data:
with open(filepath, "r", encoding="utf-8") as f: result_data[p]["updated_at"] = current_ts
return json.load(f) await database.upsert_leaf_option(host, p, result_data[p])
return {}
cache_data = await asyncio.to_thread(read_json_from_file, cache_file) logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已寫入資料庫!")
# 🌟 新增:寫入 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)
print(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
except Exception as e: except Exception as e:
print(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)
@ -339,92 +289,72 @@ 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:
"""連線至設備並抓取完整的純文字設定檔"""
conn = None
try: try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None) # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') 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 def read_until_quiet(timeout=2.0): async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
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() if prompt_pattern and re.search(prompt_pattern, output):
except asyncio.TimeoutError: break
break except asyncio.TimeoutError:
return output break
return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊 await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
await read_until_quiet(timeout=1.0)
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁 if config_type == "full":
if config_type == "full": process.stdin.write("config\n")
# 1. 先進入 config 模式 await process.stdin.drain()
process.stdin.write("config\n") await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
await process.stdin.drain()
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)# cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n")
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore) await process.stdin.drain()
cmd = "show full-configuration | nomore" raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
process.stdin.write(f"{cmd}\n")
await process.stdin.drain() process.stdin.write("exit\n")
raw_output = await read_until_quiet(timeout=3.0) await process.stdin.drain()
# 3. 抓完後退回上一層 (保持良好習慣) else:
process.stdin.write("exit\n") cmd = "show running-config | nomore"
await process.stdin.drain() process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
else: raw_output = await read_until_quiet(timeout=3.0)
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore" process.stdin.write("exit\n")
process.stdin.write(f"{cmd}\n") await process.stdin.drain()
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0) cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 最終退出設備 cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
process.stdin.write("exit\n") cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
await process.stdin.drain()
lines = cleaned_output.splitlines()
# 簡單清理頭尾的雜訊 (例如指令本身的 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) 結尾標記 clean_lines = [
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output) line for line in lines
if not line.strip().startswith(cmd)
# 關鍵修正:這裡改用 cleaned_output 來切行! and not line.strip().startswith("admin@")
lines = cleaned_output.splitlines() ]
return "\n".join(clean_lines).strip()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [
line for line in lines
if not line.strip().startswith(cmd)
and not line.strip().startswith("admin@")
]
return "\n".join(clean_lines).strip()
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 會用到)""" return {"_raw_length": len(raw_cli), "status": "pending_parser"}
# 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化
tree = {}
# 暫時回傳空字典,確保 Phase 1 & 2 能順利走通
# 真正的樹狀解析邏輯我們可以在 Phase 3 完善
return {"_raw_length": len(raw_cli), "status": "pending_parser"}

View File

@ -1,22 +1,27 @@
import asyncpg import asyncpg
import json import json
import logging
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
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
# ========================================== # ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版) # 💡 PostgreSQL 連線與操作 (高可用性版)
# ========================================== # ==========================================
logger = logging.getLogger(__name__) 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 return None
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_device_status): {e}")
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

@ -8,6 +8,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 引入獨立的 CSS 樣式表 --> <!-- 引入獨立的 CSS 樣式表 -->
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
@ -19,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>
@ -44,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>
@ -65,9 +67,10 @@
<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>
</select> </select>
</div> </div>
@ -76,12 +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" placeholder="例: 6467.7240.4076 (留白則查詢全體)"> <input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
<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>
@ -125,12 +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" placeholder="例: 13:0 (留白則查詢全體)"> <input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
<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>
@ -154,8 +159,77 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 表單 3CM 一鍵診斷中心 -->
<div id="form-cm-diagnostics" class="task-form">
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
<!-- 搜尋區塊 -->
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
<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;">
<datalist id="diag-cm-mac-list"></datalist>
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div>
<!-- 結果顯示區塊 (預設隱藏) -->
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
<!-- 上半部:基本資訊與 PHY 狀態 -->
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- 基本資訊卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
</table>
</div>
<!-- PHY 狀態卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;">
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
<td id="diagResTx" style="font-weight: bold;">-</td>
</tr>
<tr>
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
<td id="diagResRx" style="font-weight: bold;">-</td>
</tr>
</table>
<!-- 🌟 新增:上行通道標籤區塊 -->
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
<!-- 動態生成標籤 -->
</div>
</div>
</div>
</div>
<!-- 下半部Chart.js MER 圖表 -->
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
<!-- 動態生成按鈕 -->
</div>
</h4>
<div style="position: relative; height: 280px; width: 100%;">
<canvas id="merChart"></canvas>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Tab 3: CMTS 設備配置 --> <!-- Tab 3: CMTS 設備配置 -->
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;"> <div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
@ -163,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>
@ -177,10 +251,9 @@
<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>
<select id="cfgMacDomain" style="width: 150px; 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;">
<option value="">請先點擊上方載入任務...</option> <datalist id="mac-domain-list"></datalist>
</select>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button> <button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span> <span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div> </div>
@ -189,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>
@ -229,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>
@ -266,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>
@ -299,12 +370,10 @@
<!-- 頂部標題列 --> <!-- 頂部標題列 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4> <h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 20px;">
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button> <button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button> <button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button> <button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">&times;</span>
</div> </div>
</div> </div>
@ -338,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>
@ -361,6 +430,40 @@
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span> <span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</div> </div>
</div> </div>
<!-- 🌟 新增:伺服器日誌管控面板 -->
<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;">
🎛️ 伺服器日誌管控 (Server Log Management)
</h3>
<div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,<b>無需重啟伺服器</b><br>
<span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b><b>ERROR</b>,僅在需要排查問題時開啟 <b>DEBUG</b></span>
</p>
</div>
<!-- 矩陣式下拉選單容器 -->
<div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
</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>
@ -389,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;">
@ -425,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>
@ -435,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;">
@ -451,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">
@ -469,14 +573,16 @@
</div> </div>
<!-- 獨立的彈出式輸出視窗 (Modal) --> <!-- 獨立的彈出式輸出視窗 (Modal) -->
<div id="outputModal" class="modal-overlay" onclick="closeModal(event)"> <div id="outputModal" class="modal-overlay">
<div class="modal-container" onclick="event.stopPropagation()"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title"> <div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
<span>📄 執行結果</span> <span>📄 執行結果</span>
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal;"></span> <span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
</div>
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
</div> </div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre> <pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
@ -484,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))

71
logger.py Normal file
View File

@ -0,0 +1,71 @@
# --- logger.py ---
import logging
import sys
# 定義終端機輸出的 ANSI 顏色
COLORS = {
"DEBUG": "\033[36m", # 青色 (Cyan)
"INFO": "\033[32m", # 綠色 (Green)
"WARNING": "\033[33m", # 黃色 (Yellow)
"ERROR": "\033[31m", # 紅色 (Red)
"CRITICAL": "\033[1;31m",# 粗體紅色 (Bold Red)
"RESET": "\033[0m" # 重置顏色
}
class ColoredFormatter(logging.Formatter):
def format(self, record):
log_color = COLORS.get(record.levelname, COLORS["RESET"])
# 格式:時間 | 等級 | 模組名稱 | 訊息
format_str = f"{log_color}%(asctime)s | %(levelname)-7s | %(name)-15s | %(message)s{COLORS['RESET']}"
formatter = logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S")
return formatter.format(record)
# 定義系統中受管控的模組清單
MANAGED_LOGGERS = [
"app.database", # 資料庫操作
"app.scraper", # 爬蟲與快取
"app.diagnostics", # CM 診斷
"app.backup", # 備份與還原
"app.ssh", # 🔌 SSH 連線引擎 (注意這裡要有逗號!)
"app.auth" # 🔐 上帝模式與安全授權
]
def setup_logger(name: str, level=logging.INFO):
logger = logging.getLogger(name)
logger.setLevel(level)
# 避免重複添加 Handler 導致日誌印兩次
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(ColoredFormatter())
logger.addHandler(handler)
logger.propagate = False # 防止日誌往上傳遞給 root logger (避免被 FastAPI 預設格式覆蓋)
return logger
# 系統啟動時,初始化所有模組,預設等級為 INFO
for name in MANAGED_LOGGERS:
setup_logger(name, logging.INFO)
def get_logger(name: str):
"""供各個檔案引入 logger 使用"""
if name not in MANAGED_LOGGERS:
return setup_logger(name)
return logging.getLogger(name)
def get_all_log_levels():
"""取得目前所有模組的日誌等級 (供前端 UI 顯示)"""
return {name: logging.getLevelName(logging.getLogger(name).level) for name in MANAGED_LOGGERS}
def set_log_level(name: str, level_str: str):
"""動態設定特定模組的日誌等級"""
if name in MANAGED_LOGGERS:
level = getattr(logging, level_str.upper(), logging.INFO)
logging.getLogger(name).setLevel(level)
return True
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)

29
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 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")
@ -28,9 +52,12 @@ app.include_router(config.router, prefix="/api/v1")
app.include_router(leaf_options.router, prefix="/api/v1") 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(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"])

View File

@ -1,19 +1,19 @@
import os import os
import os
import argparse
# ========================================== # ==========================================
# 設定區 # 設定區
# ========================================== # ==========================================
# 輸出的合併檔案名稱
OUTPUT_FILE = "all_code.txt"
# ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄) # ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄)
EXCLUDE_DIRS = { EXCLUDE_DIRS = {
"__pycache__", "__pycache__",
".git", ".git",
".vscode", # VS Code 設定檔 ".vscode", # VS Code 設定檔
".continue", # Continue.dev 設定檔 ".continue", # Continue.dev 設定檔
"cmts_api_env", # 🚨 Python 虛擬環境 (極度重要!絕對要排除) "cmts_api_env", # 🚨 Python 虛擬環境
"venv", # 其他常見的虛擬環境名稱 "venv",
".venv", ".venv",
"node_modules", "node_modules",
"dist", "dist",
@ -22,27 +22,28 @@ EXCLUDE_DIRS = {
# ⚠️ 排除的檔案副檔名 (不讀取這些格式) # ⚠️ 排除的檔案副檔名 (不讀取這些格式)
EXCLUDE_EXTENSIONS = { EXCLUDE_EXTENSIONS = {
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", # 圖片 ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
".pyc", ".pyo", ".pyd", # Python 編譯檔 ".pyc", ".pyo", ".pyd",
".exe", ".dll", ".so", ".dylib", # 執行檔/函式庫 ".exe", ".dll", ".so", ".dylib",
".zip", ".tar", ".gz", ".rar", # 壓縮檔 ".zip", ".tar", ".gz", ".rar",
".pdf", ".doc", ".docx", # 文件檔 ".pdf", ".doc", ".docx",
".sqlite3", ".db" # 資料庫 ".sqlite3", ".db"
} }
# ⚠️ 排除的特定檔案名稱 (例如腳本自己、隱藏檔等) # ⚠️ 排除的特定檔案名稱
EXCLUDE_FILES = { EXCLUDE_FILES = {
"merge_code.py", # 排除這支腳本自己 "merge_code.py", # 排除這支腳本自己
"all_code.txt", # 排除輸出的檔案 "all_code.txt", # 排除全域輸出的檔案
".DS_Store", # Mac 系統檔 "target_code.txt", # 排除指定輸出的檔案 (新增)
".clineignore", # AI 輔助工具的忽略檔 ".DS_Store",
".clinerules", # AI 輔助工具的規則檔 ".clineignore",
".gitignore", # Git 忽略檔 ".clinerules",
"requirements.txt" # 依賴清單 (通常不需要給 AI 看,除非你要問套件問題) ".gitignore",
"requirements.txt"
} }
def should_process_file(filename): def should_process_file(filename):
"""判斷該檔案是否應該被處理""" """判斷該檔案是否應該被處理 (用於全域掃描)"""
if filename in EXCLUDE_FILES: if filename in EXCLUDE_FILES:
return False return False
@ -52,44 +53,87 @@ def should_process_file(filename):
return True return True
def merge_files(): def merge_all_files():
# 取得當前腳本所在的目錄 (專案根目錄) """模式一:全域掃描 (無參數時觸發)"""
output_file = "all_code.txt"
root_dir = os.path.dirname(os.path.abspath(__file__)) root_dir = os.path.dirname(os.path.abspath(__file__))
with open(OUTPUT_FILE, "w", encoding="utf-8") as outfile: with open(output_file, "w", encoding="utf-8") as outfile:
# 寫入一個總標題
outfile.write("=" * 80 + "\n") outfile.write("=" * 80 + "\n")
outfile.write("PROJECT SOURCE CODE EXPORT\n") outfile.write("PROJECT SOURCE CODE EXPORT (FULL)\n")
outfile.write("=" * 80 + "\n\n") outfile.write("=" * 80 + "\n\n")
# 走訪目錄
for dirpath, dirnames, filenames in os.walk(root_dir): for dirpath, dirnames, filenames in os.walk(root_dir):
# 排除不需要的目錄 (原地修改 dirnames 列表os.walk 就不會進去)
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
for filename in filenames: for filename in filenames:
if should_process_file(filename): if should_process_file(filename):
file_path = os.path.join(dirpath, filename) file_path = os.path.join(dirpath, filename)
# 計算相對路徑 (例如: routers/config.py)
rel_path = os.path.relpath(file_path, root_dir) rel_path = os.path.relpath(file_path, root_dir)
try: try:
with open(file_path, "r", encoding="utf-8") as infile: with open(file_path, "r", encoding="utf-8") as infile:
content = infile.read() content = infile.read()
# 寫入漂亮的檔名標籤
outfile.write("\n" + "=" * 80 + "\n") outfile.write("\n" + "=" * 80 + "\n")
outfile.write(f"FILE: {rel_path}\n") outfile.write(f"FILE: {rel_path}\n")
outfile.write("=" * 80 + "\n") outfile.write("=" * 80 + "\n")
# 寫入檔案內容
outfile.write(content) outfile.write(content)
outfile.write("\n") outfile.write("\n")
print(f"✅ 已合併: {rel_path}") print(f"✅ 已合併: {rel_path}")
except Exception as e: except Exception as e:
print(f"❌ 讀取失敗 {rel_path}: {e}") print(f"❌ 讀取失敗 {rel_path}: {e}")
print(f"\n🎉 合併完成!所有程式碼已儲存至: {OUTPUT_FILE}") print(f"\n🎉 全域合併完成!所有程式碼已儲存至: {output_file}")
def merge_target_files(file_list):
"""模式二:指定檔案合併 (-t 參數觸發)"""
output_file = "target_code.txt"
root_dir = os.path.dirname(os.path.abspath(__file__))
with open(output_file, "w", encoding="utf-8") as outfile:
outfile.write("=" * 80 + "\n")
outfile.write("TARGET SOURCE CODE EXPORT (SPECIFIC)\n")
outfile.write("=" * 80 + "\n\n")
for rel_path in file_list:
file_path = os.path.join(root_dir, rel_path)
if not os.path.exists(file_path):
print(f"❌ 找不到檔案 (已跳過): {rel_path}")
continue
if not os.path.isfile(file_path):
print(f"❌ 不是有效檔案 (已跳過): {rel_path}")
continue
try:
with open(file_path, "r", encoding="utf-8") as infile:
content = infile.read()
outfile.write("\n" + "=" * 80 + "\n")
outfile.write(f"FILE: {rel_path}\n")
outfile.write("=" * 80 + "\n")
outfile.write(content)
outfile.write("\n")
print(f"✅ 已合併: {rel_path}")
except Exception as e:
print(f"❌ 讀取失敗 {rel_path}: {e}")
print(f"\n🎯 指定合併完成!選定的程式碼已儲存至: {output_file}")
if __name__ == "__main__": if __name__ == "__main__":
merge_files() # 使用 argparse 來解析終端機指令
parser = argparse.ArgumentParser(description="合併專案程式碼供 AI 讀取")
parser.add_argument(
'-t', '--target',
nargs='+', # 允許接收一個或多個參數
help="指定要合併的檔案路徑 (例如: -t main.py routers/api.py)"
)
args = parser.parse_args()
# 根據是否有傳入 -t 參數,決定執行哪一種模式
if args.target:
print("🔍 啟動 [指定模式]...")
merge_target_files(args.target)
else:
print("🔍 啟動 [全域模式]...")
merge_all_files()

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

@ -1,25 +1,31 @@
import logging # --- routers/backup.py ---
import asyncssh 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
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
from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖 # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, get_cmts_lock
logger = logging.getLogger(__name__) logger = get_logger("app.backup")
router = APIRouter( router = APIRouter(
prefix="/backups", prefix="/backups",
@ -147,35 +153,74 @@ 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("/snapshot", summary="手動建立設備快照") @router.post("/{backup_id}/toggle-pin", summary="切換快照的釘選防護狀態")
async def create_snapshot(req: SnapshotRequest): async def toggle_backup_pin(backup_id: str):
try: new_state = await toggle_config_backup_pin(backup_id)
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) if new_state is None:
parsed_tree = parse_cli_to_tree(raw_cli) raise HTTPException(status_code=500, detail="資料庫更新失敗")
status_str = "已啟用釘選保護,系統絕不自動淘汰。" if new_state else "已取消釘選,該備份將納入滾動淘汰範圍。"
return {
"status": "success",
"is_pinned": new_state,
"message": f"快照狀態更新成功!{status_str}"
}
backup_id = await insert_config_backup( @router.post("/snapshot", summary="手動建立設備快照 (串流版)")
host=req.host, async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
config_type=req.config_type, async def backup_streamer():
raw_cli=raw_cli, try:
parsed_tree=parsed_tree, pool = await get_pool()
snapshot_name=req.snapshot_name,
description=req.description, # 🟢 將描述傳遞給資料庫函數
is_auto=False
)
if not backup_id: yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"} await asyncio.sleep(0.1)
return { yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
"status": "success", raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id,
"host": req.host
}
except Exception as e: yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
logger.error(f"❌ 建立快照失敗: {e}") await asyncio.sleep(0.1)
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"} parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
backup_id = await insert_config_backup(
host=req.host,
config_type=req.config_type,
raw_cli=raw_cli,
parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name,
description=req.description,
is_auto=False
)
if not backup_id:
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
return
# 🌟 關鍵變更:在同一個串流中「立即執行」清理與指標計算,確保回傳最新數據
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({
"status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id,
"host": req.host,
"metrics": metrics # 🌟 包含容量指標
}) + "\n"
except Exception as e:
logger.error(f"❌ 建立快照失敗: {e}")
yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n"
return StreamingResponse(backup_streamer(), media_type="application/x-ndjson")
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異") @router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
async def analyze_backup_diff(backup_id: str, req: RestoreRequest): async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
@ -197,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",
@ -223,97 +269,111 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
return StreamingResponse(empty_success(), media_type="application/x-ndjson") return StreamingResponse(empty_success(), media_type="application/x-ndjson")
async def restore_generator(): async def restore_generator():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = get_cmts_lock[req.host]
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令) # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
if cmts_config_lock.locked(): if host_lock.locked():
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n" yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
return return
async with cmts_config_lock: async with host_lock:
try: try:
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n" yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
conn = await asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') # 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
# 先用 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 def read_until_quiet(timeout=1.0): async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
output = "" output = ""
while True: while True:
try: try:
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()
except asyncio.TimeoutError: # 🌟 精準 Prompt 偵測
break if prompt_pattern and re.search(prompt_pattern, output):
return output break
except asyncio.TimeoutError:
break
return output
# 2. 進入設定模式 # 2. 進入設定模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5)
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
# 3. 逐行寫入並進行 Fail-safe 檢查
for cmd in commands:
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
out = await read_until_quiet(timeout=0.2)
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
# 1. 先通知前端正在撤銷
yield json.dumps({
"status": "progress",
"message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
}) + "\n"
# 2. 對設備下達 abort 指令
process.stdin.write("abort\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式 await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
# 3. 逐行寫入並進行 Fail-safe 檢查
for cmd in commands:
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
# 1. 先通知前端正在撤銷
yield json.dumps({
"status": "progress",
"message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
}) + "\n"
# 2. 對設備下達 abort 指令
process.stdin.write("abort\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
# 3. 回報最終錯誤並關閉連線
yield json.dumps({
"status": "error",
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
"output": clean_out
}) + "\n"
return
yield json.dumps({
"status": "progress",
"message": f"執行: {cmd}",
"output": clean_out
}) + "\n"
# 稍微讓出控制權,確保串流順暢
await asyncio.sleep(0.05)
# 4. 提交變更 (Commit)
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write("commit\n")
await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
# 清理 Commit 回傳的雜訊
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 3. 回報最終錯誤並關閉連線 # 5. 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
# 🌟 移除手動 conn.close(),交由 async with 處理
yield json.dumps({ yield json.dumps({
"status": "error", "status": "success",
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}", "message": "✅ 所有差異還原指令已成功送出並 Commit",
"output": clean_out "output": clean_commit
}) + "\n" }) + "\n"
conn.close()
return
yield json.dumps({
"status": "progress",
"message": f"執行: {cmd}",
"output": clean_out
}) + "\n"
# 稍微讓出控制權,確保串流順暢
await asyncio.sleep(0.05)
# 4. 提交變更 (Commit)
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write("commit\n")
await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0)
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
conn.close()
yield json.dumps({
"status": "success",
"message": "✅ 所有差異還原指令已成功送出並 Commit",
"output": clean_commit
}) + "\n"
except Exception as e: except Exception as e:
logger.error(f"還原執行失敗: {str(e)}") logger.error(f"還原執行失敗: {str(e)}")
yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n" yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n"
return StreamingResponse(restore_generator(), media_type="application/x-ndjson") return StreamingResponse(restore_generator(), media_type="application/x-ndjson")

View File

@ -9,10 +9,11 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
from netmiko import ConnectHandler from netmiko import ConnectHandler
# 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制 # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import CMTS_DEVICE, cmts_config_lock, 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
router = APIRouter() router = APIRouter()
@ -25,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) return db_filters if db_filters is not None else []
if db_filters is not None: except Exception as e:
return db_filters print(f"資料庫讀取過濾器發生例外: {e}")
else: return []
print("⚠️ [Fallback] 資料庫無法取得過濾器,自動切換至 JSON 快取讀取...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫讀取過濾器發生例外: {e},自動切換至 JSON 快取讀取...")
file_path = get_filter_file_path(config_type)
if os.path.exists(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("hidden_keys", [])
except Exception as e:
print(f"讀取過濾器檔案失敗: {e}")
return []
return []
async def save_tree_filters(config_type: str, hidden_keys: list): async def save_tree_filters(config_type: str, hidden_keys: list):
db_success = False try:
if USE_DB: await database.upsert_tree_filters(config_type, hidden_keys)
try: except Exception as e:
if await database.upsert_tree_filters(config_type, hidden_keys): print(f"資料庫寫入過濾器發生例外: {e}")
db_success = True
else:
print("⚠️ [Fallback] 資料庫儲存過濾器失敗,自動切換至 JSON 快取寫入...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入過濾器發生例外: {e},自動切換至 JSON 快取寫入...")
if not db_success:
file_path = get_filter_file_path(config_type)
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump({"hidden_keys": hidden_keys}, f, ensure_ascii=False, indent=4)
except Exception as e:
print(f"儲存過濾器檔案失敗: {e}")
# ========================================== # ==========================================
@ -89,22 +63,23 @@ async def execute_cmts_config(req: ConfigRequest):
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')] commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
async def config_streamer(): async def config_streamer():
# 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行 # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
async with cmts_config_lock: host_lock = get_cmts_lock[req.host]
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:
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死) # 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
async def read_until_quiet(timeout=0.5): async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
out_data = "" out_data = ""
while True: while True:
try: try:
@ -113,6 +88,9 @@ async def execute_cmts_config(req: ConfigRequest):
if not chunk: if not chunk:
break break
out_data += str(chunk) out_data += str(chunk)
# 🌟 精準 Prompt 偵測
if prompt_pattern and re.search(prompt_pattern, out_data):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
break break
return out_data return out_data
@ -120,7 +98,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 1. 進入設定模式 # 1. 進入設定模式
process.stdin.write("config\n") process.stdin.write("config\n")
await process.stdin.drain() await process.stdin.drain()
await read_until_quiet(0.5) # 清空歡迎訊息 await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
# 2. 逐行送出指令並回報進度 # 2. 逐行送出指令並回報進度
for cmd in commands: for cmd in commands:
@ -130,7 +108,7 @@ async def execute_cmts_config(req: ConfigRequest):
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒 # ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
wait_time = 5.0 if cmd.strip() == "commit" else 0.5 wait_time = 5.0 if cmd.strip() == "commit" else 0.5
output = await read_until_quiet(wait_time) output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort # 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower() lower_output = output.lower()
@ -148,7 +126,7 @@ async def execute_cmts_config(req: ConfigRequest):
# 3. 退出設定模式 # 3. 退出設定模式
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
final_output = await read_until_quiet(1.0) final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
yield json.dumps({ yield json.dumps({
"status": "success", "status": "success",
@ -235,6 +213,7 @@ async def generate_cli(req: GenerateCliRequest):
@router.get("/cmts-ds-rf-port-list") @router.get("/cmts-ds-rf-port-list")
async def get_ds_rf_port_list(host: str, username: str, password: str = ""): async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
"""獲取設備上所有的 DS RF Port 清單""" """獲取設備上所有的 DS RF Port 清單"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -242,7 +221,6 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = 'show running-config | include "cable ds-rf-port"' command = 'show running-config | include "cable ds-rf-port"'
raw_output = str(net_connect.send_command(command, read_timeout=60)) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
pattern = r"cable ds-rf-port\s+([0-9:/]+)" pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output) ports = re.findall(pattern, raw_output)
@ -255,11 +233,15 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
} }
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-ds-rf-port-config") @router.get("/cmts-ds-rf-port-config")
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""): async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON""" """獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -267,10 +249,10 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore" command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=60)) raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
base_tree = parse_cli_to_tree(raw_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
return { return {
"status": "success", "status": "success",
@ -279,11 +261,15 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
} }
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config") @router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"): async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON""" """獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
net_connect = None
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
@ -299,13 +285,12 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
command = "show running-config | nomore" command = "show running-config | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=180)) raw_output = str(net_connect.send_command(command, read_timeout=180))
net_connect.disconnect()
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE) clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip() clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ========================================== # ==========================================
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯 # 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
@ -331,12 +316,16 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "success", "data": perfect_tree} return {"status": "success", "data": perfect_tree}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config/stream") @router.get("/cmts-full-config/stream")
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"): async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置 (串流進度回報版)""" """獲取整台 CMTS 的完整配置 (串流進度回報版)"""
async def config_streamer(): async def config_streamer():
net_connect = None
try: try:
# 1. 廣播:正在連線 # 1. 廣播:正在連線
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n" yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
@ -360,8 +349,6 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
command = "show running-config | nomore" command = "show running-config | nomore"
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180) raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
await asyncio.to_thread(net_connect.disconnect)
# 3. 廣播:正在解析 # 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n" yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -369,8 +356,9 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE) clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip() clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output) # 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
perfect_tree = deep_split_tree(base_tree) base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ========================================== # ==========================================
# 🌟 深度過濾攔截器 (維持原樣) # 🌟 深度過濾攔截器 (維持原樣)
@ -399,6 +387,10 @@ async def get_full_config_stream(host: str, username: str, password: str = "", s
except Exception as e: except Exception as e:
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n" yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
finally:
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
if net_connect:
await asyncio.to_thread(net_connect.disconnect)
# 回傳 NDJSON 串流格式 # 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson") return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
@ -423,3 +415,23 @@ async def update_tree_filters(req: SettingsRequest):
# 🌟 根據 config_type 寫入對應的 JSON # 🌟 根據 config_type 寫入對應的 JSON
await save_tree_filters(req.config_type, req.hidden_keys) await save_tree_filters(req.config_type, req.hidden_keys)
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"} return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
# ==========================================
# 🌟 伺服器日誌管控 API (Server Log Management)
# ==========================================
class LogLevelRequest(BaseModel):
module: str
level: str
@router.get("/settings/logs")
async def api_get_log_levels():
"""獲取目前所有模組的日誌等級"""
return {"status": "success", "data": get_all_log_levels()}
@router.post("/settings/logs")
async def api_set_log_level(req: LogLevelRequest):
"""動態修改特定模組的日誌等級"""
success = set_log_level(req.module, req.level)
if success:
return {"status": "success", "message": f"已將 {req.module} 的日誌等級設為 {req.level}"}
raise HTTPException(status_code=400, detail="未知的模組名稱")

158
routers/diagnostics.py Normal file
View File

@ -0,0 +1,158 @@
# --- routers/diagnostics.py ---
import asyncio
import re
import asyncssh
from logger import get_logger
from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any
logger = get_logger("app.diagnostics")
router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"])
@router.get("/")
async def get_cm_diagnostics(
host: str = Query(...),
username: str = Query(...),
password: str = Query(...),
mac: str = Query(..., description="Cable Modem MAC Address")
) -> Dict[str, Any]:
mac = mac.strip().lower()
if not mac:
raise HTTPException(status_code=400, detail="MAC Address is required")
result_data = {
"mac": mac,
"ip": "N/A",
"state": "N/A",
"cpe_count": 0,
"phy": {
"tx_power": None,
"rx_power": None
},
"upstreams": [],
"mer_channels": {}
}
try:
# 先用 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}")
# ==========================================
# 1. 查詢基本狀態
# ==========================================
cmd_base = "show cable modem " + mac + " | nomore"
res_base = await conn.run(cmd_base, check=False)
if res_base.exit_status == 0 and res_base.stdout:
for line in res_base.stdout.splitlines():
if mac in line.lower():
tokens = line.split()
for t in tokens:
if re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$", t):
result_data["ip"] = t
elif re.match(r"^[a-z]-online|online|offline|init.*|reject", t, re.IGNORECASE):
result_data["state"] = t
nums = [t for t in tokens if t.isdigit()]
if nums:
result_data["cpe_count"] = int(nums[-1])
break
# ==========================================
# 2. 查詢 PHY 狀態 (💡 修復:移除寫死的 us/oad 判斷,改用通用特徵)
# ==========================================
cmd_phy = "show cable modem " + mac + " phy | nomore"
res_phy = await conn.run(cmd_phy, check=False)
if res_phy.exit_status == 0 and res_phy.stdout:
tx_list = []
rx_list = []
for line in res_phy.stdout.splitlines():
line_lower = line.lower()
if mac in line_lower:
tokens = line.split()
if len(tokens) >= 6:
ch_name = tokens[1]
# 💡 只要名稱包含 ":" 和 "/",就認定是合法的通道 (例如 Oa32, Oad32, Us32)
if ":" in ch_name and "/" in ch_name:
try:
snr_val = float(tokens[4])
result_data["upstreams"].append({"channel": ch_name, "snr": snr_val})
except:
pass
try:
tx_val = float(tokens[3].split('/')[0])
rx_list_val = float(tokens[5].split('/')[0])
tx_list.append(tx_val)
rx_list.append(rx_list_val)
except:
pass
if tx_list: result_data["phy"]["tx_power"] = round(sum(tx_list)/len(tx_list), 2)
if rx_list: result_data["phy"]["rx_power"] = round(sum(rx_list)/len(rx_list), 2)
# ==========================================
# 3. 尋找所有 OFDM 通道
# ==========================================
cmd_help = "show cable modem " + mac + " ofdm-channel ?"
res_help = await conn.run(cmd_help, check=False)
text_to_search = (res_help.stdout or "") + "\n" + (res_help.stderr or "")
cmd_verb = "show cable modem " + mac + " verbose | nomore"
res_verb = await conn.run(cmd_verb, check=False)
text_to_search += "\n" + (res_verb.stdout or "")
matches = re.findall(r"\b(Of(?:dm)?\d*[\d/:]+)\b", text_to_search, re.IGNORECASE)
ofdm_channels = []
for m in matches:
clean_m = m.lower()
if clean_m.startswith("of"):
clean_m = "Of" + clean_m[2:]
if clean_m not in ofdm_channels:
ofdm_channels.append(clean_m)
ofdm_channels.sort()
# ==========================================
# 4. 對每一個 OFDM 通道查詢 MER
# ==========================================
for ch in ofdm_channels:
cmd_mer = "show cable modem " + mac + " ofdm-channel " + ch + " mer | nomore"
res_mer = await conn.run(cmd_mer, check=False)
histogram = {}
min_mer = 999
if res_mer.exit_status == 0 and res_mer.stdout:
mer_lines = re.finditer(r"(\d+)\s*(?:db|dB)?\s*\|[^|]*\|?\s*(\**)", res_mer.stdout, re.IGNORECASE)
for match in mer_lines:
db_val = match.group(1)
stars = match.group(2)
if stars:
histogram[db_val] = len(stars) * 100
db_int = int(db_val)
if db_int < min_mer:
min_mer = db_int
health = "unknown"
if histogram and min_mer != 999:
if min_mer >= 41: health = "good"
elif min_mer >= 38: health = "warning"
else: health = "critical"
result_data["mer_channels"][ch] = {
"histogram": histogram,
"health": health,
"min_mer": min_mer if min_mer != 999 else "N/A"
}
logger.debug(f"✅ [Diagnostics] 解析結果: {result_data}")
return {"status": "success", "data": result_data}
except Exception as e:
logger.error("CM Diagnostics Error: " + str(e))
raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e))

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:
@ -54,86 +41,58 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
if await request.is_disconnected(): if await request.is_disconnected():
break break
try: try:
message = await asyncio.wait_for(client_queue.get(), timeout=1.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:
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:
db_options = await database.get_all_leaf_options(host, config_type)
if db_options is not None:
# 取得 metadata
metadata = await database.get_device_status(host, config_type)
if metadata:
db_options["__metadata__"] = metadata
return db_options
else:
# 若回傳 None 表示 DB 連線異常,執行 Fallback
print("⚠️ [Fallback] 資料庫無法取得資料,自動切換至 JSON 快取讀取...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫讀取發生例外: {e},自動切換至 JSON 快取讀取...")
cache_file = get_cache_file(host, config_type)
if not os.path.exists(cache_file): return {}
# 🌟 優化 3將讀取動作丟到背景執行緒
def read_json_from_file(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
return await asyncio.to_thread(read_json_from_file, cache_file)
async def run_scan_task(host: str, username: str, password: str, paths: List[str], config_type: str):
"""在背景執行的爬蟲任務,並將進度廣播出去"""
global SCAN_STATUS
channel_key = f"{host}_{config_type}"
try: try:
await broadcast_message({"event": "scan_start"}, host, config_type) db_options = await database.get_all_leaf_options(host)
if db_options is not None:
# 呼叫爬蟲 (帶入設備連線資訊與 config_type) metadata = await database.get_device_status(host)
async for chunk in sync_cmts_leaves_async( if metadata:
host=host, db_options["__metadata__"] = metadata
username=username, return db_options
password=password, raise HTTPException(status_code=500, detail="資料庫連線異常")
leaf_paths=paths,
config_type=config_type
):
if isinstance(chunk, str):
await broadcast_message(json.loads(chunk), host, config_type)
else:
await broadcast_message(chunk, host, config_type)
await broadcast_message({"event": "done"}, host, config_type)
except Exception as e: except Exception as e:
await broadcast_message({"event": "error", "message": str(e)}, host, config_type) raise HTTPException(status_code=500, detail=f"資料庫讀取失敗: {e}")
async def run_scan_task(host: str, username: str, password: str, paths: List[str]):
global SCAN_STATUS
channel_key = host
try:
await broadcast_message({"event": "scan_start"}, host)
async for chunk in sync_cmts_leaves_async(host=host, username=username, password=password, leaf_paths=paths):
if isinstance(chunk, str):
await broadcast_message(json.loads(chunk), host)
else:
await broadcast_message(chunk, host)
await broadcast_message({"event": "done"}, host)
except Exception as e:
await broadcast_message({"event": "error", "message": str(e)}, host)
finally: finally:
# 解除特定頻道的鎖定
SCAN_STATUS[channel_key] = False SCAN_STATUS[channel_key] = False
@router.post("/cmts-leaf-options/sync") @router.post("/cmts-leaf-options/sync")
@ -142,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("::", " ")
@ -157,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:
db_cleared = await database.delete_leaf_options(host, config_type, paths_to_clear)
if db_cleared >= 0:
cleared_count = db_cleared
else:
print("⚠️ [Fallback] 資料庫清除失敗,自動切換至 JSON 快取清除...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫清除發生例外: {e},自動切換至 JSON 快取清除...")
# 2. 清除 JSON (如果 DB 沒清掉,或 USE_DB=False或者是連同舊檔一起清確保乾淨)
cache_file = get_cache_file(host, config_type)
if not os.path.exists(cache_file):
# 如果是走 DB且有清掉回傳 DB 的結果
if USE_DB and cleared_count > 0:
return {"status": "success", "cleared_count": cleared_count}
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
try: try:
with open(cache_file, "r", encoding="utf-8") as f: cleared_count = await database.delete_leaf_options(host, paths_to_clear)
cache_data = json.load(f) if cleared_count >= 0:
return {"status": "success", "cleared_count": cleared_count}
json_cleared_count = 0 raise HTTPException(status_code=500, detail="資料庫清除失敗")
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)}"}

View File

@ -9,11 +9,12 @@ router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ========================================== # ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table) # 💡 全域鎖定表 (In-Memory Lock Table)
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} } # 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
# ⚠️ 嚴格規則Lock Key 絕對不包含 config_type確保 running 與 full 視圖共用同一把鎖!
# ========================================== # ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {} ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120 LOCK_TIMEOUT = 120
# 🌟 新增 host 欄位 # 🌟 請求模型嚴格排除 config_type
class LockAcquireReq(BaseModel): class LockAcquireReq(BaseModel):
host: str host: str
path: str path: str
@ -62,6 +63,7 @@ async def acquire_lock(req: LockAcquireReq):
if conflict_lock: if conflict_lock:
raise HTTPException(status_code=409, detail=error_msg) raise HTTPException(status_code=409, detail=error_msg)
# ⚠️ 嚴格綁定 host 與 path跨視圖共用
lock_key = f"{req.host}@@{req.path}" lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[lock_key] = { ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id, "user_id": req.user_id,

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

@ -1,55 +1,106 @@
# --- routers/query.py --- # --- routers/query.py ---
import re import re
import asyncssh
import asyncio
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from netmiko import ConnectHandler
from shared import CMTS_DEVICE from shared import CMTS_DEVICE
router = APIRouter() router = APIRouter()
def parse_cm_output(raw_text: str) -> list: # ==========================================
parsed_data = [] # 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko)
lines = raw_text.splitlines() # ==========================================
data_started = False
for line in lines:
if not line.strip(): continue
if line.strip().startswith('---'):
data_started = True
continue
if line.strip().startswith('==='): break
if data_started:
columns = re.split(r'\s{2,}', line.strip())
if len(columns) >= 8:
cm_info = {
"downstream": columns[0], "upstream": columns[1],
"bond_cap": columns[2], "ofdm_cap": columns[3],
"mac_address": columns[4], "ip_address": columns[5],
"num_cpe": int(columns[6]), "state": columns[7]
}
parsed_data.append(cm_info)
return parsed_data
@router.get("/cable-modems") async def execute_single_command(host, username, password, command, timeout=15.0):
async def get_cable_modems(): """
執行單一查詢指令 (適用於 90% 的標準 show 指令)
自動防呆確保指令帶有取消分頁的後綴防止 Event Loop 卡死
"""
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
raw_output = str(net_connect.send_command("show cable modem")) conn = await asyncio.wait_for(
net_connect.disconnect() asyncssh.connect(host, username=username, password=password, known_hosts=None),
structured_data = parse_cm_output(raw_output) timeout=10.0
return {"status": "success", "total_count": len(structured_data), "data": structured_data} )
# 再進入 async with 確保資源會被自動關閉
async with conn:
# 確保指令包含取消分頁的參數
if "| nomore" not in command.lower():
command += " | nomore"
result = await conn.run(command, check=False, timeout=timeout)
return result.stdout or ""
except asyncssh.Error as e:
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
except asyncio.TimeoutError:
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}") raise Exception(f"SSH Unexpected Error: {str(e)}")
@router.get("/configuration") async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
async def get_configuration(): """
執行互動式指令 (適用於需要進入 config 模式或使用 '?' 觸發補全的指令)
動態處理終端機的 --More-- 提示
"""
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) # 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
net_connect.send_command_timing("config") conn = await asyncio.wait_for(
raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120) asyncssh.connect(host, username=username, password=password, known_hosts=None),
net_connect.send_command_timing("exit") timeout=10.0
net_connect.disconnect() )
return {"status": "success", "data": raw_output} # 再進入 async with 確保資源會被自動關閉
async with conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
async def read_until_quiet(wait_time):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=wait_time)
if not chunk: break
output += chunk
# 動態處理分頁符號
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
# 1. 清除登入 MOTD
await read_until_quiet(1.5)
# 2. 執行取消分頁指令 (雙重保險)
process.stdin.write("terminal length 0\n")
await process.stdin.drain()
await read_until_quiet(0.5)
# 3. 執行目標指令
final_output = ""
for cmd in commands:
process.stdin.write(cmd)
await process.stdin.drain()
final_output += await read_until_quiet(timeout)
# 4. 退出 session
process.stdin.write("exit\n")
await process.stdin.drain()
# 清理 ANSI 控制碼與退格鍵雜訊
clean_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', final_output)
return clean_output
except asyncssh.Error as e:
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
except asyncio.TimeoutError:
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}") raise Exception(f"SSH Unexpected Error: {str(e)}")
# ==========================================
# 🚀 API 路由
# ==========================================
@router.get("/cmts-query") @router.get("/cmts-query")
async def get_cmts_query( async def get_cmts_query(
@ -60,8 +111,6 @@ async def get_cmts_query(
password: str = Query(...) password: str = Query(...)
): ):
try: try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
target_str = f" {target.strip()}" if target.strip() else "" target_str = f" {target.strip()}" if target.strip() else ""
commands = { commands = {
@ -105,9 +154,7 @@ async def get_cmts_query(
raise ValueError(f"未知的查詢類型: {query_type}") raise ValueError(f"未知的查詢類型: {query_type}")
cli_command = commands[query_type] + " | nomore" cli_command = commands[query_type] + " | nomore"
net_connect = ConnectHandler(**device) raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
raw_output = net_connect.send_command(cli_command, read_timeout=15)
net_connect.disconnect()
return {"status": "success", "command": cli_command, "data": raw_output} return {"status": "success", "command": cli_command, "data": raw_output}
except Exception as e: except Exception as e:
@ -116,13 +163,8 @@ async def get_cmts_query(
@router.get("/cmts-mac-domain-config") @router.get("/cmts-mac-domain-config")
async def get_mac_domain_config(target: str, host: str, username: str, password: str): async def get_mac_domain_config(target: str, host: str, username: str, password: str):
try: try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore" cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
net_connect = ConnectHandler(**device) raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
net_connect.disconnect()
# 💡 正名工程:更新字典的 Key 為一致性的命名 # 💡 正名工程:更新字典的 Key 為一致性的命名
config = { config = {
@ -186,14 +228,10 @@ async def get_cmts_mac_domain_list(
password: str = Query(...) password: str = Query(...)
): ):
try: try:
device = CMTS_DEVICE.copy() # 這裡使用 '?' 觸發補全,必須使用互動式引擎
device.update({'host': host, 'username': username, 'password': password}) commands = ["show running-config cable mac-domain ?"]
net_connect = ConnectHandler(**device) raw_output = await execute_interactive_command(host, username, password, commands, timeout=5.0)
raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
net_connect.disconnect()
import re
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output) matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
mac_domains = list(dict.fromkeys(matches)) mac_domains = list(dict.fromkeys(matches))
@ -208,14 +246,8 @@ async def get_cmts_version(
password: str = Query(...) password: str = Query(...)
): ):
try: try:
device = CMTS_DEVICE.copy() raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0)
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command("show version", read_timeout=15))
net_connect.disconnect()
import re
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號 # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output) match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
@ -226,3 +258,50 @@ async def get_cmts_version(
except Exception as e: except Exception as e:
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"} return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
async def list_cms(host: str, username: str, password: str):
try:
raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
if not raw_output:
return {"cms": []}
# 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
macs = []
for line in raw_output.splitlines():
match = mac_pattern.search(line)
if match:
macs.append(match.group(1).lower())
# 去重複並排序
return {"cms": sorted(list(set(macs)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
async def list_rpds(host: str, username: str, password: str):
try:
raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
if not raw_output:
return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = []
for line in raw_output.splitlines():
match = vcvs_pattern.search(line)
if match:
rpds.append(match.group(1))
return {"rpds": sorted(list(set(rpds)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
return {"rpds": []}

View File

@ -1,17 +1,24 @@
import asyncio import asyncio
import asyncssh import asyncssh
import traceback
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from logger import get_logger
logger = get_logger("app.ssh")
router = APIRouter() router = APIRouter()
@router.websocket("/ws/terminal") @router.websocket("/ws/terminal")
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str): async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
await websocket.accept() await websocket.accept()
conn = None conn = None
process = None
ws_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(
@ -25,14 +32,15 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
while True: while True:
data_bytes = await process.stdout.read(8192) data_bytes = await process.stdout.read(8192)
if not data_bytes: if not data_bytes:
print("[DEBUG] 設備端主動關閉了 stdout 通道") logger.debug("設備端主動關閉了 stdout 通道")
break break
safe_text = data_bytes.decode('utf-8', errors='replace') safe_text = data_bytes.decode('utf-8', errors='replace')
await websocket.send_text(safe_text) await websocket.send_text(safe_text)
except asyncio.CancelledError:
pass
except Exception as e: except Exception as e:
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:") logger.error(f"❌ [WS Forward Error] 讀取設備畫面時發生錯誤: {str(e)}")
traceback.print_exc()
async def forward_to_ssh(): async def forward_to_ssh():
try: try:
@ -47,10 +55,15 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
process.stdin.write(data_bytes) process.stdin.write(data_bytes)
await process.stdin.drain() await process.stdin.drain()
except WebSocketDisconnect: except WebSocketDisconnect:
# 主動拋出,讓外層捕捉以進行資源回收
# raise
# 🌟 修正:不要再 raise 拋出去了,直接 return 結束任務,讓外層自然回收
logger.debug("前端 WebSocket 正常斷開 (使用者重整或關閉網頁)")
return
except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:") logger.error(f"❌ [SSH Forward Error] 寫入指令到設備時發生錯誤: {str(e)}")
traceback.print_exc()
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())
@ -63,18 +76,34 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
for task in pending: for task in pending:
task.cancel() task.cancel()
except WebSocketDisconnect:
logger.debug("WebSocket 正常斷線,正在終止背景任務...")
if ws_task and not ws_task.done():
ws_task.cancel()
if ssh_task and not ssh_task.done():
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
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:") logger.error(f"❌ [Connection Error] 建立連線或執行過程中發生錯誤: {str(e)}")
traceback.print_exc() return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process:
process.close()
if conn: if conn:
conn.close() conn.close()
try: try:
await websocket.close() await websocket.close()
except: except:
pass pass

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) 隔離的鎖 (安全動態獲取版)
cmts_config_lock = 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) {
@ -151,40 +148,93 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
return response.json(); return response.json();
} }
// 🌟 新增:動態抓取設備清單 API
export async function apiListCms(host, username, password) {
const url = `/api/v1/list-cms?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiListRpds(host, username, password) {
const url = `/api/v1/list-rpds?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
// ========================================== // ==========================================
// 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);
}
} }
} }
} }
export async function apiGetCmDiagnostics(host, username, password, mac) {
const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`;
const response = await fetch(url);
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "伺服器發生錯誤");
}
return response.json();
}
export async function apiGetLogLevels() {
const response = await fetch('/api/v1/settings/logs');
return response.json();
}
export async function apiSetLogLevel(module, level) {
const response = await fetch('/api/v1/settings/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module, level })
});
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();
}

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,9 @@ function escapeHTML(str) {
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {}; const ACTIVE_HEARTBEATS = {};
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
const activeEditSessions = new Set();
export let currentEditPath = null; export let currentEditPath = null;
export let currentEditElementId = null; export let currentEditElementId = null;
export let currentEditIsSuccess = false; export let currentEditIsSuccess = false;
@ -32,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('@@');
@ -43,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) {
@ -69,16 +86,46 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
// ========================================== // ==========================================
export async function startEditFolder(path, elementId) { export async function startEditFolder(path, elementId) {
if (activeEditSessions.has(elementId)) return;
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}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
editBtn.innerText = "⏳";
}
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host);
}
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️";
}
return;
}
if (result.status === 409) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
return alert(`⚠️ ${result.data.detail}`);
}
if (result.data.status === 'success') { if (result.data.status === 'success') {
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
@ -86,7 +133,30 @@ 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;
const editBtn = document.getElementById(`edit-btn-${elementId}`); if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
const btnPath = btn.getAttribute('data-path');
if (btnPath === path) {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
} else {
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
}
btn.innerText = "🔒";
}
});
if (editBtn) editBtn.style.display = 'none'; if (editBtn) editBtn.style.display = 'none';
const actionBtn = document.getElementById(`actions-${elementId}`); const actionBtn = document.getElementById(`actions-${elementId}`);
@ -95,22 +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) {}
if (!activeEditSessions.has(elementId)) return;
const pathsToSync = []; const pathsToSync = [];
const containersArray = Array.from(leafContainers); const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50;
// 🌟 第一階段:盤點缺少的快取
containersArray.forEach(container => {
const rawPath = container.getAttribute('data-path');
const origVal = container.getAttribute('data-original');
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (!optData[cacheKey]) pathsToSync.push(cacheKey);
});
// 🌟 第二階段:如果有缺,觸發同步並「等待」
if (pathsToSync.length > 0) {
// 先讓所有欄位顯示載入中
containersArray.forEach(c => c.innerHTML = `<span style="font-size: 12px; color: #f39c12; font-weight: bold;">⏳ 載入選項中...</span>`);
apiSyncLeafOptions(host, pathsToSync);
for (let i = 0; i < 10; i++) {
if (!activeEditSessions.has(elementId)) return;
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host);
// 檢查是否至少抓到一部分了 (只要有 hint 或 options 就當作抓到了)
const isMakingProgress = pathsToSync.some(p => optData[p] && (optData[p].hint || (optData[p].options && optData[p].options.length > 0)));
if (isMakingProgress && i > 2) break; // 給它至少 3 秒,有進度就放行,避免死等
}
}
if (!activeEditSessions.has(elementId)) return;
// 🌟 第三階段:正式渲染輸入框與 ❓
const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { 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 => {
@ -122,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 = "";
@ -153,25 +260,58 @@ 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);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
} }
} }
export async function startEditLeaf(path, elementId) { export async function startEditLeaf(path, elementId) {
if (activeEditSessions.has(elementId)) return;
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}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
editBtn.innerText = "⏳";
}
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host);
}
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️";
}
return;
}
if (result.status === 409) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
return alert(`⚠️ ${result.data.detail}`);
}
if (result.data.status === 'success') { if (result.data.status === 'success') {
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
@ -179,34 +319,56 @@ export async function startEditLeaf(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;
document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
btn.title = `🔒 您正在另一個視圖編輯此項目`;
btn.innerText = "🔒";
}
});
if (editBtn) editBtn.style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`; container.innerHTML = `<span style="font-size: 12px; color: #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;
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;
let inputHtml = ""; let inputHtml = "";
let hintAttr = ""; let hintAttr = "";
let hintIcon = ""; let hintIcon = "";
@ -233,15 +395,23 @@ export async function startEditLeaf(path, elementId) {
} }
container.innerHTML = inputHtml; container.innerHTML = inputHtml;
} catch (e) { } catch (e) {
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;">`;
} }
} }
} catch (error) { } catch (error) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
} }
} }
export async function cancelEditFolder(path, elementId) { export async function cancelEditFolder(path, elementId) {
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
@ -250,10 +420,59 @@ export async function cancelEditFolder(path, elementId) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]); clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey]; delete ACTIVE_HEARTBEATS[lockKey];
} }
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
document.getElementById(`actions-${elementId}`).style.display = 'none'; if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
delete window.globalActiveLocks[host][path];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
const btnPath = btn.getAttribute('data-path');
let stillLockedByOther = false;
// 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點
if (window.globalActiveLocks && window.globalActiveLocks[host]) {
const hostLocks = window.globalActiveLocks[host];
if (hostLocks[btnPath]) {
stillLockedByOther = true;
} else {
for (const lockedPath of Object.keys(hostLocks)) {
if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true;
break;
}
}
}
}
if (!stillLockedByOther) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
});
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.display = 'inline-block';
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
}
const actionBtns = document.getElementById(`actions-${elementId}`);
if (actionBtns) actionBtns.style.display = 'none';
const contentDiv = document.getElementById(`content-${elementId}`); const contentDiv = document.getElementById(`content-${elementId}`);
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
@ -262,9 +481,33 @@ export async function cancelEditFolder(path, elementId) {
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`; container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
}); });
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
if (currentEditElementId === elementId) {
document.querySelectorAll('.tree-view-instance').forEach(pane => {
pane.style.flex = '1';
pane.style.width = '100%';
pane.style.maxWidth = 'none';
});
const rightPane = document.getElementById('side-cli-preview');
if (rightPane) {
rightPane.style.flex = '1';
rightPane.style.maxWidth = '50%';
rightPane.style.width = '';
rightPane.style.display = 'none';
}
document.getElementById('drag-resizer').style.display = 'none';
currentEditPath = null;
currentEditElementId = null;
currentEditIsSuccess = false;
currentEditType = null;
}
} }
export async function cancelEditLeaf(path, elementId) { export async function cancelEditLeaf(path, elementId) {
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
@ -273,15 +516,65 @@ export async function cancelEditLeaf(path, elementId) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]); clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey]; delete ACTIVE_HEARTBEATS[lockKey];
} }
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
document.getElementById(`actions-${elementId}`).style.display = 'none'; if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
delete window.globalActiveLocks[host][path];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
});
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.display = 'inline-block';
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
}
const actionBtns = document.getElementById(`actions-${elementId}`);
if (actionBtns) actionBtns.style.display = 'none';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`; container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
if (currentEditElementId === elementId) {
document.querySelectorAll('.tree-view-instance').forEach(pane => {
pane.style.flex = '1';
pane.style.width = '100%';
pane.style.maxWidth = 'none';
});
const rightPane = document.getElementById('side-cli-preview');
if (rightPane) {
rightPane.style.flex = '1';
rightPane.style.maxWidth = '50%';
rightPane.style.width = '';
rightPane.style.display = 'none';
}
document.getElementById('drag-resizer').style.display = 'none';
currentEditPath = null;
currentEditElementId = null;
currentEditIsSuccess = false;
currentEditType = null;
}
} }
// ========================================== // ==========================================
@ -396,8 +689,16 @@ function showCliPreviewModal(cliScript) {
textarea.style.backgroundColor = '#1e1e1e'; textarea.style.backgroundColor = '#1e1e1e';
textarea.style.color = '#ecf0f1'; textarea.style.color = '#ecf0f1';
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)'); // 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
leftPanes.forEach(pane => {
pane.style.flex = 'none';
pane.style.maxWidth = 'none';
pane.style.width = 'calc(50% - 11px)';
});
rightPane.style.flex = 'none';
rightPane.style.maxWidth = 'none';
rightPane.style.width = 'calc(50% - 11px)'; rightPane.style.width = 'calc(50% - 11px)';
resizer.style.display = 'flex'; resizer.style.display = 'flex';
rightPane.style.display = 'block'; rightPane.style.display = 'block';
@ -411,10 +712,24 @@ function showCliPreviewModal(cliScript) {
// 4. 側邊欄與執行邏輯 // 4. 側邊欄與執行邏輯
// ========================================== // ==========================================
export async function hideSideCLI() { export async function hideSideCLI() {
document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%'); // 🌟 完美修復:明確恢復 Flexbox 伸展能力與 100% 寬度
document.querySelectorAll('.tree-view-instance').forEach(pane => {
pane.style.flex = '1'; // 恢復 HTML 原本賦予的彈性伸展能力
pane.style.width = '100%'; // 強制撐滿整個父容器
pane.style.maxWidth = 'none';
});
const rightPane = document.getElementById('side-cli-preview');
if (rightPane) {
rightPane.style.flex = '1';
rightPane.style.maxWidth = '50%'; // 恢復 HTML 原本的 50% 限制
rightPane.style.width = '';
rightPane.style.display = 'none';
}
document.getElementById('drag-resizer').style.display = 'none'; document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none';
// 以下為原本的取消編輯與還原邏輯,完全保持不變,確保功能安全
if (currentEditIsSuccess && currentEditElementId && currentEditPath) { if (currentEditIsSuccess && currentEditElementId && currentEditPath) {
const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`); const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`);
if (wrapper) { if (wrapper) {
@ -462,7 +777,7 @@ async function applyEditLeaf(path, elementId) {
export function executeSideCLI() { export function executeSideCLI() {
const finalScript = document.getElementById('side-cli-textarea').value; const finalScript = document.getElementById('side-cli-textarea').value;
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...'; document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
document.getElementById('side-pane-title').style.color = '#3498db'; document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
document.getElementById('btn-side-cancel').style.display = 'none'; document.getElementById('btn-side-cancel').style.display = 'none';
document.getElementById('btn-side-confirm').style.display = 'none'; document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-close').style.display = 'none'; document.getElementById('btn-side-close').style.display = 'none';
@ -590,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);
@ -801,10 +1116,20 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) {
document.getElementById('side-execution-result').style.display = 'none'; document.getElementById('side-execution-result').style.display = 'none';
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)'); // 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
rightPane.style.width = 'calc(50% - 11px)'; leftPanes.forEach(pane => {
pane.style.flex = 'none';
pane.style.maxWidth = 'none';
pane.style.width = 'calc(50% - 11px)';
});
if (rightPane) {
rightPane.style.flex = 'none';
rightPane.style.maxWidth = 'none';
rightPane.style.width = 'calc(50% - 11px)';
rightPane.style.display = 'block';
}
if (resizer) resizer.style.display = 'flex'; if (resizer) resizer.style.display = 'flex';
if (rightPane) rightPane.style.display = 'block';
if (!window.isResizerInitialized && typeof initResizer === 'function') { if (!window.isResizerInitialized && typeof initResizer === 'function') {
initResizer(); initResizer();

View File

@ -18,34 +18,45 @@ export async function loadMacDomainList() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
const mdSelect = document.getElementById('cfgMacDomain'); const mdInput = document.getElementById('cfgMacDomain');
const mdDatalist = document.getElementById('mac-domain-list');
const statusSpan = document.getElementById('fetchStatus'); const statusSpan = document.getElementById('fetchStatus');
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>'; // 載入前:鎖定輸入框並更改提示
mdInput.disabled = true;
mdInput.value = '';
mdInput.placeholder = '⏳ 查詢設備中...';
if (mdDatalist) mdDatalist.innerHTML = '';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單..."; statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12"; statusSpan.style.color = "#f39c12";
try { try {
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass); const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success' && result.data.length > 0) { if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = ''; // 載入成功:將資料塞入 datalist
result.data.forEach(md => { if (mdDatalist) {
const option = document.createElement('option'); result.data.forEach(md => {
option.value = md; const option = document.createElement('option');
option.textContent = md; option.value = md;
mdSelect.appendChild(option); mdDatalist.appendChild(option);
}); });
}
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。"; statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60"; statusSpan.style.color = "#27ae60";
} else { } else {
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>'; mdInput.placeholder = '❌ 找不到資料';
statusSpan.textContent = "無法解析清單,請確認設備狀態。"; statusSpan.textContent = "無法解析清單,請確認設備狀態。";
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} }
} catch (error) { } catch (error) {
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>'; mdInput.placeholder = '❌ 連線錯誤';
statusSpan.textContent = "連線失敗:" + error; statusSpan.textContent = "連線失敗:" + error;
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} finally {
// 載入結束:解鎖輸入框
mdInput.disabled = false;
} }
} }
@ -263,6 +274,15 @@ export async function executeBondingConfig() {
const outputEl = document.getElementById('modalOutput'); const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`; outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`;
// 鎖定關閉按鈕 (Execution Lock)
const closeBtn = document.getElementById('btn-modal-close');
if (closeBtn) {
closeBtn.disabled = true;
closeBtn.style.opacity = '0.5';
closeBtn.style.cursor = 'not-allowed';
closeBtn.style.pointerEvents = 'none';
}
try { try {
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') { if (result.status === 'success') {
@ -275,5 +295,13 @@ export async function executeBondingConfig() {
} catch (error) { } catch (error) {
outputEl.style.color = "#e74c3c"; outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`; outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
} finally {
// 恢復關閉按鈕
if (closeBtn) {
closeBtn.disabled = false;
closeBtn.style.opacity = '1';
closeBtn.style.cursor = 'pointer';
closeBtn.style.pointerEvents = 'auto';
}
} }
} }

View File

@ -115,10 +115,11 @@ button:hover { background-color: #3498db; }
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; } .task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
/* 網格系統:高空間利用率 */ /* 網格系統:高空間利用率 */
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 15px; justify-content: start; } .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
.form-row { display: flex; flex-direction: column; gap: 4px; } .form-row { display: flex; flex-direction: column; gap: 4px; }
.form-row label { font-size: 12px; font-weight: bold; color: #34495e; } .form-row label { font-size: 12px; font-weight: bold; color: #34495e; }
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; } .form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
.form-row select { padding-right: 30px; text-overflow: ellipsis; } /* 🌟 確保下拉箭頭有足夠空間,不會遮擋文字 */
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; } .form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }
/* 表單底部操作區 */ /* 表單底部操作區 */
@ -126,14 +127,12 @@ 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); }
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; } .modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; } .modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
.modal-close:hover { color: #e74c3c; }
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */ /* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
.modal-body { .modal-body {
flex-grow: 1; flex-grow: 1;
@ -169,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;
@ -273,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,17 +84,36 @@ 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;
document.getElementById('cmtsUser').disabled = true; document.getElementById('cmtsUser').disabled = true;
document.getElementById('cmtsPass').disabled = true; document.getElementById('cmtsPass').disabled = true;
term.focus(); term.focus();
// 🌟 原有:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
const queryTaskSelect = document.getElementById('queryTask');
if (queryTaskSelect) {
queryTaskSelect.dispatchEvent(new Event('change'));
}
// 🌟 新增:連線成功後,強制觸發配置任務切換,藉此啟動「正確 IP」的 SSE 監聽
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
}; };
ws.onmessage = (event) => { 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));
}; };
@ -96,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

@ -1,11 +1,19 @@
// --- static/tree-ui.js --- // --- static/tree-ui.js ---
import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
// ========================================== // ==========================================
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering) // 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ========================================== // ==========================================
export const folderDataCache = {}; export const folderDataCache = {};
export const filterFolderCache = {}; export const filterFolderCache = {};
// 🧹 [修復 Leak] 徹底清空樹狀圖快取,釋放記憶體
export function clearTreeCache() {
for (let key in folderDataCache) delete folderDataCache[key];
for (let key in filterFolderCache) delete filterFolderCache[key];
}
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去 // 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
window.lazyLoadFolder = function(elementId, isFilter = false) { window.lazyLoadFolder = function(elementId, isFilter = false) {
const detailsEl = document.getElementById(`details-${elementId}`); const detailsEl = document.getElementById(`details-${elementId}`);
@ -21,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) {
if (isFilter) { // 🌟 統一渲染作法:單點展開也套用非同步渲染保護
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode); contentDiv.innerHTML = `<span style="color: #f39c12; font-size: 13px; font-weight: bold; margin-left: 5px;">⏳ 載入中...</span>`;
} else { document.body.style.cursor = 'wait';
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
} setTimeout(() => {
detailsEl.dataset.loaded = 'true'; // 標記為已載入 if (isFilter) {
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
} else {
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
}
detailsEl.dataset.loaded = 'true'; // 標記為已載入
document.body.style.cursor = 'default';
}, 10);
} }
}; };
@ -35,41 +50,86 @@ 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-'); const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
window.lazyLoadFolder(elementId, isFilter); if (!cache) return;
}
details.open = true; // 🌟 讓滑鼠變成讀取狀態,給予使用者即時回饋
document.body.style.cursor = 'wait';
// 找出剛剛渲染出來的第一層子資料夾,遞迴展開 const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
const contentDiv = details.querySelector(':scope > div');
if (contentDiv) { if (contentDiv) {
const childDetails = contentDiv.querySelectorAll(':scope > details'); contentDiv.innerHTML = `<span style="color: #f39c12; font-weight: bold; font-size: 13px;">⏳ 正在極速展開中,請稍候...</span>`;
childDetails.forEach(d => {
const subId = d.id.replace('details-', '');
expandAll(subId); // 遞迴展開
});
}
} }
// 🌟 使用 setTimeout 讓出一個 Frame讓瀏覽器能渲染出「讀取中」的文字與滑鼠狀態
setTimeout(() => {
const currentPath = details.dataset.path;
const isCommandGroup = details.dataset.isGroup === 'true';
const mode = details.dataset.mode;
// 🚀 核心魔法:在記憶體中一次性遞迴生成所有子節點的 HTML 字串 (傳入 forceExpand = true)
let fullHtml = '';
if (isFilter) {
fullHtml = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode, true);
} else {
fullHtml = buildTree(cache, currentPath, isCommandGroup, mode, true);
}
// 🚀 終極效能:只執行 1 次 DOM 寫入!
if (contentDiv) contentDiv.innerHTML = fullHtml;
details.dataset.loaded = 'true';
details.open = true;
const iconClosed = details.querySelector('.icon-closed');
const iconOpen = details.querySelector('.icon-open');
if (iconClosed) iconClosed.style.display = 'none';
if (iconOpen) iconOpen.style.display = 'inline-block';
document.body.style.cursor = 'default';
}, 20);
} }
export function collapseAll(elementId) { 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 參數
let html = ''; export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false, renderAll = false) {
if (!node || typeof node !== 'object') return html; const htmlParts = [];
if (!node || typeof node !== 'object') return htmlParts.join('');
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
for (const [key, value] of Object.entries(node)) { for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key; const currentPath = path ? `${path}::${key}` : key;
@ -81,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}')"
@ -110,10 +161,46 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
`; `;
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder const currentHost = document.getElementById('cmtsHost')?.value.trim();
html += ` let isLocked = false;
let lockTitle = "鎖定並編輯此群組";
let lockText = "✏️";
let lockOpacity = "0.3";
let lockPointer = "auto";
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost];
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
}
} else {
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
}
}
}
}
// 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML
const isOpenAttr = forceExpand ? 'open' : '';
const isLoadedAttr = (forceExpand || renderAll) ? 'true' : 'false';
const displayClosed = forceExpand ? 'none' : 'inline-block';
const displayOpen = forceExpand ? 'inline-block' : 'none';
let innerContent = '<!-- 🌟 延遲渲染 -->';
if (forceExpand || renderAll) {
// 🌟 遞迴呼叫時,把 renderAll 傳遞下去
innerContent = buildTree(value, currentPath, isCommandGroup, mode, forceExpand, renderAll);
}
htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <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}"
@ -122,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}
@ -138,9 +225,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍 🔍
</span> </span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')" <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
${lockText}
</span> </span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;"> <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button> <button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button>
@ -149,12 +236,11 @@ 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>
`; `);
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no'); const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -165,12 +251,34 @@ 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);`;
const currentHost = document.getElementById('cmtsHost')?.value.trim();
let isLocked = false;
let lockTitle = "鎖定並編輯此項目";
let lockText = "✏️";
let lockOpacity = "0.3";
let lockPointer = "auto";
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost];
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
}
} else {
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
}
}
}
}
let displayContent = ''; let displayContent = '';
if (isVirtualIndex) { if (isVirtualIndex) {
@ -199,7 +307,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
`; `;
} }
html += ` htmlParts.push(`
<div class="tree-leaf-node" <div class="tree-leaf-node"
title="${cliCommand}" title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -217,9 +325,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍 🔍
</span> </span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')" <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
${lockText}
</span> </span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;"> <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button> <button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button>
@ -227,20 +335,30 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
</div> </div>
</div> </div>
`; `);
} }
} }
return html; return htmlParts.join('');
} }
// ========================================== // ==========================================
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (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 '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`; const htmlParts = [];
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
for (const key in data) { 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;
@ -248,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;">`;
@ -263,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}')"
@ -288,28 +396,37 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
</span> </span>
`; `;
// 🌟 加入 lazyLoadFolder 觸發 // 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML
html += ` const isOpenAttr = forceExpand ? 'open' : '';
const isLoadedAttr = forceExpand ? 'true' : 'false';
const displayClosed = forceExpand ? 'none' : 'inline-block';
const displayOpen = forceExpand ? 'inline-block' : 'none';
let innerContent = '<!-- 🌟 延遲渲染 -->';
if (forceExpand) {
innerContent = buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup, mode, true);
}
htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" <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>
`; `);
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`; let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
@ -330,7 +447,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
currentIcon = `<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>`; currentIcon = `<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>`;
} }
html += ` htmlParts.push(`
<div class="tree-leaf-node" <div class="tree-leaf-node"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'" onmouseout="this.style.backgroundColor='transparent'"
@ -343,9 +460,32 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<span style="margin-right: 5px;">${displayName}${colonHtml}</span> <span style="margin-right: 5px;">${displayName}${colonHtml}</span>
${valueHtml} ${valueHtml}
</div> </div>
`; `);
} }
} }
html += '</div>'; htmlParts.push('</div>');
return html; 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';
}
};

615
target_code.txt Normal file
View File

@ -0,0 +1,615 @@
================================================================================
TARGET SOURCE CODE EXPORT (SPECIFIC)
================================================================================
================================================================================
FILE: index.html
================================================================================
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Harmonic CMTS Manager</title>
<link rel="icon" type="image/png" href="/static/my_icon.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 引入獨立的 CSS 樣式表 -->
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<!-- 1. 標題加上 id="app-title" -->
<h1 id="app-title" style="cursor: default;">🎙️ Harmonic CMTS Admin</h1>
<div class="global-settings">
<label><strong>🌐 目標設備:</strong></label>
<!-- 🌟 修改後 (徹底淨化) -->
<input type="text" id="cmtsHost" placeholder="IP 地址" style="width: 130px;">
<input type="text" id="cmtsUser" placeholder="帳號" style="width: 100px;">
<input type="password" id="cmtsPass" placeholder="密碼" style="width: 100px;">
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
<button onclick="disconnectWebSocket()" id="btnDisconnect" class="btn-modern btn-disconnect" style="display: none;">斷開連線</button>
<span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
<button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
<button class="tab-btn" onclick="switchTab('config-tab', this)">🛠️ CMTS 設備配置</button>
<!-- 💡 新增:系統設定頁籤 -->
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
<!-- 💡 新增:備份與還原頁籤 -->
<button class="tab-btn" onclick="switchTab('backup-tab', this); loadBackupHistory();">💾 設備備份與還原</button>
</div>
<!-- Tab 1: True SSH Terminal -->
<div id="cli-tab" class="tab-content active">
<div class="control-group">
<label><strong>快捷指令:</strong></label>
<select id="fixedCmd" aria-label="選擇快捷指令" >
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
</select>
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<button onclick="downloadTerminal()" class="btn-modern btn-slate">💾 下載紀錄</button>
</div>
</div>
<div id="terminal-container"></div>
</div>
<!-- Tab 2: CMTS 狀態查詢 -->
<div id="query-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
<div class="manager-container">
<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>
<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-rpd-query">📡 RPD 狀態綜合查詢</option>
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
</select>
</div>
<!-- 表單 1Cable 狀態綜合查詢 -->
<div id="form-cm-query" class="task-form active">
<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-row">
<label>目標設備 (Cable Modem MAC)</label>
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
<datalist id="cm-mac-list"></datalist>
</div>
<div class="form-row">
<label>查詢動作 (Action)</label>
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')" aria-label="選擇 Cable Modem 查詢動作">
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
<option value="base">基本狀態 (show cable modem)</option>
<option value="cpe">CPE 資訊 (cpe)</option>
<option value="cpe_dhcp">CPE DHCP (cpe dhcp)</option>
<option value="cpe_ipv6">CPE IPv6 (cpe ipv6)</option>
<option value="bonding">綁定摘要 (bonding)</option>
<option value="bonding_ds">下行綁定 (bonding downstream)</option>
<option value="bonding_us">上行綁定 (bonding upstream)</option>
<option value="phy">實體層狀態 (phy)</option>
<option value="verbose">詳細資訊 (verbose)</option>
<option value="ofdm_profile">OFDM Profile (ofdm-profile)</option>
<option value="ofdma_profile">OFDMA Profile (ofdma-profile)</option>
<option value="dhcp_verbose">DHCP 詳細資訊 (dhcp verbose)</option>
<option value="service_flow">Service Flow (service-flow)</option>
<option value="service_flow_verbose">Service Flow 詳細 (service-flow verbose)</option>
<option value="qos">QoS 資訊 (qos)</option>
<option value="uptime">運行時間 (uptime)</option>
<option value="ugs">UGS 資訊 (ugs)</option>
<option value="cm_status">CM 狀態 (cm-status)</option>
</optgroup>
<optgroup label="全域狀態查詢 (不分特定目標)">
<option value="partial_mode">Partial Mode (partial-mode)</option>
<option value="hop">Cable Hop (show cable hop)</option>
<option value="flap_list">Flap List (show cable flap-list)</option>
<option value="flap_sum">Flap Summary (show cable flap-sum)</option>
</optgroup>
</select>
</div>
</div>
<div class="form-actions">
<button onclick="executeQuery('Cm')" class="btn-modern btn-load">執行查詢</button>
<div style="display: flex; align-items: center;">
<input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
<label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
</div>
</div>
</div>
<!-- 表單 2RPD 狀態綜合查詢 -->
<div id="form-rpd-query" class="task-form">
<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-row">
<label>目標設備 (RPD VC:VS)</label>
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
<datalist id="rpd-vcvs-list"></datalist>
</div>
<div class="form-row">
<label>查詢動作 (Action)</label>
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')" aria-label="選擇 RPD 查詢動作">
<option value="rpd_base">基本狀態 (show cable rpd)</option>
<option value="rpd_verbose">詳細資訊 (verbose)</option>
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
<option value="rpd_ptp_verbose">PTP 詳細資訊 (ptp verbose)</option>
<option value="rpd_counters_map">Counters Map (counters map)</option>
<option value="rpd_capabilities">能力資訊 (capabilities)</option>
<option value="rpd_video_counters">Video Counters (video-channel counters)</option>
<option value="rpd_env_temp">環境溫度 (environment temperature)</option>
<option value="rpd_env_volt">環境電壓 (environment voltage)</option>
<option value="rpd_session">Session (session)</option>
<option value="rpd_reset_history">重啟歷史 (reset-history)</option>
<option value="rpd_port_transceiver">光模組資訊 (port-transceiver)</option>
</select>
</div>
</div>
<div class="form-actions">
<button onclick="executeQuery('Rpd')" class="btn-modern btn-load">執行查詢</button>
<div style="display: flex; align-items: center;">
<input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
<label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
</div>
</div>
</div>
<!-- 表單 3CM 一鍵診斷中心 -->
<div id="form-cm-diagnostics" class="task-form">
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
<!-- 搜尋區塊 -->
<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>
<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>
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div>
<!-- 結果顯示區塊 (預設隱藏) -->
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
<!-- 上半部:基本資訊與 PHY 狀態 -->
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- 基本資訊卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
</table>
</div>
<!-- PHY 狀態卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;">
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
<td id="diagResTx" style="font-weight: bold;">-</td>
</tr>
<tr>
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
<td id="diagResRx" style="font-weight: bold;">-</td>
</tr>
</table>
<!-- 🌟 新增:上行通道標籤區塊 -->
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
<!-- 動態生成標籤 -->
</div>
</div>
</div>
</div>
<!-- 下半部Chart.js MER 圖表 -->
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
<!-- 動態生成按鈕 -->
</div>
</h4>
<div style="position: relative; height: 280px; width: 100%;">
<canvas id="merChart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab 3: CMTS 設備配置 -->
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
<div class="manager-container">
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
<!-- 🌟 拆分為兩個獨立的選項 -->
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
</select>
<button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
</div>
<!-- 表單 3MAC Domain 配置精靈 -->
<div id="form-bonding-config" class="task-form active">
<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;">
<label 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;">
<datalist id="mac-domain-list"></datalist>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div>
<div id="macDomainConfigArea" style="display: none; margin-top: 20px;">
<!-- Common Settings -->
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
<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>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>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>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>
<!-- [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>
<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>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>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>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
</div>
<!-- [Static] Downstream Bonding Groups -->
<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>
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
<label>選擇要編輯的 DS Group</label>
<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;">
</div>
<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>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>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
</div>
</div>
<!-- [Static] Upstream Bonding Groups -->
<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>
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
<label>選擇要編輯的 US Group</label>
<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;">
</div>
<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>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>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
</div>
</div>
<!-- CLI 預覽區塊 -->
<div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>
<div class="form-actions">
<button id="btnRevert" onclick="revertFormChanges()" class="btn-modern btn-disconnect" style="margin-right: 15px;">🔄 復原重填</button>
<button onclick="generateMacDomainCLI()" class="btn-modern btn-scan">⚙️ 產生絕對路徑指令預覽</button>
<button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
</div>
</div>
</div>
<!-- 表單 4設備配置樹狀圖 (共用容器) -->
<div id="form-tree-config" class="task-form" style="display: none;">
<!-- 頂部:標題與按鈕區塊 (維持原樣) -->
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
<div style="display: flex; align-items: center; gap: 15px;">
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
<span id="tree-form-title">🌲 設備配置樹狀圖</span>
</h3>
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
</span>
</div>
<div style="display: flex; gap: 10px;">
<button id="btn-scan-options" onclick="scanMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">🚀 掃描缺失選項</button>
<button id="btn-clear-options" onclick="clearOptionsCache()" class="btn-modern btn-clear" style="display: none;">🗑️ 清除選項快取</button>
</div>
</div>
<!-- 🌟 左右分屏容器 (關鍵修改align-items: stretch 讓左右強制等高) -->
<div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
<!-- 左側:樹狀圖 (保留 max-height: 600px超過才出捲軸) -->
<div id="tree-container-running" class="tree-view-instance" style="flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
</div>
<div id="tree-container-full" class="tree-view-instance" style="display: none; flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
</div>
<!-- 🖱️ 拖曳分隔線 (拔除寫死的 height: 400px) -->
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; flex-shrink: 0; align-items: center; justify-content: center; z-index: 10;" title="左右拖曳調整寬度">
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
</div>
<!-- 右側CLI 預覽與執行結果外框 -->
<div id="side-cli-preview" style="flex: 1; min-width: 300px; max-width: 50%; display: none; box-sizing: border-box;">
<!-- 🌟 內部黑底容器 (使用 flex column 與 height: 100% 完美填滿外框) -->
<div style="display: flex; flex-direction: column; height: 100%; background: #1e1e1e; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #34495e; box-sizing: border-box;">
<!-- 頂部標題列 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
<div style="display: flex; align-items: center; gap: 20px;">
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
</div>
</div>
<!-- 模式一:指令預覽框 (🌟 拔除 height: 400px改用 flex: 1 自動填滿) -->
<textarea id="side-cli-textarea" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; outline: none; box-sizing: border-box; white-space: pre; overflow-x: auto; overflow-y: auto; margin: 0; display: block; resize: none; min-height: 150px;"></textarea>
<!-- 模式二:執行結果框 (🌟 拔除 height: 400px改用 flex: 1 自動填滿) -->
<div id="side-execution-result" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; box-sizing: border-box; overflow-x: auto; overflow-y: auto; margin: 0; display: none; white-space: pre; min-height: 150px;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 💡 Tab 4: 系統設定 (System Settings) -->
<div id="settings-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe; text-align: left;">
<div class="manager-container" style="display: block; max-width: 100%;">
<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;">
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
⚙️ 全域系統設定 (God Mode Filters)
</h3>
<div style="background: #f8f9fa; border-left: 4px solid #3498db; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
請勾選您希望在「完整設備配置樹狀圖」中 <b>隱藏</b> 的設定項目。<br>
<span style="color: #7f8c8d; font-size: 14px;">💡 點擊下方按鈕載入設備實時配置,您可以深入展開並勾選任何層級的節點進行過濾。</span>
</p>
</div>
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
<label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
<option value="running">Running 配置過濾器</option>
<option value="full">Full 配置過濾器</option>
</select>
<button onclick="loadRealConfigForFilters()" class="btn-modern btn-load">
📥 載入設備配置以設定過濾
</button>
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
</div>
<!-- 樹狀 Checkbox 容器 -->
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
</div>
<div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
<button onclick="saveSystemSettings()" class="btn-modern btn-connect" style="padding: 10px 20px; font-size: 15px;">
💾 儲存並套用設定
</button>
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</div>
</div>
<!-- 🌟 新增:伺服器日誌管控面板 -->
<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;">
🎛️ 伺服器日誌管控 (Server Log Management)
</h3>
<div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,<b>無需重啟伺服器</b>。<br>
<span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b> 或 <b>ERROR</b>,僅在需要排查問題時開啟 <b>DEBUG</b>。</span>
</p>
</div>
<!-- 矩陣式下拉選單容器 -->
<div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
</div>
</div>
</div>
</div>
<!-- 💡 Tab 5: 設備備份與還原 -->
<div id="backup-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
<div class="manager-container">
<!-- 上半部:建立新快照 -->
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
<!-- 💡 修正:移除負 margin讓底線與內容邊界對齊 (如同系統設定頁籤) -->
<div style="display: flex; justify-content: flex-start; align-items: center; gap: 15px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px;">
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px;">
📸 建立新快照
</h3>
<button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save" style="padding: 8px 20px; font-size: 14px; background-color: #2980b9; border: none; box-shadow: 0 2px 4px rgba(41, 128, 185, 0.3);">
🚀 立即執行備份
</button>
<span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none; font-weight: bold;">
⏳ 正在連線設備並抓取設定,請稍候...
</span>
</div>
<!-- 表單網格區 -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
<div>
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
快照名稱 <span style="color: #e74c3c;">*</span>
</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;">
</div>
<div>
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
配置類型
</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;">
<option value="running">Running Config (運行中配置)</option>
<option value="full">Full Config (完整配置)</option>
</select>
</div>
</div>
<!-- 描述區 -->
<div style="margin-bottom: 0;">
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
備份描述 <span style="color: #7f8c8d; font-weight: normal; font-size: 13px;">(選填)</span>
</label>
<input type="text" id="snapshotDescription" placeholder="請簡述此次備份的目的,例如:升級至 Unify FDD firmware驗收通過" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px;">
</div>
</div>
<!-- 下半部:歷史紀錄列表 -->
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
<!-- 💡 修正:移除負 margin讓底線與內容邊界對齊 -->
<div style="display: flex; align-items: center; gap: 20px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px; flex-wrap: wrap;">
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px; white-space: nowrap;">
📁 歷史備份紀錄
</h3>
<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()">
<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()">
<option value="">所有類型</option>
<option value="running">running</option>
<option value="full">full</option>
</select>
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
<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()">
<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()">
</div>
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
🔄 重新整理
</button>
</div>
</div>
<!-- 表格區塊 -->
<table style="width: 100%; border-collapse: collapse; text-align: left;">
<thead>
<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: 25%;">快照名稱</th>
<th style="padding: 10px; color: #34495e; width: 25%;">描述</th>
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
</tr>
</thead>
<tbody id="backup-history-tbody">
<tr>
<td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 獨立的彈出式輸出視窗 (Modal) -->
<div id="outputModal" class="modal-overlay">
<div class="modal-container">
<div class="modal-header">
<div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
<span>📄 執行結果</span>
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
</div>
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
</div>
</div>
<div class="modal-body">
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
</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 邏輯 -->
<script type="module" src="/static/app.js"></script>
</body>
</html>