feat(core): 系統底層架構重構、資安強化與極致效能優化
本次提交涵蓋了 Phase 3.5 至 Phase 4 的核心架構翻修,徹底解決了 SSH 互動陷阱、資料庫冗餘、機密外洩以及巨量 DOM 渲染的效能瓶頸。 🏗️ 1. 資料庫與快取架構整併 (DB Consolidation) - 重構 `cmts_options` 資料表,主鍵簡化為 `(host, path)`,徹底解耦 `config_type`。 - 實現 `running-tree` 與 `full-tree` 共用全域選項字典,達成極致的「增量掃描 (Incremental Scanning)」。 - 強化 `init_db.py` 防呆機制,保留 `config_backups` 避免誤刪珍貴快照。 🔒 2. 企業級資安與權限管理 (Security Hardening) - 導入 `python-dotenv`,全面落實 12-Factor App 規範。 - 徹底淨化 `index.html`, `shared.py`, `database.py` 中的硬編碼 (Hardcoded) 密碼。 - 建立 `.env.example` 範例檔,確保正式機密碼「零外洩 (Zero Secrets Leakage)」。 - 強化 God Mode 驗證邏輯,未設定環境變數時預設拒絕所有進階授權 (Fail-Fast)。 📡 3. SSH 引擎與 SSE 廣播機制 (SSH & SSE Engine) - 修復 SSH Echo 與 MOTD 陷阱,導入精準的 `prompt_pattern` 絕對靜音偵測。 - 廢除危險的 `Ctrl+C`,改用底層 `Ctrl+U (\x15)` 瞬間清空輸入緩衝區,確保 100% 停留在 config 模式。 - 完善 SSE (Server-Sent Events) 廣播機制,前端精準呈現「百分比進度條」與「動態狀態文字」。 ⚡ 4. 前端效能與使用者體驗 (Performance & UX) - 升級 Regex 解析器,完美支援水平列表與無標題說明,成功找回消失的 `❓` 提示。 - 導入 HTML5 `<datalist>`,將 Enum 欄位無痛升級為「半自由下拉選單 (Combobox)」。 - 解決「延遲載入 (Lazy Load)」與「編輯模式」的衝突:實作「預先渲染但不展開」機制,結合 `content-visibility: auto` 達成零效能損耗。 - 實作 Xterm.js DOM 事件防火牆 (Capture Phase Interception),完美阻擋第三方外掛產生的殘缺鍵盤事件,消除 Console 紅字報錯。 ⚠️ Breaking Changes (破壞性變更) - 系統啟動前必須建立 `.env` 檔案並填入必要的資料庫與設備連線資訊。 - 資料庫結構已變更,舊有環境需執行 `python init_db.py --reset` 重新建表。
This commit is contained in:
parent
061ee3435a
commit
7a49ef9833
|
|
@ -1,51 +1,71 @@
|
||||||
# Harmonic CMTS Manager - AI 開發守則與架構白皮書
|
# Harmonic CMTS Manager - AI Architect Guidelines & System Blueprint
|
||||||
|
|
||||||
## 1. 專案架構概覽與技術棧
|
> **⚠️ AI 角色與絕對約束 (AI Persona & Absolute Directives)**
|
||||||
- **定位**: 專為有線電視網路終端設備 (CMTS) 設計的企業級 Web 管理系統。
|
> 你現在是一位資深的系統架構師與全端工程師。
|
||||||
- **後端**: Python 3.10+, FastAPI (Async-first), AsyncSSH, Netmiko, asyncpg.
|
> 1. **唯一真理**:絕對禁止自行幻想任何不存在的模組、變數或第三方套件。所有修改必須基於現有架構。
|
||||||
- **前端**: 原生 Vanilla JS (ES Modules), HTML5, CSS3, Xterm.js.
|
> 2. **語言規範**:註解、對話與 Git Commit 一律使用**繁體中文 (zh-TW)**。
|
||||||
- **資料庫**: PostgreSQL (主要), JSON File Cache (高可用性降級備援).
|
> 3. **無聲錯誤是原罪**:所有後端 API 必須使用 `try-except` 捕捉例外,並回傳標準 JSON `{"status": "error", "message": "..."}`,嚴禁 FastAPI 直接拋出 HTTP 500 導致前端崩潰。
|
||||||
|
|
||||||
### 📂 目錄與檔案結構
|
---
|
||||||
- **進入點**: `main.py`
|
|
||||||
- **路由管理**: API 路由統一放置於 `routers/` 目錄。包含 backup.py, config.py, leaf_options.py, lock.py, query.py, terminal.py。
|
|
||||||
- **前端介面**: `index.html` 與 `static/` 目錄。 包含 api.js, app.js, edit-mode.js, mac-domain.js, style.css, terminal.js, tree-ui.js, utils.js。
|
|
||||||
- **核心邏輯**:
|
|
||||||
- `cmts_scraper.py`: 負責底層爬蟲與資料處理。
|
|
||||||
- `shared.py`: 放置共用函式 (如兩階段解析法)。
|
|
||||||
- `config.py`: 配置轉譯器 (CLI Generator)。
|
|
||||||
- **資料庫**: 連線與 ORM 邏輯在 `database.py`,初始化腳本為 `init_db.py`。
|
|
||||||
- **併發控制**: `lock.py` (路徑階層鎖), `leaf_options.py` (SSE 頻道分流)。
|
|
||||||
|
|
||||||
## 2. 核心架構與業務邏輯 (Architecture & Logic)
|
## 🏗️ 1. 系統總體架構 (System Architecture)
|
||||||
- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。
|
|
||||||
- **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `BATCH_SIZE = 30` 搭配 `asyncio.sleep()` 進行非同步批次處理。
|
|
||||||
- **絕對路徑與 Commit 機制 (Absolute Path)**: Harmonic CMTS 支援從 Global 模式直接寫入帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 ...`),無須層層進入子模式。所有變更指令發送完畢後,必須執行 `commit` 才能生效。
|
|
||||||
- **智慧差異還原 (Smart Diff Recovery)**: 設備還原不採用盲目覆蓋,而是透過深度比對 (Deep Diff) 當前設備狀態與備份快照,動態生成包含 `no` 的反向刪除指令與新增/修改指令,確保配置精準還原且無殘留。
|
|
||||||
- **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。
|
|
||||||
- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。
|
|
||||||
|
|
||||||
## 3. 🚨 AI 開發絕對約束 (Directives for AI)
|
本系統為專為 Harmonic CableOS 設計的企業級 Web 管理介面,採用前後端分離架構,並透過 WebSocket 與 SSE 實現即時雙向通訊。
|
||||||
|
|
||||||
### ⚙️ 系統與環境規範
|
* **後端 (Backend)**: Python 3.10+, FastAPI (Async-first), `asyncssh` (核心連線引擎), `asyncpg` (資料庫連線池)。
|
||||||
1. **套件管理**: 若需安裝新套件,請提醒我手動在 `cmts_api_env` 中安裝,並更新 `requirements.txt`。
|
* **前端 (Frontend)**: Vanilla JS (ES Modules), HTML5, CSS3 (CSS Variables, Content-Visibility), Xterm.js, Chart.js。
|
||||||
2. **資料讀取限制**: 請勿隨意讀取 `*_cache.json` 檔案的內容,若需了解資料結構,請參考 `cmts_scraper.py` 中的定義。
|
* **資料庫 (Database)**: PostgreSQL (`cmts_nms` 資料庫)。
|
||||||
3. **狀態同步**: 完成重大修改或一個 Phase 後,必須主動更新 `PROJECT_STATE.md` 記錄最新進度與待辦事項。
|
|
||||||
|
|
||||||
### 💻 程式碼風格與後端規範
|
---
|
||||||
4. **語言與風格**: 註解與對話請一律使用**繁體中文**。Python 程式碼請遵循 PEP8 規範,並加上適當的 Type Hints (型別提示)。
|
|
||||||
5. **Async-First (非同步絕對優先)**: 嚴禁使用阻塞的同步 I/O。同步函數必須封裝進 `await asyncio.to_thread()`。
|
|
||||||
6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。
|
|
||||||
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。
|
|
||||||
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。
|
|
||||||
9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`。
|
|
||||||
10. **精準 Prompt 偵測**: 透過 SSH 讀取設備回傳時,嚴禁盲目依賴 `timeout`。必須在 `read_until_quiet` 中傳入 `prompt_pattern` (如 `r"\(config\)#"` 或 `r"(?:#|>)"`) 進行正規表達式匹配,以最高速釋放資源。
|
|
||||||
|
|
||||||
### 🎨 前端規範
|
## 🧩 2. 模組化切割與檔案關聯 (Module Map)
|
||||||
11. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。
|
|
||||||
12. **UI 狀態安全還原**: 動態修改 DOM 的 CSS 屬性(如 `flex`, `width`)後,若需恢復原狀,嚴禁寫死預設值,必須將屬性設為空字串 `''`,讓瀏覽器自然退回 HTML 定義的樣式,避免改 A 壞 B。
|
### 🟢 後端核心模組 (Backend Core)
|
||||||
13. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時,必須透過 SSE 或 ReadableStream 即時回報進度。
|
* `main.py`: 系統進入點。負責掛載靜態檔案、初始化 DB Pool (`lifespan`),並將所有 API 路由統一掛載於 `/api/v1` 前綴之下。
|
||||||
14. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy(保留策略/定期清理機制),不可只寫入不刪除。
|
* `database.py`: PostgreSQL 非同步連線池管理。嚴格區分 `running` 與 `full` 的 `config_type` 雙軌隔離設計。
|
||||||
15. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。
|
* `cmts_scraper.py`: 底層 SSH 爬蟲引擎。負責發送 `?` 探測設備選項、解析終端機分頁 (`--More--`),並清理 ANSI 控制碼。
|
||||||
16. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()` 與 `?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。
|
* `shared.py`: 純邏輯共用區。包含核心的 `parse_cli_to_tree` (兩階段解析法)、`deep_split_tree` (降維展開),以及全域的 `cmts_config_locks` (依 IP 隔離的非同步鎖)。
|
||||||
|
* `logger.py`: 具備 ANSI 色彩的自訂日誌系統,支援透過 API 動態調整各模組的 Log Level。
|
||||||
|
|
||||||
|
### 🔵 後端路由模組 (Routers - `/routers/`)
|
||||||
|
* `config.py`: 負責抓取完整配置 (`/cmts-full-config`)、套用系統過濾器,以及將前端 Diff 轉譯為 CLI 腳本 (`generate_cli`)。
|
||||||
|
* `leaf_options.py`: 負責選項快取的背景掃描,並透過 `asyncio.Queue` 實作 SSE (Server-Sent Events) 頻道分流,即時推播掃描進度。
|
||||||
|
* `lock.py`: 實作 In-Memory 的路徑階層鎖 (`ACTIVE_LOCKS`),支援 Heartbeat 續命與過期自動清理。
|
||||||
|
* `backup.py`: 設備快照與還原中心。實作 Deep Diff 演算法,並透過 `StreamingResponse` (NDJSON) 實作具備 Fail-safe (自動 `abort`) 的安全還原管道。
|
||||||
|
* `query.py`: 處理標準 `show` 指令查詢,以及 MAC Domain 的互動式解析。
|
||||||
|
* `diagnostics.py`: 深度解析 CM 狀態,包含 PHY 功率、SNR 與 OFDM MER 陣列。
|
||||||
|
* `terminal.py`: WebSocket 代理,將前端 Xterm.js 的輸入轉發至 `asyncssh` 的 PTY。
|
||||||
|
|
||||||
|
### 🟡 前端模組 (Frontend - `/static/`)
|
||||||
|
* `app.js`: 主協調器 (Orchestrator)。負責頁籤切換、SSE 監聽初始化、全域鎖定狀態輪詢 (`startLockStatusPolling`) 與 God Mode 授權。
|
||||||
|
* `api.js`: 純粹的 Fetch API 封裝層,負責與後端 `/api/v1` 溝通。
|
||||||
|
* `tree-ui.js`: **效能核心**。負責將 JSON 轉換為 HTML 樹狀圖。採用「記憶體遞迴渲染」與「延遲載入 (`lazyLoadFolder`)」。
|
||||||
|
* `edit-mode.js`: 編輯狀態機。處理鎖定獲取、UI 狀態切換 (✏️ -> 🔒 -> ⏳)、生成 Diff 陣列,以及右側 CLI 預覽面板的控制。
|
||||||
|
* `mac-domain.js`: 獨立的 MAC Domain 狀態感知配置精靈邏輯。
|
||||||
|
* `terminal.js`: Xterm.js 實例化、WebSocket 連線管理與終端機字串上色 (`colorizeTerminalStream`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 3. 核心演算法與開發規範 (Core Mechanisms & Rules)
|
||||||
|
|
||||||
|
### ⚡ 3.1 前端極致效能規範 (Extreme DOM Performance)
|
||||||
|
本系統的 DOM 節點可能高達數萬個,**嚴禁使用同步迴圈大量操作 DOM**。
|
||||||
|
1. **記憶體遞迴渲染 (In-Memory Rendering)**:在 `tree-ui.js` 中,必須先在 JS 記憶體中將 HTML 字串完全組裝完畢,最後只執行 **1 次** `innerHTML` 寫入。
|
||||||
|
2. **非同步 UI 保護**:任何大型渲染(如展開全部、初始載入),必須先顯示 `⏳ 載入中...` 並將游標設為 `wait`,接著使用 `setTimeout(..., 20)` 讓出主執行緒,確保瀏覽器不卡死。
|
||||||
|
3. **CSS 渲染隔離**:依賴 `style.css` 中的 `content-visibility: auto;`,嚴禁在 JS 中破壞 `.tree-folder-content` 的結構。
|
||||||
|
|
||||||
|
### 🔒 3.2 併發與鎖定機制 (Concurrency & Locking)
|
||||||
|
1. **IP 隔離原則**:所有的鎖定 Key 必須是 `host@@path` 格式,確保不同設備間的鎖定互不干擾。
|
||||||
|
2. **父子階層鎖 (Cascading Lock)**:前端在輪詢鎖定狀態時,必須檢查「自身」、「父節點」與「子節點」的鎖定衝突。
|
||||||
|
3. **防閃爍冷卻 (Optimistic UI Cooldown)**:前端主動釋放鎖定後,必須將該 Key 寫入 `recentlyReleasedLocks` (冷卻 8 秒),防止後端狀態未同步導致的 UI 閃爍。
|
||||||
|
|
||||||
|
### 🛡️ 3.3 設備寫入與安全還原 (Safe SSH Execution)
|
||||||
|
1. **絕對路徑策略 (Absolute Path)**:寫入設備時,一律生成帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 admin-state down`),嚴禁依賴傳統的層層進入模式。
|
||||||
|
2. **Fail-safe 撤銷機制**:在 `backup.py` 與 `config.py` 的寫入迴圈中,只要偵測到設備回傳 `% Invalid`, `% Incomplete` 或 `Error`,必須**立即停止寫入**,並向設備發送 `abort` 指令放棄所有變更。
|
||||||
|
3. **精準 Prompt 偵測**:使用 `asyncssh` 讀取輸出時,嚴禁盲目等待 Timeout。必須在 `read_until_quiet` 中傳入精準的 `prompt_pattern` (如 `r"\(config.*\)#"` 或 `r"(?:#|>)"`)。
|
||||||
|
|
||||||
|
### 🌳 3.4 樹狀圖解析與資料結構 (Tree Parsing)
|
||||||
|
1. **兩階段解析**:`shared.py` 中的 `parse_cli_to_tree` 必須先依賴「縮排」建立實體樹,再透過 `deep_split_tree` 將空白分隔的字串降維成深層巢狀 JSON。
|
||||||
|
2. **終極衝突保護**:在合併字典時,若遇到「資料夾」與「字串」的型態衝突,必須自動升級為資料夾,並將原字串保留至虛擬鍵 `[0]`, `[1]` 中,**絕對不允許遺失任何設備配置**。
|
||||||
|
3. **雙軌記憶體**:前端 `window.treeDataStore` 必須嚴格區分 `running` 與 `full`,切換視圖時純粹切換 CSS `display`,不銷毀資料。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -4,3 +4,8 @@ cmts_api_env/
|
||||||
*_cache.json
|
*_cache.json
|
||||||
filters_*.json
|
filters_*.json
|
||||||
*.bk
|
*.bk
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
|
||||||
|
|
@ -6,37 +6,54 @@
|
||||||
|
|
||||||
## ✅ 已完成開發階段 (Completed Phases)
|
## ✅ 已完成開發階段 (Completed Phases)
|
||||||
|
|
||||||
### Phase 1: PostgreSQL 高可用性架構升級 (Completed)
|
### Phase 1: PostgreSQL 高可用性架構升級
|
||||||
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
|
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
|
||||||
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
|
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
|
||||||
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
|
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
|
||||||
- [x] 調整 `init_db.py` 為非同步啟動腳本。
|
|
||||||
|
|
||||||
### Phase 2: 設備配置備份與快照機制 (Completed)
|
### Phase 2: 設備配置備份與快照機制
|
||||||
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。
|
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。
|
||||||
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
|
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表。
|
||||||
- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。
|
- [x] **前端 UI 優化與防禦性編程**:實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
|
||||||
- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
|
|
||||||
|
|
||||||
### Phase 3: 智慧差異還原與歷史預覽 (Completed)
|
### Phase 3: 智慧差異還原與歷史預覽
|
||||||
- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
|
|
||||||
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。
|
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。
|
||||||
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
|
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
|
||||||
- [x] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 `ReadableStream` 即時渲染後端傳來的 SSH 逐行執行 Log,並具備自動捲動功能。
|
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應),並具備 Fail-safe `abort` 撤銷機制。
|
||||||
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:
|
|
||||||
- 實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應)。
|
### Phase 3.5: 企業級前端效能重構 (Extreme Performance Optimization)
|
||||||
- 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止、發送 `abort` 撤銷變更,並回傳錯誤 Chunk。
|
- [x] **記憶體遞迴渲染 (In-Memory Recursive Rendering)**:徹底重構 `tree-ui.js`,將數千次 DOM 寫入壓縮為單次 `innerHTML` 寫入,解決「展開全部」導致瀏覽器卡死的問題。
|
||||||
- [x] **效能與體驗優化**:完成 DOM 陣列渲染優化、Terminal 資源回收 (防殭屍連線)、精準 Prompt 偵測,以及 UI 視窗連動防呆機制。
|
- [x] **非同步 UI 保護機制**:在所有大型渲染場景 (初始載入、單點展開、全部展開) 導入 `setTimeout` 讓出主執行緒,並搭配沙漏游標與橘色讀取提示,確保 UI 絕對滑順。
|
||||||
|
- [x] **CSS 渲染隔離**:導入 `content-visibility: auto`,讓不在可視範圍內的 DOM 節點暫停渲染計算。
|
||||||
|
- [x] **精準 DOM 查詢**:將鎖定狀態輪詢 (`startLockStatusPolling`) 的搜尋範圍限縮於當前啟用的視圖內,消除全域搜尋造成的卡頓 (Jank)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚧 目前開發階段 (Current Phase)
|
## 🚀 即將到來的里程碑 (Upcoming Milestones Summary)
|
||||||
|
|
||||||
### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management)
|
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
|
||||||
- [ ] **自動備份攔截 (Auto-Backup Interceptor)**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制,確保每次變更前都有還原點。
|
- [x] **智能視覺診斷 (Visual Diagnostics)**:
|
||||||
- [ ] **備份保留策略 (Retention Policy)**:實作定期清理過期或過多歷史快照的機制,防止資料庫無限膨脹。
|
- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
|
||||||
|
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**:
|
||||||
|
- 重新 Review `generate_diff_commands` 演算法。
|
||||||
|
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
|
||||||
|
- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)**:
|
||||||
|
- 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。
|
||||||
|
- 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。
|
||||||
|
- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)**:
|
||||||
|
- **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。
|
||||||
|
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。
|
||||||
|
|
||||||
|
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
|
||||||
|
- [ ] **RPD 樣板克隆引擎 (Template Cloning)**:
|
||||||
|
- 於「MAC Domain 狀態感知配置精靈」中新增 RPD 擴容模式。允許使用者選擇現有設備上已配置完成的 RPD (支援 FDX, FDD, D3.1+) 作為基準樣板。
|
||||||
|
- [ ] **Tree 節點複製與參數替換 (Node Duplication & Modification)**:
|
||||||
|
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
|
||||||
|
- [ ] **安全寫入與校驗 (Safe Provisioning)**:
|
||||||
|
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
|
## 🐛 已知問題與技術債 (Known Issues & Tech Debt)
|
||||||
- 目前系統運行穩定,各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。
|
- 目前系統運行極度穩定,前端效能瓶頸已徹底消除,各項併發鎖定與 UI 狀態連動皆已完善。準備進入 Phase 4 的 Diff 引擎強化開發。
|
||||||
|
|
||||||
|
|
|
||||||
1479
all_code.txt
1479
all_code.txt
File diff suppressed because it is too large
Load Diff
301
cmts_scraper.py
301
cmts_scraper.py
|
|
@ -6,113 +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
|
from logger import get_logger
|
||||||
|
|
||||||
logger = get_logger("app.scraper")
|
logger = get_logger("app.scraper")
|
||||||
|
|
||||||
def parse_question_mark_output(output: str) -> dict:
|
def parse_question_mark_output(output: str) -> dict:
|
||||||
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
|
"""解析 '?' 回傳內容,無差別掃描支援所有標題排列組合"""
|
||||||
hint_lines = []
|
hint_lines = []
|
||||||
options = []
|
options = []
|
||||||
current_value = None
|
current_value = None
|
||||||
format_desc = None
|
format_desc = None
|
||||||
is_format_only = False
|
is_format_only = False
|
||||||
|
|
||||||
state = "INIT"
|
|
||||||
has_description = False
|
|
||||||
has_bracket_value = False
|
has_bracket_value = False
|
||||||
|
|
||||||
# 🌟 Case-5 特殊處理:dhcp-relay 混合型欄位 (選項 + IP輸入)
|
|
||||||
# 直接在開頭掃描完整輸出,若包含 dhcp-relay,強制轉為純文字輸入框
|
|
||||||
if "dhcp-relay" in output.lower():
|
if "dhcp-relay" in output.lower():
|
||||||
is_format_only = True
|
is_format_only = True
|
||||||
format_desc = "IP address or options"
|
format_desc = "IP address or options"
|
||||||
|
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
# 過濾雜訊與終端機提示字元
|
|
||||||
if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
|
# ==========================================
|
||||||
|
# 🛡️ 1. 終極雜訊與 Echo 過濾器
|
||||||
|
# ==========================================
|
||||||
|
if not line or line.startswith('%') or line.startswith('^'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith('Description:'):
|
# 攔截 Echo 的問號指令 (例如 "logging buffered ?")
|
||||||
state = "DESC"
|
if line.endswith('?'):
|
||||||
has_description = True
|
|
||||||
hint_lines.append(line)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith('Possible completions:'):
|
# 攔截終端機 Prompt (例如 "admin@SERCOMM-COS-02(config)# ..." 或 "admin@SERCOMM-COS-02>")
|
||||||
state = "COMPLETIONS"
|
if re.search(r"[a-zA-Z0-9_.@-]+\(.*\)#", line) or re.search(r"^[a-zA-Z0-9_.@-]+>", line):
|
||||||
# 🌟 解除限制:無論有沒有 Description,都把這行加進 hint
|
|
||||||
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,
|
||||||
|
|
@ -120,9 +115,8 @@ def parse_question_mark_output(output: str) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_device_response(output: str) -> dict:
|
def parse_device_response(output: str) -> dict:
|
||||||
"""支援多種編輯狀態格式解析"""
|
# 完美支援 (<string, min: 0 chars, max: 128 chars>) (Cold Start): 格式
|
||||||
match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
|
match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
|
||||||
|
|
||||||
if match:
|
if match:
|
||||||
enum_content = match.group(1)
|
enum_content = match.group(1)
|
||||||
desc_content = match.group(2)
|
desc_content = match.group(2)
|
||||||
|
|
@ -144,11 +138,9 @@ def parse_device_response(output: str) -> dict:
|
||||||
"current_value": current_value,
|
"current_value": current_value,
|
||||||
"format_desc": desc_content.strip()
|
"format_desc": desc_content.strip()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
|
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
|
||||||
|
|
||||||
# 🌟 1. 函數簽名加上 config_type 參數,預設為 "running"
|
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
|
|
||||||
try:
|
try:
|
||||||
total_paths = len(leaf_paths)
|
total_paths = len(leaf_paths)
|
||||||
processed_count = 0
|
processed_count = 0
|
||||||
|
|
@ -156,13 +148,12 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
BATCH_SIZE = 30
|
BATCH_SIZE = 30
|
||||||
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
|
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
|
||||||
|
|
||||||
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
|
cmts_version = "unknown"
|
||||||
|
|
||||||
for batch_idx, batch in enumerate(batches):
|
for batch_idx, batch in enumerate(batches):
|
||||||
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
|
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||||||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
|
|
@ -176,43 +167,44 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
# 🌟 精準 Prompt 偵測
|
|
||||||
if prompt_pattern and re.search(prompt_pattern, output):
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
break
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# 🌟 新增:在第一批次連線時,先抓取設備版本
|
# 🌟 關鍵修復 1:等待登入歡迎詞 (MOTD) 結束,確保設備準備好接收指令
|
||||||
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
if cmts_version == "unknown":
|
if cmts_version == "unknown":
|
||||||
# 🌟 補上 | nomore,並稍微延長等待時間,確保不會被分頁卡住
|
|
||||||
process.stdin.write("show version | nomore\n")
|
process.stdin.write("show version | nomore\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
|
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
|
||||||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
match = re.search(r"(?:infra|vcmts-cd-0|CableOS)\s+([\w\.\-]+)", version_output, re.IGNORECASE)
|
||||||
if match:
|
if match:
|
||||||
cmts_version = match.group(1)
|
cmts_version = match.group(1)
|
||||||
|
else:
|
||||||
|
cmts_version = "parse_failed" # 🌟 避免正則失敗導致每批次都重查
|
||||||
|
|
||||||
|
# 🌟 關鍵修復 2:確保成功進入 config 模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
for path in batch:
|
for path in batch:
|
||||||
# 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
|
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
# --- 步驟 1:發送 '?' 查詢 ---
|
|
||||||
command_to_send = f"{path} ?"
|
command_to_send = f"{path} ?"
|
||||||
process.stdin.write(command_to_send)
|
process.stdin.write(command_to_send)
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
|
||||||
output_question = await read_until_quiet(timeout=1.0)
|
output_question = await read_until_quiet(timeout=0.8)
|
||||||
q_data = parse_question_mark_output(output_question)
|
q_data = parse_question_mark_output(output_question)
|
||||||
|
|
||||||
backspaces = "\x08" * (len(command_to_send) + 5)
|
# 🌟 頂級優雅解法:使用 Ctrl+U (\x15) 瞬間清空整行輸入緩衝區
|
||||||
process.stdin.write(backspaces)
|
process.stdin.write("\x15")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=0.5)
|
await read_until_quiet(timeout=0.2)
|
||||||
|
|
||||||
parsed_data = {
|
parsed_data = {
|
||||||
"hint": q_data["hint"],
|
"hint": q_data["hint"],
|
||||||
|
|
@ -221,7 +213,6 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
"current_value": q_data.get("current_value")
|
"current_value": q_data.get("current_value")
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 步驟 2:判斷是否需要發送 Enter ---
|
|
||||||
if q_data.get("is_format_only"):
|
if q_data.get("is_format_only"):
|
||||||
parsed_data["type"] = "string_or_number"
|
parsed_data["type"] = "string_or_number"
|
||||||
|
|
||||||
|
|
@ -229,7 +220,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
process.stdin.write(f"{path}\n")
|
process.stdin.write(f"{path}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
|
||||||
output_enter = await read_until_quiet(timeout=1.0)
|
output_enter = await read_until_quiet(timeout=0.8)
|
||||||
enter_data = parse_device_response(output_enter)
|
enter_data = parse_device_response(output_enter)
|
||||||
|
|
||||||
parsed_data["type"] = enter_data["type"]
|
parsed_data["type"] = enter_data["type"]
|
||||||
|
|
@ -237,25 +228,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
parsed_data["current_value"] = enter_data["current_value"]
|
parsed_data["current_value"] = enter_data["current_value"]
|
||||||
|
|
||||||
if enter_data["type"] != "unknown":
|
if enter_data["type"] != "unknown":
|
||||||
process.stdin.write("\x03")
|
# 如果進入了互動式輸入 (例如 prompt 變成 (val): ),按 Enter 接受預設值並退出
|
||||||
|
process.stdin.write("\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
else:
|
else:
|
||||||
logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
|
# 如果只是印出錯誤,我們不需要做什麼
|
||||||
process.stdin.write("\n")
|
pass
|
||||||
await process.stdin.drain()
|
|
||||||
|
|
||||||
await read_until_quiet(timeout=0.5)
|
await read_until_quiet(timeout=0.5)
|
||||||
|
|
||||||
result_data[path] = parsed_data
|
result_data[path] = parsed_data
|
||||||
|
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
event_data = {
|
yield json.dumps({
|
||||||
"event": "progress",
|
"event": "progress",
|
||||||
"current": processed_count,
|
"current": processed_count,
|
||||||
"total": total_paths,
|
"total": total_paths,
|
||||||
"current_path": path
|
"current_path": path
|
||||||
}
|
}) + "\n"
|
||||||
yield json.dumps(event_data) + "\n"
|
|
||||||
|
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
|
@ -265,75 +255,24 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
|
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- 步驟 3:寫入快取 (DB or JSON) ---
|
|
||||||
try:
|
try:
|
||||||
db_success = False
|
|
||||||
current_ts = int(time.time())
|
current_ts = int(time.time())
|
||||||
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
|
|
||||||
# 1. 嘗試寫入資料庫
|
# 寫入資料庫
|
||||||
if USE_DB:
|
if cmts_version not in ["unknown", "parse_failed"]:
|
||||||
try:
|
await database.upsert_device_status(host, {"cmts_version": cmts_version, "last_scanned": formatted_time})
|
||||||
# 寫入 metadata
|
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
|
|
||||||
logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
|
|
||||||
else:
|
|
||||||
logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
|
|
||||||
|
|
||||||
# 2. 如果 DB 寫入失敗或 USE_DB=False,則 Fallback 寫入 JSON
|
|
||||||
if not db_success:
|
|
||||||
# 🌟 動態決定快取檔名 (加入 IP 隔離)
|
|
||||||
safe_host = host.replace(".", "_")
|
|
||||||
cache_file = f"{safe_host}_{config_type}_cache.json"
|
|
||||||
|
|
||||||
def read_json_from_file(filepath):
|
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)
|
|
||||||
logger.info(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
|
logger.error(f"⚠️ 資料庫寫入失敗: {e}")
|
||||||
|
|
||||||
if batch_idx < len(batches) - 1:
|
if batch_idx < len(batches) - 1:
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
@ -344,9 +283,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
|
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
|
||||||
|
|
||||||
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
|
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
|
||||||
"""連線至設備並抓取完整的純文字設定檔"""
|
|
||||||
try:
|
try:
|
||||||
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期
|
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||||||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
|
|
@ -357,65 +294,46 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||||||
if not chunk: break
|
if not chunk: break
|
||||||
output += chunk
|
output += chunk
|
||||||
# 自動翻頁
|
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
# 🌟 精準 Prompt 偵測
|
|
||||||
if prompt_pattern and re.search(prompt_pattern, output):
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
break
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
|
||||||
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
|
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
|
|
||||||
if config_type == "full":
|
if config_type == "full":
|
||||||
# 1. 先進入 config 模式
|
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
|
|
||||||
cmd = "show full-configuration | nomore"
|
cmd = "show full-configuration | nomore"
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
|
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
# 3. 抓完後退回上一層 (保持良好習慣)
|
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
|
|
||||||
cmd = "show running-config | nomore"
|
cmd = "show running-config | nomore"
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
raw_output = await read_until_quiet(timeout=3.0)
|
raw_output = await read_until_quiet(timeout=3.0)
|
||||||
|
|
||||||
# 最終退出設備
|
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
|
||||||
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
|
|
||||||
# 🌟 關鍵修正 1:強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
|
|
||||||
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
|
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
|
||||||
|
|
||||||
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
|
|
||||||
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
|
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
|
||||||
|
|
||||||
# 3. 清除純文字的 --More-- (防呆)
|
|
||||||
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
|
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
|
||||||
|
|
||||||
# 🌟 新增 4:清除 CableOS 終端機特有的 (END) 結尾標記
|
|
||||||
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
|
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
|
||||||
|
|
||||||
# 關鍵修正:這裡改用 cleaned_output 來切行!
|
|
||||||
lines = cleaned_output.splitlines()
|
lines = cleaned_output.splitlines()
|
||||||
|
|
||||||
# 🌟 關鍵修正 2:加入 strip() 避免空白干擾,確保精準踢掉提示字元
|
|
||||||
clean_lines = [
|
clean_lines = [
|
||||||
line for line in lines
|
line for line in lines
|
||||||
if not line.strip().startswith(cmd)
|
if not line.strip().startswith(cmd)
|
||||||
|
|
@ -426,14 +344,5 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
|
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
|
||||||
|
|
||||||
finally:
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def parse_config_to_tree(raw_cli: str) -> dict:
|
def parse_config_to_tree(raw_cli: str) -> dict:
|
||||||
"""將純文字設定檔轉換為簡單的階層式 JSON 樹狀圖 (Phase 3 會用到)"""
|
return {"_raw_length": len(raw_cli), "status": "pending_parser"}
|
||||||
# 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化
|
|
||||||
tree = {}
|
|
||||||
# 暫時回傳空字典,確保 Phase 1 & 2 能順利走通
|
|
||||||
# 真正的樹狀解析邏輯我們可以在 Phase 3 完善
|
|
||||||
return {"_raw_length": len(raw_cli), "status": "pending_parser"}
|
|
||||||
|
|
|
||||||
144
database.py
144
database.py
|
|
@ -1,22 +1,27 @@
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
from logger import get_logger
|
from logger import get_logger
|
||||||
|
|
||||||
|
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 💡 PostgreSQL 連線與操作 (高可用性版)
|
# 💡 PostgreSQL 連線與操作 (高可用性版)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
logger = get_logger("app.database")
|
logger = get_logger("app.database")
|
||||||
|
|
||||||
# DB 設定 (從您的 init_db.py 中提取)
|
# 🌟 2. 同步改用 os.getenv 讀取環境變數
|
||||||
DB_CONFIG = {
|
DB_CONFIG = {
|
||||||
"database": "cmts_nms",
|
"database": os.getenv("DB_NAME", "cmts_nms"),
|
||||||
"user": "swpa",
|
"user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
|
||||||
"password": "swpa4920",
|
"password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
|
||||||
"host": "127.0.0.1",
|
"host": os.getenv("DB_HOST", "127.0.0.1"),
|
||||||
"port": "5432"
|
"port": os.getenv("DB_PORT", "5432")
|
||||||
}
|
}
|
||||||
|
|
||||||
_pool: Optional[asyncpg.Pool] = None
|
_pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
@ -56,148 +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")
|
cmts_version = metadata.get("cmts_version")
|
||||||
last_scanned = metadata.get("last_scanned", None)
|
last_scanned = metadata.get("last_scanned", None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號!
|
|
||||||
if cmts_version and cmts_version != "unknown":
|
if cmts_version and cmts_version != "unknown":
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
|
INSERT INTO device_status (host, cmts_version, last_scanned)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (host, config_type)
|
ON CONFLICT (host)
|
||||||
DO UPDATE SET
|
DO UPDATE SET cmts_version = EXCLUDED.cmts_version, last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
|
||||||
cmts_version = EXCLUDED.cmts_version,
|
|
||||||
last_scanned = EXCLUDED.last_scanned,
|
|
||||||
updated_at = CURRENT_TIMESTAMP;
|
|
||||||
"""
|
"""
|
||||||
await conn.execute(query, host, config_type, cmts_version, last_scanned)
|
await conn.execute(query, host, cmts_version, last_scanned)
|
||||||
else:
|
else:
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO device_status (host, config_type, last_scanned)
|
INSERT INTO device_status (host, last_scanned)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2)
|
||||||
ON CONFLICT (host, config_type)
|
ON CONFLICT (host)
|
||||||
DO UPDATE SET
|
DO UPDATE SET last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
|
||||||
last_scanned = EXCLUDED.last_scanned,
|
|
||||||
updated_at = CURRENT_TIMESTAMP;
|
|
||||||
"""
|
"""
|
||||||
await conn.execute(query, host, config_type, last_scanned)
|
await conn.execute(query, host, last_scanned)
|
||||||
return True
|
return True
|
||||||
except Exception 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
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# ------------------------------------------
|
# ------------------------------------------
|
||||||
|
|
|
||||||
13
index.html
13
index.html
|
|
@ -20,9 +20,10 @@
|
||||||
|
|
||||||
<div class="global-settings">
|
<div class="global-settings">
|
||||||
<label><strong>🌐 目標設備:</strong></label>
|
<label><strong>🌐 目標設備:</strong></label>
|
||||||
<input type="text" id="cmtsHost" placeholder="IP 地址" value="10.14.110.4" style="width: 130px;">
|
<!-- 🌟 修改後 (徹底淨化) -->
|
||||||
<input type="text" id="cmtsUser" placeholder="帳號" value="admin" style="width: 100px;">
|
<input type="text" id="cmtsHost" placeholder="IP 地址" style="width: 130px;">
|
||||||
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;">
|
<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;">
|
<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>
|
||||||
|
|
@ -338,10 +339,8 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 10px;">
|
<div style="display: flex; gap: 10px;">
|
||||||
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描局部缺失</button>
|
<button id="btn-scan-options" onclick="scanMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">🚀 掃描缺失選項</button>
|
||||||
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">清除局部快取</button>
|
<button id="btn-clear-options" onclick="clearOptionsCache()" class="btn-modern btn-clear" style="display: none;">🗑️ 清除選項快取</button>
|
||||||
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
|
|
||||||
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
76
init_db.py
76
init_db.py
|
|
@ -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,6 +88,7 @@ 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,
|
||||||
raw_cli TEXT,
|
raw_cli TEXT,
|
||||||
parsed_tree JSONB,
|
parsed_tree JSONB,
|
||||||
|
|
@ -77,7 +96,6 @@ async def init_database():
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# 建立複合索引以加速列表查詢與排序
|
|
||||||
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);
|
||||||
|
|
@ -90,4 +108,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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ class GodModeRequest(BaseModel):
|
||||||
|
|
||||||
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
|
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
|
||||||
async def verify_god_mode(req: GodModeRequest):
|
async def verify_god_mode(req: GodModeRequest):
|
||||||
# 從環境變數讀取正確密碼,預設為 "admin999"
|
# 從環境變數讀取正確密碼,預設為 "19760107@Serc0mm"
|
||||||
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
|
expected_password = os.getenv("GOD_MODE_SECRET", "19760107@Serc0mm")
|
||||||
|
|
||||||
# 使用 compare_digest 防禦計時攻擊
|
# 使用 compare_digest 防禦計時攻擊
|
||||||
if secrets.compare_digest(req.password, expected_password):
|
if secrets.compare_digest(req.password, expected_password):
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from pydantic import BaseModel
|
||||||
from typing import List
|
from typing import List
|
||||||
from netmiko import ConnectHandler
|
from netmiko import ConnectHandler
|
||||||
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
||||||
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
|
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from logger import get_all_log_levels, set_log_level
|
from logger import get_all_log_levels, set_log_level
|
||||||
|
|
@ -26,45 +26,18 @@ def get_filter_file_path(config_type: str) -> str:
|
||||||
return f"filters_{safe_type}.json"
|
return f"filters_{safe_type}.json"
|
||||||
|
|
||||||
async def load_tree_filters(config_type: str) -> list:
|
async def load_tree_filters(config_type: str) -> list:
|
||||||
if USE_DB:
|
try:
|
||||||
try:
|
db_filters = await database.get_tree_filters(config_type)
|
||||||
db_filters = await database.get_tree_filters(config_type)
|
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}")
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,19 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List
|
from typing import List
|
||||||
import json, os, asyncio, re
|
import json, asyncio, re
|
||||||
import database
|
import database
|
||||||
from cmts_scraper import sync_cmts_leaves_async
|
from cmts_scraper import sync_cmts_leaves_async
|
||||||
from shared import CMTS_DEVICE, USE_DB
|
from shared import CMTS_DEVICE
|
||||||
|
|
||||||
router = APIRouter(tags=["Options"])
|
router = APIRouter(tags=["Options"])
|
||||||
|
|
||||||
# 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
|
|
||||||
def get_cache_file(host: str, config_type: str):
|
|
||||||
safe_host = host.replace(".", "_")
|
|
||||||
return f"{safe_host}_{config_type}_cache.json"
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
|
|
||||||
# ==========================================
|
|
||||||
# 將狀態改為空字典,動態根據 host_configType 建立
|
|
||||||
SCAN_STATUS = {}
|
SCAN_STATUS = {}
|
||||||
active_clients = {}
|
active_clients = {}
|
||||||
|
|
||||||
async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
|
async def broadcast_message(message_dict: dict, host: str):
|
||||||
"""將訊息推播給特定頻道的連線前端"""
|
channel_key = host
|
||||||
channel_key = f"{host}_{config_type}"
|
|
||||||
dead_clients = set()
|
dead_clients = set()
|
||||||
|
|
||||||
# SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
|
|
||||||
message_str = f"data: {json.dumps(message_dict)}\n\n"
|
message_str = f"data: {json.dumps(message_dict)}\n\n"
|
||||||
|
|
||||||
for client_queue in active_clients.get(channel_key, set()):
|
for client_queue in active_clients.get(channel_key, set()):
|
||||||
|
|
@ -39,9 +27,8 @@ async def broadcast_message(message_dict: dict, host: str, config_type: str = "r
|
||||||
active_clients[channel_key].discard(dead)
|
active_clients[channel_key].discard(dead)
|
||||||
|
|
||||||
@router.get("/cmts-leaf-options/stream")
|
@router.get("/cmts-leaf-options/stream")
|
||||||
async def sse_stream(request: Request, host: str, config_type: str = "running"):
|
async def sse_stream(request: Request, host: str):
|
||||||
"""前端一載入就會連上這個路由,持續監聽特定頻道的廣播"""
|
channel_key = host
|
||||||
channel_key = f"{host}_{config_type}"
|
|
||||||
client_queue = asyncio.Queue()
|
client_queue = asyncio.Queue()
|
||||||
|
|
||||||
if channel_key not in active_clients:
|
if channel_key not in active_clients:
|
||||||
|
|
@ -51,92 +38,58 @@ async def sse_stream(request: Request, host: str, config_type: str = "running"):
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# 主動檢查斷線,避免死鎖
|
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
# [修復 Leak] 延長 timeout 降低輪詢負擔
|
|
||||||
message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
|
message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
|
||||||
yield message
|
yield message
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
yield ": keepalive\n\n"
|
yield ": keepalive\n\n"
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
# [修復 Leak] 確保斷線時 Queue 絕對會被移出 active_clients 釋放記憶體
|
|
||||||
if channel_key in active_clients:
|
if channel_key in active_clients:
|
||||||
active_clients[channel_key].discard(client_queue)
|
active_clients[channel_key].discard(client_queue)
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -145,14 +98,12 @@ async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: Backg
|
||||||
if not request.leaf_paths:
|
if not request.leaf_paths:
|
||||||
raise HTTPException(status_code=400)
|
raise HTTPException(status_code=400)
|
||||||
|
|
||||||
channel_key = f"{request.host}_{request.config_type}"
|
channel_key = request.host
|
||||||
|
|
||||||
if SCAN_STATUS.get(channel_key, False):
|
if SCAN_STATUS.get(channel_key, False):
|
||||||
return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"}
|
return {"status": "busy", "message": "⚠️ 該設備的掃描任務正在執行中,請勿重複點擊!"}
|
||||||
|
|
||||||
SCAN_STATUS[channel_key] = True
|
SCAN_STATUS[channel_key] = True
|
||||||
|
|
||||||
# 保留您原本完美的路徑清理邏輯
|
|
||||||
cmts_query_paths = []
|
cmts_query_paths = []
|
||||||
for p in request.leaf_paths:
|
for p in request.leaf_paths:
|
||||||
clean_p = p.replace("::", " ")
|
clean_p = p.replace("::", " ")
|
||||||
|
|
@ -160,60 +111,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)}"}
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
# 主動拋出,讓外層捕捉以進行資源回收
|
# 主動拋出,讓外層捕捉以進行資源回收
|
||||||
raise
|
# raise
|
||||||
|
# 🌟 修正:不要再 raise 拋出去了,直接 return 結束任務,讓外層自然回收
|
||||||
|
logger.debug("前端 WebSocket 正常斷開 (使用者重整或關閉網頁)")
|
||||||
|
return
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -48,24 +48,21 @@ export async function apiGetFullConfig(host, username, password, skipFilter = fa
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
|
export async function apiGetLeafOptions(host) {
|
||||||
export async function apiGetLeafOptions(host, configType = 'running') {
|
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}`);
|
||||||
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
|
export async function apiSyncLeafOptions(host, paths) {
|
||||||
export async function apiSyncLeafOptions(host, paths, configType = 'running') {
|
|
||||||
return fetch('/api/v1/cmts-leaf-options/sync', {
|
return fetch('/api/v1/cmts-leaf-options/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
|
body: JSON.stringify({ host, leaf_paths: paths })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 3. 清除快取 (加上 configType 查詢參數)
|
export async function apiClearCache(host, paths) {
|
||||||
export async function apiClearCache(host, paths, configType = 'running') {
|
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}`, {
|
||||||
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(paths)
|
body: JSON.stringify(paths)
|
||||||
|
|
@ -80,9 +77,9 @@ export async function apiGetCmtsVersion(host, username, password) {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGetScanStatus(host, configType = 'running') {
|
export async function apiGetScanStatus(host) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`);
|
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.is_scanning;
|
return data.is_scanning;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -167,37 +164,25 @@ export async function apiListRpds(host, username, password) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
|
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
|
export async function apiSyncLeafOptionsStream(host, paths, onProgress) {
|
||||||
export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
|
|
||||||
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
|
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
|
body: JSON.stringify({ host, leaf_paths: paths })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
|
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
|
||||||
|
|
||||||
// 🌟 核心修改:讀取串流資料
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break; // 伺服器斷開連線 (任務完成)
|
if (done) break;
|
||||||
|
|
||||||
// 解碼二進位資料為文字
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
// 因為一次可能收到多行 JSON,我們用換行符號切開
|
|
||||||
const lines = chunk.split("\n").filter(line => line.trim() !== "");
|
const lines = chunk.split("\n").filter(line => line.trim() !== "");
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line);
|
const data = JSON.parse(line);
|
||||||
onProgress(data); // 將解析後的資料傳給 UI 介面
|
onProgress(data);
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
console.error("JSON 解析錯誤:", e, line);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
332
static/app.js
332
static/app.js
|
|
@ -16,7 +16,9 @@ import {
|
||||||
apiGetCmDiagnostics,
|
apiGetCmDiagnostics,
|
||||||
apiListCms, apiListRpds,
|
apiListCms, apiListRpds,
|
||||||
apiGetLogLevels, apiSetLogLevel,
|
apiGetLogLevels, apiSetLogLevel,
|
||||||
apiVerifyGodMode
|
apiVerifyGodMode,
|
||||||
|
apiGetLeafOptions, // 🌟 補上這個
|
||||||
|
apiSyncLeafOptions // 🌟 補上這個
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
|
|
||||||
// 3. 共用工具模組 (Utils)
|
// 3. 共用工具模組 (Utils)
|
||||||
|
|
@ -40,6 +42,11 @@ import {
|
||||||
} from './mac-domain.js';
|
} from './mac-domain.js';
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 🌟 全域記憶體 (Dual-Track Memory)
|
||||||
|
// ============================================================================
|
||||||
|
window.treeDataStore = { running: null, full: null };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
|
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -48,18 +55,28 @@ let idleTimer;
|
||||||
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
|
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
|
||||||
let globalSSE = null;
|
let globalSSE = null;
|
||||||
|
|
||||||
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
|
// 初始化全域 SSE 監聽器 (設備級別)
|
||||||
function initGlobalSSE(mode = 'running') {
|
function initGlobalSSE() {
|
||||||
|
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
|
||||||
|
if (!isConnected) {
|
||||||
|
if (globalSSE) {
|
||||||
|
globalSSE.close();
|
||||||
|
globalSSE = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
|
if (!connInfo || !connInfo.host) return;
|
||||||
if (globalSSE) globalSSE.close();
|
if (globalSSE) globalSSE.close();
|
||||||
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
|
||||||
|
// 🌟 拔除 config_type,純粹監聽該設備的廣播
|
||||||
|
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}`);
|
||||||
|
|
||||||
globalSSE.onmessage = (event) => {
|
globalSSE.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
const statusEl = document.getElementById('scan-status');
|
const statusEl = document.getElementById('scan-status');
|
||||||
|
|
||||||
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
|
|
||||||
if (data.event === 'scan_start' || data.event === 'progress') {
|
if (data.event === 'scan_start' || data.event === 'progress') {
|
||||||
setAdminButtonsState(true, "⏳ 系統更新中...");
|
setAdminButtonsState(true, "⏳ 系統更新中...");
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +99,6 @@ function initGlobalSSE(mode = 'running') {
|
||||||
statusEl.style.color = "#27ae60";
|
statusEl.style.color = "#27ae60";
|
||||||
setTimeout(() => statusEl.textContent = "", 5000);
|
setTimeout(() => statusEl.textContent = "", 5000);
|
||||||
}
|
}
|
||||||
// 掃描完成才解鎖
|
|
||||||
setAdminButtonsState(false);
|
setAdminButtonsState(false);
|
||||||
localStorage.removeItem('scanLockUntil');
|
localStorage.removeItem('scanLockUntil');
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +107,6 @@ function initGlobalSSE(mode = 'running') {
|
||||||
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
|
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
|
||||||
statusEl.style.color = "#e74c3c";
|
statusEl.style.color = "#e74c3c";
|
||||||
}
|
}
|
||||||
// 發生錯誤也要解鎖
|
|
||||||
setAdminButtonsState(false);
|
setAdminButtonsState(false);
|
||||||
localStorage.removeItem('scanLockUntil');
|
localStorage.removeItem('scanLockUntil');
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +132,8 @@ function startLockStatusPolling() {
|
||||||
if (lockPollingTimer) clearInterval(lockPollingTimer);
|
if (lockPollingTimer) clearInterval(lockPollingTimer);
|
||||||
|
|
||||||
lockPollingTimer = setInterval(async () => {
|
lockPollingTimer = setInterval(async () => {
|
||||||
|
if (!isConnected) return; // 🌟 新增防線:未連線時不進行輪詢
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
if (!connInfo || !connInfo.host) return;
|
if (!connInfo || !connInfo.host) return;
|
||||||
const host = connInfo.host;
|
const host = connInfo.host;
|
||||||
|
|
@ -139,82 +156,84 @@ function startLockStatusPolling() {
|
||||||
for (const [path, lockInfo] of Object.entries(currentLocks)) {
|
for (const [path, lockInfo] of Object.entries(currentLocks)) {
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
||||||
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
|
// 🛡️ 防閃爍機制
|
||||||
if (window.recentlyReleasedLocks[lockKey]) {
|
if (window.recentlyReleasedLocks[lockKey]) {
|
||||||
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
|
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
|
||||||
continue; // 跳過,不當作鎖定
|
continue;
|
||||||
} else {
|
} else {
|
||||||
delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶
|
delete window.recentlyReleasedLocks[lockKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newLockState.add(lockKey); // 記憶體加入 Host 標籤
|
newLockState.add(lockKey);
|
||||||
|
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
|
|
||||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
|
|
||||||
|
|
||||||
targetBtns.forEach(btn => {
|
// 🌟 效能優化:只在當前啟用的視圖中搜尋,避免掃描隱藏的龐大 DOM
|
||||||
// 略過目前正在編輯的那個實體按鈕
|
const currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running';
|
||||||
if (btn.id === `edit-btn-${currentEditElementId}`) return;
|
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
|
||||||
|
|
||||||
|
if (activeContainer) {
|
||||||
|
const targetBtns = activeContainer.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
|
||||||
|
|
||||||
|
targetBtns.forEach(btn => {
|
||||||
|
if (btn.id === `edit-btn-${currentEditElementId}`) return;
|
||||||
|
|
||||||
if (btn.innerText !== "🔒") {
|
if (btn.innerText !== "🔒") {
|
||||||
btn.style.pointerEvents = 'none';
|
btn.style.pointerEvents = 'none';
|
||||||
btn.style.opacity = '0.2';
|
btn.style.opacity = '0.2';
|
||||||
|
|
||||||
const btnPath = btn.getAttribute('data-path');
|
const btnPath = btn.getAttribute('data-path');
|
||||||
if (btnPath === path) {
|
if (btnPath === path) {
|
||||||
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
|
btn.title = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
|
||||||
if (lockInfo.user_id !== SESSION_USER_ID) {
|
|
||||||
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
|
||||||
} else {
|
} else {
|
||||||
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
||||||
}
|
}
|
||||||
} else {
|
btn.innerText = "🔒";
|
||||||
// 子節點被父節點鎖定
|
|
||||||
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
|
||||||
}
|
}
|
||||||
btn.innerText = "🔒";
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔓 任務 2:處理「解除鎖定」的節點
|
// 🔓 任務 2:處理「解除鎖定」的節點
|
||||||
activeLockState.forEach(oldKey => {
|
activeLockState.forEach(oldKey => {
|
||||||
const [oldHost, oldPath] = oldKey.split('@@');
|
const [oldHost, oldPath] = oldKey.split('@@');
|
||||||
|
|
||||||
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
|
|
||||||
if (oldHost === host && !newLockState.has(oldKey)) {
|
if (oldHost === host && !newLockState.has(oldKey)) {
|
||||||
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
|
|
||||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
|
|
||||||
|
|
||||||
targetBtns.forEach(btn => {
|
// 🌟 效能優化:同樣只在當前啟用的視圖中搜尋
|
||||||
if (btn.innerText === "🔒") {
|
const currentMode = document.getElementById('configTask')?.value === 'form-full-config' ? 'full' : 'running';
|
||||||
const btnPath = btn.getAttribute('data-path');
|
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
|
||||||
let stillLockedByOther = false;
|
|
||||||
|
if (activeContainer) {
|
||||||
// 檢查自己是否還在鎖定清單中
|
const targetBtns = activeContainer.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
|
||||||
if (currentLocks[btnPath]) {
|
|
||||||
stillLockedByOther = true;
|
targetBtns.forEach(btn => {
|
||||||
} else {
|
if (btn.innerText === "🔒") {
|
||||||
// 檢查是否有其他父節點鎖定它
|
const btnPath = btn.getAttribute('data-path');
|
||||||
for (const lockedPath of Object.keys(currentLocks)) {
|
let stillLockedByOther = false;
|
||||||
if (btnPath.startsWith(lockedPath + "::")) {
|
|
||||||
stillLockedByOther = true;
|
if (currentLocks[btnPath]) {
|
||||||
break;
|
stillLockedByOther = true;
|
||||||
|
} else {
|
||||||
|
for (const lockedPath of Object.keys(currentLocks)) {
|
||||||
|
if (btnPath.startsWith(lockedPath + "::")) {
|
||||||
|
stillLockedByOther = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!stillLockedByOther) {
|
||||||
|
btn.style.pointerEvents = 'auto';
|
||||||
|
btn.style.opacity = '0.3';
|
||||||
|
btn.title = "鎖定並編輯此項目";
|
||||||
|
btn.innerText = "✏️";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if (!stillLockedByOther) {
|
}
|
||||||
btn.style.pointerEvents = 'auto';
|
|
||||||
btn.style.opacity = '0.3';
|
|
||||||
btn.title = "鎖定並編輯此項目";
|
|
||||||
btn.innerText = "✏️";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -231,7 +250,7 @@ function startLockStatusPolling() {
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
initTerminal();
|
initTerminal();
|
||||||
resetIdleTimer(); // 頁面載入時啟動閒置計時器
|
resetIdleTimer(); // 頁面載入時啟動閒置計時器
|
||||||
initGlobalSSE(); // 啟動全域廣播監聽
|
// initGlobalSSE(); <--- 🌟 刪除這行!不要在網頁剛開時就偷聽
|
||||||
startLockStatusPolling(); // 啟動鎖定狀態輪詢
|
startLockStatusPolling(); // 啟動鎖定狀態輪詢
|
||||||
|
|
||||||
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
|
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
|
||||||
|
|
@ -420,7 +439,7 @@ function switchConfigTask() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initGlobalSSE(currentMode);
|
initGlobalSSE();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -596,7 +615,7 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
|
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
|
||||||
if (versionRes.status === 'success') {
|
if (versionRes.status === 'success') {
|
||||||
const currentVersion = versionRes.version;
|
const currentVersion = versionRes.version;
|
||||||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
|
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}`);
|
||||||
const optData = await optRes.json();
|
const optData = await optRes.json();
|
||||||
const cachedVersion = optData.__metadata__?.cmts_version;
|
const cachedVersion = optData.__metadata__?.cmts_version;
|
||||||
|
|
||||||
|
|
@ -678,7 +697,8 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
|
|
||||||
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
|
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
|
||||||
|
|
||||||
window.currentTreeData = finalTreeData;
|
// 🌟 1. 存入雙軌記憶體,不再污染全域變數
|
||||||
|
window.treeDataStore[configType] = finalTreeData;
|
||||||
|
|
||||||
const currentSelectedTask = document.getElementById('configTask').value;
|
const currentSelectedTask = document.getElementById('configTask').value;
|
||||||
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
|
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
|
||||||
|
|
@ -686,19 +706,29 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
if (currentSelectedTask !== expectedTask) return;
|
if (currentSelectedTask !== expectedTask) return;
|
||||||
|
|
||||||
if (treeContainer) {
|
if (treeContainer) {
|
||||||
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
|
// 🌟 統一渲染作法:先顯示 UI 提示,讓出主執行緒
|
||||||
if (loadingMsg) loadingMsg.style.display = 'none';
|
treeContainer.innerHTML = `<div style="color: #f39c12; font-weight: bold; padding: 15px;">⏳ 正在極速建構樹狀圖,請稍候...</div>`;
|
||||||
|
document.body.style.cursor = 'wait';
|
||||||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
|
|
||||||
if (isSomeoneScanning) {
|
// 因為內部有 await apiGetScanStatus,所以這裡使用 async () => {}
|
||||||
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
|
setTimeout(async () => {
|
||||||
lockScanButton(60000);
|
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
|
||||||
} else {
|
if (loadingMsg) loadingMsg.style.display = 'none';
|
||||||
enableGlobalScanButton();
|
document.body.style.cursor = 'default';
|
||||||
if (needAutoScan) {
|
|
||||||
setTimeout(() => scanGlobalMissingOptions(configType), 500);
|
// 🌟 拔除 configType,檢查設備級別的掃描狀態
|
||||||
|
const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
|
||||||
|
if (isSomeoneScanning) {
|
||||||
|
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
|
||||||
|
lockScanButton(60000);
|
||||||
|
} else {
|
||||||
|
enableGlobalScanButton();
|
||||||
|
if (needAutoScan) {
|
||||||
|
// 🌟 呼叫新的全域掃描函數
|
||||||
|
setTimeout(() => scanMissingOptions(), 500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -707,7 +737,7 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
if (progressInterval) clearInterval(progressInterval);
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
if (loadingMsg) {
|
if (loadingMsg) {
|
||||||
loadingMsg.style.display = 'none';
|
loadingMsg.style.display = 'none';
|
||||||
loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色
|
loadingMsg.style.color = "#f39c12";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -913,16 +943,14 @@ function renderMerChart(channelName, histogramData) {
|
||||||
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
|
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
|
// 為現有的 input 加上下拉選項 (不破壞原有結構)
|
||||||
async function enhanceInputWithOptions(path, elementId, mode = 'running') {
|
async function enhanceInputWithOptions(path, elementId) {
|
||||||
// 🌟 1. 取得當前連線的 IP
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
if (!connInfo || !connInfo.host) return;
|
if (!connInfo || !connInfo.host) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🌟 2. GET 請求:網址加上 host 參數
|
// 🌟 拔除 config_type 參數
|
||||||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
const optData = await apiGetLeafOptions(connInfo.host);
|
||||||
const optData = await optRes.json();
|
|
||||||
|
|
||||||
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
const leafCache = optData[cacheKey];
|
const leafCache = optData[cacheKey];
|
||||||
|
|
@ -944,11 +972,11 @@ async function enhanceInputWithOptions(path, elementId, mode = 'running') {
|
||||||
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
|
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🌟 3. POST 請求:把 host 塞進 body 裡面
|
// 🌟 拔除 config_type 參數
|
||||||
fetch('/api/v1/cmts-leaf-options/sync', {
|
fetch('/api/v1/cmts-leaf-options/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode })
|
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey] })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -979,58 +1007,38 @@ function extractAllCacheKeys(node, path = '') {
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performScan(isGlobal, mode = 'running') {
|
// 🌟 全新:設備級全域差異掃描
|
||||||
|
async function scanMissingOptions() {
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
|
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
|
||||||
|
|
||||||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
|
const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
|
||||||
if (isSomeoneScanning) {
|
if (isSomeoneScanning) {
|
||||||
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
|
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treeContainer = document.getElementById(`tree-container-${mode}`);
|
// 🌟 從雙軌記憶體中精準提取當前視圖的資料樹
|
||||||
if (!treeContainer) return;
|
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||||||
|
const targetTreeData = window.treeDataStore[mode];
|
||||||
|
if (!targetTreeData) return alert("請先載入設備配置!");
|
||||||
|
|
||||||
const statusEl = document.getElementById('scan-status');
|
const statusEl = document.getElementById('scan-status');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
|
const optData = await apiGetLeafOptions(connInfo.host);
|
||||||
const optData = await optRes.json();
|
|
||||||
const pathsToSync = [];
|
const pathsToSync = [];
|
||||||
|
const allKeys = extractAllCacheKeys(targetTreeData);
|
||||||
if (isGlobal) {
|
|
||||||
// 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
|
// 差異比對:只挑出資料庫沒有的 Key
|
||||||
if (!window.currentTreeData) return alert("請先載入設備配置!");
|
allKeys.forEach(cacheKey => {
|
||||||
const allKeys = extractAllCacheKeys(window.currentTreeData);
|
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
|
||||||
|
pathsToSync.push(cacheKey);
|
||||||
allKeys.forEach(cacheKey => {
|
}
|
||||||
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
|
});
|
||||||
pathsToSync.push(cacheKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的)
|
|
||||||
const allContainers = treeContainer.querySelectorAll('.leaf-container');
|
|
||||||
allContainers.forEach(container => {
|
|
||||||
const isHiddenByFolder = container.closest('details:not([open])') !== null;
|
|
||||||
if (!isHiddenByFolder) {
|
|
||||||
const path = container.getAttribute('data-path');
|
|
||||||
if (path) {
|
|
||||||
const origVal = container.getAttribute('data-original') || '';
|
|
||||||
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
|
||||||
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
|
||||||
|
|
||||||
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
|
|
||||||
pathsToSync.push(cacheKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathsToSync.length === 0) {
|
if (pathsToSync.length === 0) {
|
||||||
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!";
|
statusEl.textContent = "✅ 所有欄位都已有最新選項,無需掃描!";
|
||||||
statusEl.style.color = "#27ae60";
|
statusEl.style.color = "#27ae60";
|
||||||
setTimeout(() => statusEl.textContent = "", 3000);
|
setTimeout(() => statusEl.textContent = "", 3000);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1039,7 +1047,7 @@ async function performScan(isGlobal, mode = 'running') {
|
||||||
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
|
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode })
|
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
@ -1054,6 +1062,47 @@ async function performScan(isGlobal, mode = 'running') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 全新:設備級清除快取
|
||||||
|
async function clearOptionsCache() {
|
||||||
|
const connInfo = getGlobalConnectionInfo();
|
||||||
|
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
|
||||||
|
|
||||||
|
const isSomeoneScanning = await apiGetScanStatus(connInfo.host);
|
||||||
|
if (isSomeoneScanning) {
|
||||||
|
alert("⚠️ 系統正在進行選項掃描更新中!\n為避免資料衝突,請稍候掃描完成再執行清除動作。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optData = await apiGetLeafOptions(connInfo.host);
|
||||||
|
const pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__');
|
||||||
|
|
||||||
|
if (pathsToClear.length === 0) return alert("目前沒有任何快取可以清除!");
|
||||||
|
|
||||||
|
if (!confirm(`確定要清除該設備共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料,並自動為您重新掃描)`)) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(connInfo.host)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pathsToClear)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const statusEl = document.getElementById('scan-status');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`;
|
||||||
|
statusEl.style.color = "#27ae60";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除完畢後,自動呼叫掃描
|
||||||
|
await scanMissingOptions();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("清除快取失敗:", error);
|
||||||
|
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
|
// 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -1307,7 +1356,14 @@ async function loadRealConfigForFilters() {
|
||||||
|
|
||||||
if (finalTreeData) {
|
if (finalTreeData) {
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
|
// 🌟 統一渲染作法:先顯示 UI 提示,讓出主執行緒
|
||||||
|
container.innerHTML = `<div style="color: #f39c12; font-weight: bold; padding: 15px;">⏳ 正在極速建構過濾器樹狀圖,請稍候...</div>`;
|
||||||
|
document.body.style.cursor = 'wait';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
}, 20);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("未收到完整的配置資料");
|
throw new Error("未收到完整的配置資料");
|
||||||
}
|
}
|
||||||
|
|
@ -1316,7 +1372,7 @@ async function loadRealConfigForFilters() {
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
|
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
|
||||||
} finally {
|
} finally {
|
||||||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
loadingMsg.style.display = 'none';
|
loadingMsg.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1904,22 +1960,19 @@ document.addEventListener("DOMContentLoaded", checkScanButtonState);
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
|
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
|
const ADMIN_BTN_IDS = ['btn-scan-options', 'btn-clear-options'];
|
||||||
|
|
||||||
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
|
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
|
||||||
function unlockAdminFeatures() {
|
function unlockAdminFeatures() {
|
||||||
// 1. 解鎖全域快取掃描按鈕
|
|
||||||
ADMIN_BTN_IDS.forEach(id => {
|
ADMIN_BTN_IDS.forEach(id => {
|
||||||
const btn = document.getElementById(id);
|
const btn = document.getElementById(id);
|
||||||
if (btn) btn.style.display = 'inline-block';
|
if (btn) btn.style.display = 'inline-block';
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
|
|
||||||
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
|
||||||
btn.style.display = 'inline-block';
|
btn.style.display = 'inline-block';
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
|
|
||||||
document.querySelectorAll('.admin-only-action').forEach(btn => {
|
document.querySelectorAll('.admin-only-action').forEach(btn => {
|
||||||
btn.style.display = 'inline-block';
|
btn.style.display = 'inline-block';
|
||||||
});
|
});
|
||||||
|
|
@ -1933,9 +1986,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
unlockAdminFeatures();
|
unlockAdminFeatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門
|
// 🌟 2. 連點標題觸發機制
|
||||||
|
|
||||||
// 🌟 3. 連點標題觸發機制
|
|
||||||
let clickCount = 0;
|
let clickCount = 0;
|
||||||
let clickTimer = null;
|
let clickTimer = null;
|
||||||
if (appTitle) {
|
if (appTitle) {
|
||||||
|
|
@ -1953,7 +2004,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 4. 綁定密碼框的 Enter 快捷鍵
|
// 🌟 3. 綁定密碼框的 Enter 快捷鍵
|
||||||
const pwInput = document.getElementById('godModePassword');
|
const pwInput = document.getElementById('godModePassword');
|
||||||
if (pwInput) {
|
if (pwInput) {
|
||||||
pwInput.addEventListener('keypress', function(e) {
|
pwInput.addEventListener('keypress', function(e) {
|
||||||
|
|
@ -2023,7 +2074,7 @@ function triggerShake(element) {
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 統一控制 4 顆按鈕的狀態
|
// 統一控制 2 顆按鈕的狀態
|
||||||
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
||||||
ADMIN_BTN_IDS.forEach(id => {
|
ADMIN_BTN_IDS.forEach(id => {
|
||||||
const btn = document.getElementById(id);
|
const btn = document.getElementById(id);
|
||||||
|
|
@ -2031,19 +2082,17 @@ function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
|
||||||
btn.disabled = isBusy;
|
btn.disabled = isBusy;
|
||||||
if (isBusy) {
|
if (isBusy) {
|
||||||
btn.classList.add('is-busy');
|
btn.classList.add('is-busy');
|
||||||
if (id.includes('scan')) btn.innerText = text;
|
if (id === 'btn-scan-options') btn.innerText = text;
|
||||||
} else {
|
} else {
|
||||||
btn.classList.remove('is-busy');
|
btn.classList.remove('is-busy');
|
||||||
// 恢復原本的文字
|
if (id === 'btn-scan-options') btn.innerText = "🚀 掃描缺失選項";
|
||||||
if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失";
|
|
||||||
if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkScanButtonState() {
|
function checkScanButtonState() {
|
||||||
const btn = document.getElementById('btn-scan-visible');
|
const btn = document.getElementById('btn-scan-options');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const scanLockUntil = localStorage.getItem('scanLockUntil');
|
const scanLockUntil = localStorage.getItem('scanLockUntil');
|
||||||
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
|
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
|
||||||
|
|
@ -2072,7 +2121,6 @@ function enableGlobalScanButton() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫)
|
// 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -2105,10 +2153,8 @@ window.loadMacDomainList = loadMacDomainList;
|
||||||
window.openSystemSettings = openSystemSettings;
|
window.openSystemSettings = openSystemSettings;
|
||||||
window.loadRealConfigForFilters = loadRealConfigForFilters;
|
window.loadRealConfigForFilters = loadRealConfigForFilters;
|
||||||
window.saveSystemSettings = saveSystemSettings;
|
window.saveSystemSettings = saveSystemSettings;
|
||||||
window.scanVisibleMissingOptions = scanVisibleMissingOptions;
|
window.scanMissingOptions = scanMissingOptions;
|
||||||
window.scanGlobalMissingOptions = scanGlobalMissingOptions;
|
window.clearOptionsCache = clearOptionsCache;
|
||||||
window.clearVisibleCache = clearVisibleCache;
|
|
||||||
window.clearGlobalCache = clearGlobalCache;
|
|
||||||
window.previewNodeCLI = previewNodeCLI;
|
window.previewNodeCLI = previewNodeCLI;
|
||||||
window.switchFilterMode = switchFilterMode;
|
window.switchFilterMode = switchFilterMode;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,18 +86,14 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export async function startEditFolder(path, elementId) {
|
export async function startEditFolder(path, elementId) {
|
||||||
// 🚨 防護 1:防止連點或重複執行
|
|
||||||
if (activeEditSessions.has(elementId)) return;
|
if (activeEditSessions.has(elementId)) return;
|
||||||
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
|
activeEditSessions.add(elementId);
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const username = connInfo ? connInfo.user : 'admin';
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
const host = connInfo ? connInfo.host : '';
|
const host = connInfo ? connInfo.host : '';
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
||||||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
|
||||||
|
|
||||||
// 立即讓按鈕呈現讀取中,防止使用者焦慮連點
|
|
||||||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
editBtn.style.pointerEvents = 'none';
|
editBtn.style.pointerEvents = 'none';
|
||||||
|
|
@ -107,13 +103,11 @@ export async function startEditFolder(path, elementId) {
|
||||||
try {
|
try {
|
||||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
||||||
|
|
||||||
// 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消
|
|
||||||
if (!activeEditSessions.has(elementId)) {
|
if (!activeEditSessions.has(elementId)) {
|
||||||
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
||||||
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
|
|
||||||
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||||||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||||||
apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去
|
apiReleaseLock(path, SESSION_USER_ID, host);
|
||||||
}
|
}
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
editBtn.style.pointerEvents = 'auto';
|
editBtn.style.pointerEvents = 'auto';
|
||||||
|
|
@ -121,7 +115,7 @@ export async function startEditFolder(path, elementId) {
|
||||||
editBtn.title = "鎖定並編輯此項目";
|
editBtn.title = "鎖定並編輯此項目";
|
||||||
editBtn.innerText = "✏️";
|
editBtn.innerText = "✏️";
|
||||||
}
|
}
|
||||||
return; // 立即中斷,絕不觸碰 UI
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 409) {
|
if (result.status === 409) {
|
||||||
|
|
@ -139,18 +133,15 @@ export async function startEditFolder(path, elementId) {
|
||||||
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
||||||
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
||||||
|
|
||||||
// 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
|
|
||||||
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
||||||
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
||||||
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
||||||
|
|
||||||
// 🌟 確保不在冷卻名單中 (防止手速過快)
|
|
||||||
if (window.recentlyReleasedLocks) {
|
if (window.recentlyReleasedLocks) {
|
||||||
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
// 🌟 擴大選擇器:連同所有子節點一起秒級鎖定
|
|
||||||
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
|
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
|
||||||
if (btn.id !== `edit-btn-${elementId}`) {
|
if (btn.id !== `edit-btn-${elementId}`) {
|
||||||
btn.style.pointerEvents = 'none';
|
btn.style.pointerEvents = 'none';
|
||||||
|
|
@ -174,27 +165,61 @@ export async function startEditFolder(path, elementId) {
|
||||||
const detailsEl = document.getElementById(`details-${elementId}`);
|
const detailsEl = document.getElementById(`details-${elementId}`);
|
||||||
if (detailsEl) detailsEl.open = true;
|
if (detailsEl) detailsEl.open = true;
|
||||||
|
|
||||||
const contentDiv = document.getElementById(`content-${elementId}`);
|
// ==========================================
|
||||||
if (!contentDiv) {
|
// 🌟 完美修復:在抓取葉子節點前,強制將該資料夾底下的所有 HTML 預先渲染出來
|
||||||
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
|
// 這樣 querySelectorAll 就能一次抓到所有深層的欄位!
|
||||||
return;
|
// ==========================================
|
||||||
|
if (typeof window.forceRenderFolderHTML === 'function') {
|
||||||
|
window.forceRenderFolderHTML(elementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
if (!contentDiv) return;
|
||||||
|
|
||||||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
|
||||||
let optData = {};
|
let optData = {};
|
||||||
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
|
try { optData = await apiGetLeafOptions(host); } catch (e) {}
|
||||||
|
|
||||||
// 🚨 防護 3:如果等待 API 期間使用者按了取消,立刻中斷後續渲染
|
|
||||||
if (!activeEditSessions.has(elementId)) return;
|
if (!activeEditSessions.has(elementId)) return;
|
||||||
|
|
||||||
const pathsToSync = [];
|
const pathsToSync = [];
|
||||||
const containersArray = Array.from(leafContainers);
|
const containersArray = Array.from(leafContainers);
|
||||||
|
|
||||||
|
// 🌟 第一階段:盤點缺少的快取
|
||||||
|
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;
|
const CHUNK_SIZE = 50;
|
||||||
|
|
||||||
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
||||||
// 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷
|
|
||||||
if (!activeEditSessions.has(elementId)) return;
|
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 => {
|
||||||
|
|
@ -206,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 = "";
|
||||||
|
|
@ -237,8 +260,6 @@ export async function startEditFolder(path, elementId) {
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
activeEditSessions.delete(elementId);
|
activeEditSessions.delete(elementId);
|
||||||
|
|
@ -251,7 +272,6 @@ export async function startEditFolder(path, elementId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startEditLeaf(path, elementId) {
|
export async function startEditLeaf(path, elementId) {
|
||||||
// 🚨 防護 1:防止連點或重複執行
|
|
||||||
if (activeEditSessions.has(elementId)) return;
|
if (activeEditSessions.has(elementId)) return;
|
||||||
activeEditSessions.add(elementId);
|
activeEditSessions.add(elementId);
|
||||||
|
|
||||||
|
|
@ -260,8 +280,6 @@ export async function startEditLeaf(path, elementId) {
|
||||||
const host = connInfo ? connInfo.host : '';
|
const host = connInfo ? connInfo.host : '';
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
||||||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
|
||||||
|
|
||||||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
editBtn.style.pointerEvents = 'none';
|
editBtn.style.pointerEvents = 'none';
|
||||||
|
|
@ -271,10 +289,8 @@ export async function startEditLeaf(path, elementId) {
|
||||||
try {
|
try {
|
||||||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
||||||
|
|
||||||
// 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消
|
|
||||||
if (!activeEditSessions.has(elementId)) {
|
if (!activeEditSessions.has(elementId)) {
|
||||||
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
||||||
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
|
|
||||||
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
||||||
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
||||||
apiReleaseLock(path, SESSION_USER_ID, host);
|
apiReleaseLock(path, SESSION_USER_ID, host);
|
||||||
|
|
@ -328,32 +344,30 @@ export async function startEditLeaf(path, elementId) {
|
||||||
const origVal = container.getAttribute('data-original');
|
const origVal = container.getAttribute('data-original');
|
||||||
const safeOrigVal = escapeHTML(origVal);
|
const safeOrigVal = escapeHTML(origVal);
|
||||||
|
|
||||||
// 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22)
|
|
||||||
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
|
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let optData = await apiGetLeafOptions(host, currentMode);
|
let optData = await apiGetLeafOptions(host);
|
||||||
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||||||
let leafCache = optData[cacheKey];
|
let leafCache = optData[cacheKey];
|
||||||
|
|
||||||
if (!leafCache) {
|
if (!leafCache) {
|
||||||
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
apiSyncLeafOptions(host, [cacheKey]);
|
||||||
let found = false;
|
let found = false;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
if (!activeEditSessions.has(elementId)) return;
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
optData = await apiGetLeafOptions(host, currentMode);
|
optData = await apiGetLeafOptions(host);
|
||||||
leafCache = optData[cacheKey];
|
leafCache = optData[cacheKey];
|
||||||
|
|
||||||
// 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來
|
|
||||||
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
|
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
|
||||||
found = true; break;
|
found = true; break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
if (!activeEditSessions.has(elementId)) return;
|
||||||
|
|
||||||
let inputHtml = "";
|
let inputHtml = "";
|
||||||
let hintAttr = "";
|
let hintAttr = "";
|
||||||
|
|
@ -381,7 +395,7 @@ export async function startEditLeaf(path, elementId) {
|
||||||
}
|
}
|
||||||
container.innerHTML = inputHtml;
|
container.innerHTML = inputHtml;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
if (!activeEditSessions.has(elementId)) return;
|
||||||
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
|
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -891,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);
|
||||||
|
|
|
||||||
|
|
@ -287,3 +287,18 @@ button:hover { background-color: #3498db; }
|
||||||
border-color: #e74c3c !important; /* 震動時外框變紅 */
|
border-color: #e74c3c !important; /* 震動時外框變紅 */
|
||||||
background-color: #fdedec !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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -74,11 +88,17 @@ export function connectWebSocket(resetCallback) {
|
||||||
document.getElementById('cmtsPass').disabled = true;
|
document.getElementById('cmtsPass').disabled = true;
|
||||||
term.focus();
|
term.focus();
|
||||||
|
|
||||||
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
|
// 🌟 原有:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
|
||||||
const queryTaskSelect = document.getElementById('queryTask');
|
const queryTaskSelect = document.getElementById('queryTask');
|
||||||
if (queryTaskSelect) {
|
if (queryTaskSelect) {
|
||||||
queryTaskSelect.dispatchEvent(new Event('change'));
|
queryTaskSelect.dispatchEvent(new Event('change'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 新增:連線成功後,強制觸發配置任務切換,藉此啟動「正確 IP」的 SSE 監聽
|
||||||
|
const configTaskSelect = document.getElementById('configTask');
|
||||||
|
if (configTaskSelect) {
|
||||||
|
configTaskSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
|
@ -102,6 +122,12 @@ 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');
|
||||||
|
|
||||||
|
// 🌟 新增:斷線時,強制觸發配置任務切換,藉此「關閉」SSE 監聽
|
||||||
|
const configTaskSelect = document.getElementById('configTask');
|
||||||
|
if (configTaskSelect) {
|
||||||
|
configTaskSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
|
||||||
if (resetCallback) resetCallback();
|
if (resetCallback) resetCallback();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,19 @@ window.lazyLoadFolder = function(elementId, isFilter = false) {
|
||||||
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
|
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
|
||||||
|
|
||||||
if (contentDiv && cache) {
|
if (contentDiv && cache) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -43,42 +50,87 @@ window.lazyLoadFolder = function(elementId, isFilter = false) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export function expandAll(elementId) {
|
export function expandAll(elementId) {
|
||||||
const details = document.getElementById(`details-${elementId}`);
|
const details = document.getElementById(`details-${elementId}`);
|
||||||
if (details) {
|
if (!details) return;
|
||||||
// 如果還沒載入,先強制載入它
|
|
||||||
if (!details.dataset.loaded) {
|
const isFilter = details.id.includes('filter-');
|
||||||
const isFilter = details.id.includes('filter-');
|
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 參數
|
||||||
|
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false, renderAll = false) {
|
||||||
const htmlParts = [];
|
const htmlParts = [];
|
||||||
if (!node || typeof node !== 'object') return htmlParts.join('');
|
if (!node || typeof node !== 'object') return htmlParts.join('');
|
||||||
|
|
||||||
|
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||||||
|
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
||||||
|
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
||||||
|
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||||||
|
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(node)) {
|
for (const [key, value] of Object.entries(node)) {
|
||||||
const currentPath = path ? `${path}::${key}` : key;
|
const currentPath = path ? `${path}::${key}` : key;
|
||||||
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
|
@ -89,20 +141,11 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
const elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
const elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
|
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
|
||||||
|
|
||||||
// 🌟 將資料存入快取,供延遲渲染使用
|
|
||||||
folderDataCache[elementId] = value;
|
folderDataCache[elementId] = value;
|
||||||
|
|
||||||
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
|
||||||
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
|
||||||
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
|
||||||
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
|
||||||
|
|
||||||
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
||||||
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
||||||
|
|
||||||
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
|
||||||
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
|
||||||
|
|
||||||
const expandCollapseBtns = `
|
const expandCollapseBtns = `
|
||||||
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
|
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
|
||||||
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
|
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
|
||||||
|
|
@ -118,7 +161,6 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
|
|
||||||
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
||||||
let isLocked = false;
|
let isLocked = false;
|
||||||
let lockTitle = "鎖定並編輯此群組";
|
let lockTitle = "鎖定並編輯此群組";
|
||||||
|
|
@ -128,37 +170,37 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
|
|
||||||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
const hostLocks = window.globalActiveLocks[currentHost];
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
|
||||||
// 1. 檢查自己是否被鎖定
|
|
||||||
if (hostLocks[currentPath]) {
|
if (hostLocks[currentPath]) {
|
||||||
const lockInfo = hostLocks[currentPath];
|
const lockInfo = hostLocks[currentPath];
|
||||||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
isLocked = true;
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
lockText = "🔒";
|
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
|
||||||
lockOpacity = "0.2";
|
|
||||||
lockPointer = "none";
|
|
||||||
lockTitle = lockInfo.user_id !== SESSION_USER_ID
|
|
||||||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
|
||||||
: `🔒 您正在另一個視圖編輯此項目`;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
|
|
||||||
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
||||||
if (currentPath.startsWith(lockedPath + "::")) {
|
if (currentPath.startsWith(lockedPath + "::")) {
|
||||||
isLocked = true;
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
lockText = "🔒";
|
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
|
||||||
lockOpacity = "0.2";
|
|
||||||
lockPointer = "none";
|
|
||||||
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚀 核心魔法:如果 forceExpand 為 true,直接在記憶體中遞迴生成子節點 HTML
|
||||||
|
const isOpenAttr = forceExpand ? 'open' : '';
|
||||||
|
const isLoadedAttr = (forceExpand || renderAll) ? 'true' : 'false';
|
||||||
|
const displayClosed = forceExpand ? 'none' : 'inline-block';
|
||||||
|
const displayOpen = forceExpand ? 'inline-block' : 'none';
|
||||||
|
|
||||||
|
let innerContent = '<!-- 🌟 延遲渲染 -->';
|
||||||
|
if (forceExpand || renderAll) {
|
||||||
|
// 🌟 遞迴呼叫時,把 renderAll 傳遞下去
|
||||||
|
innerContent = buildTree(value, currentPath, isCommandGroup, mode, forceExpand, renderAll);
|
||||||
|
}
|
||||||
|
|
||||||
htmlParts.push(`
|
htmlParts.push(`
|
||||||
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
||||||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
|
||||||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
||||||
<summary class="tree-folder-header"
|
<summary class="tree-folder-header"
|
||||||
title="${cliCommand}"
|
title="${cliCommand}"
|
||||||
|
|
@ -167,8 +209,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
|
style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
|
||||||
|
|
||||||
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
||||||
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
|
<span class="icon-closed" style="display: ${displayClosed};">${iconClosed}</span>
|
||||||
<span class="icon-open" style="display: none;">${iconOpen}</span>
|
<span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
|
||||||
</span>
|
</span>
|
||||||
<b style="color: #2c3e50;">${key}</b>${folderSuffix}
|
<b style="color: #2c3e50;">${key}</b>${folderSuffix}
|
||||||
${expandCollapseBtns}
|
${expandCollapseBtns}
|
||||||
|
|
@ -194,7 +236,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
|
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
|
||||||
<!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 -->
|
${innerContent}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`);
|
`);
|
||||||
|
|
@ -209,13 +251,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
|
||||||
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
|
||||||
|
|
||||||
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
|
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
|
||||||
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
|
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
|
||||||
|
|
||||||
// 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
|
|
||||||
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
||||||
let isLocked = false;
|
let isLocked = false;
|
||||||
let lockTitle = "鎖定並編輯此項目";
|
let lockTitle = "鎖定並編輯此項目";
|
||||||
|
|
@ -225,29 +263,17 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
|
|
||||||
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
const hostLocks = window.globalActiveLocks[currentHost];
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
|
||||||
// 1. 檢查自己是否被鎖定
|
|
||||||
if (hostLocks[currentPath]) {
|
if (hostLocks[currentPath]) {
|
||||||
const lockInfo = hostLocks[currentPath];
|
const lockInfo = hostLocks[currentPath];
|
||||||
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
isLocked = true;
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
lockText = "🔒";
|
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
|
||||||
lockOpacity = "0.2";
|
|
||||||
lockPointer = "none";
|
|
||||||
lockTitle = lockInfo.user_id !== SESSION_USER_ID
|
|
||||||
? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
|
|
||||||
: `🔒 您正在另一個視圖編輯此項目`;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
|
|
||||||
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
||||||
if (currentPath.startsWith(lockedPath + "::")) {
|
if (currentPath.startsWith(lockedPath + "::")) {
|
||||||
isLocked = true;
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
lockText = "🔒";
|
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
|
||||||
lockOpacity = "0.2";
|
|
||||||
lockPointer = "none";
|
|
||||||
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,12 +344,21 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
|
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
|
// 🌟 新增 forceExpand 參數,預設為 false
|
||||||
|
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running', forceExpand = false) {
|
||||||
if (typeof data !== 'object' || data === null) return '';
|
if (typeof data !== 'object' || data === null) return '';
|
||||||
|
|
||||||
const htmlParts = [];
|
const htmlParts = [];
|
||||||
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
|
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
|
||||||
|
|
||||||
|
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||||||
|
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||||||
|
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
||||||
|
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
||||||
|
|
||||||
for (const key in data) {
|
for (const key in data) {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
const currentPath = parentPath ? `${parentPath}::${key}` : key;
|
const currentPath = parentPath ? `${parentPath}::${key}` : key;
|
||||||
|
|
@ -331,12 +366,6 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
|
|
||||||
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
|
||||||
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
|
||||||
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
|
||||||
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
|
||||||
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
|
||||||
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
|
||||||
|
|
||||||
const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
|
const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
|
||||||
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
|
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
|
||||||
const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;
|
const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;
|
||||||
|
|
@ -346,16 +375,12 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
||||||
const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
|
||||||
// 🌟 存入過濾器專用快取
|
|
||||||
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
|
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
|
||||||
|
|
||||||
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
||||||
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
||||||
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
|
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
|
||||||
|
|
||||||
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
|
||||||
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
|
||||||
|
|
||||||
const expandCollapseBtns = `
|
const expandCollapseBtns = `
|
||||||
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
|
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
|
||||||
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
|
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
|
||||||
|
|
@ -371,23 +396,33 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 🌟 加入 lazyLoadFolder 觸發
|
// 🚀 核心魔法:如果 forceExpand 為 true,直接在記憶體中遞迴生成子節點 HTML
|
||||||
|
const isOpenAttr = forceExpand ? 'open' : '';
|
||||||
|
const isLoadedAttr = forceExpand ? 'true' : 'false';
|
||||||
|
const displayClosed = forceExpand ? 'none' : 'inline-block';
|
||||||
|
const displayOpen = forceExpand ? 'inline-block' : 'none';
|
||||||
|
|
||||||
|
let innerContent = '<!-- 🌟 延遲渲染 -->';
|
||||||
|
if (forceExpand) {
|
||||||
|
innerContent = buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup, mode, true);
|
||||||
|
}
|
||||||
|
|
||||||
htmlParts.push(`
|
htmlParts.push(`
|
||||||
<details id="details-${elementId}" class="tree-folder"
|
<details id="details-${elementId}" class="tree-folder"
|
||||||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
|
||||||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
||||||
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
|
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
|
||||||
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
|
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
|
||||||
${checkboxHtml}
|
${checkboxHtml}
|
||||||
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
||||||
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
|
<span class="icon-closed" style="display: ${displayClosed};">${iconClosed}</span>
|
||||||
<span class="icon-open" style="display: none;">${iconOpen}</span>
|
<span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>${key}${folderSuffix}</span>
|
<span>${key}${folderSuffix}</span>
|
||||||
${expandCollapseBtns}
|
${expandCollapseBtns}
|
||||||
</summary>
|
</summary>
|
||||||
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
|
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
|
||||||
<!-- 🌟 延遲渲染 -->
|
${innerContent}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`);
|
`);
|
||||||
|
|
@ -430,4 +465,23 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
}
|
}
|
||||||
htmlParts.push('</div>');
|
htmlParts.push('</div>');
|
||||||
return htmlParts.join('');
|
return htmlParts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🌟 輔助函數:強制預先渲染資料夾 HTML (供編輯模式使用)
|
||||||
|
// ==========================================
|
||||||
|
window.forceRenderFolderHTML = function(elementId) {
|
||||||
|
const detailsEl = document.getElementById(`details-${elementId}`);
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
const cache = folderDataCache[elementId];
|
||||||
|
|
||||||
|
if (detailsEl && contentDiv && cache && detailsEl.dataset.loaded !== 'true') {
|
||||||
|
const currentPath = detailsEl.dataset.path;
|
||||||
|
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
|
||||||
|
const mode = detailsEl.dataset.mode;
|
||||||
|
|
||||||
|
// 重新渲染,傳入 renderAll = true (生成 HTML 但不展開)
|
||||||
|
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode, false, true);
|
||||||
|
detailsEl.dataset.loaded = 'true';
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue