Compare commits
46 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
4198d17e42 | |
|
|
10d53db467 | |
|
|
69447981bc | |
|
|
d4721b163f | |
|
|
050936668d | |
|
|
387d1d600a | |
|
|
8f026aac9e | |
|
|
7a49ef9833 | |
|
|
061ee3435a | |
|
|
bc933e1e39 | |
|
|
19d5a30305 | |
|
|
8bc68b8bf1 | |
|
|
3cd7ce253e | |
|
|
ecedee49d2 | |
|
|
6698e34292 | |
|
|
2cbbdb7b0c | |
|
|
2168ccf770 | |
|
|
60195a735e | |
|
|
4437e4e3fa | |
|
|
d0d80b26ac | |
|
|
e9b07ddadc | |
|
|
fb1c091be9 | |
|
|
cf4b6f2e3b | |
|
|
61241c796e | |
|
|
9a3850031c | |
|
|
f8285f7ff7 | |
|
|
a868d82bd5 | |
|
|
e00642553f | |
|
|
77821fc620 | |
|
|
eb8c3af98e | |
|
|
f2de682b9b | |
|
|
cfc4060d2b | |
|
|
9de6896363 | |
|
|
d11f70b898 | |
|
|
d53f90efff | |
|
|
05fdccf235 | |
|
|
3cebbb2824 | |
|
|
289204780a | |
|
|
78b7493bd4 | |
|
|
17ff31dcf7 | |
|
|
de705431f7 | |
|
|
69228013be | |
|
|
91f7f5be34 | |
|
|
03814b29dc | |
|
|
682c9341a7 | |
|
|
de48fb1790 |
|
|
@ -0,0 +1,16 @@
|
||||||
|
# 1. Python 虛擬環境與快取 (吃錢最大凶手,絕對不要讓 AI 讀!)
|
||||||
|
cmts_api_env/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# 2. 其他 AI 工具的隱藏資料夾
|
||||||
|
.continue/
|
||||||
|
|
||||||
|
# 3. 動態生成的 JSON 快取資料 (非常耗 Token,且對 AI 寫程式幫助不大)
|
||||||
|
*_cache.json
|
||||||
|
filters_*.json
|
||||||
|
*.bk
|
||||||
|
|
||||||
|
# 4. 版控與系統隱藏檔 (如果有這兩個的話)
|
||||||
|
.git/
|
||||||
|
.vscode/
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Harmonic CMTS Manager - AI 開發守則與架構白皮書
|
||||||
|
|
||||||
|
## 1. 專案架構概覽與技術棧
|
||||||
|
- **定位**: 專為有線電視網路終端設備 (CMTS) 設計的企業級 Web 管理系統。
|
||||||
|
- **後端**: Python 3.10+, FastAPI (Async-first), AsyncSSH, Netmiko, asyncpg.
|
||||||
|
- **前端**: 原生 Vanilla JS (ES Modules), HTML5, CSS3, Xterm.js.
|
||||||
|
- **資料庫**: PostgreSQL (主要), JSON File Cache (高可用性降級備援).
|
||||||
|
|
||||||
|
### 📂 目錄與檔案結構
|
||||||
|
- **進入點**: `main.py`
|
||||||
|
- **路由管理**: API 路由統一放置於 `routers/` 目錄。包含 backup.py, config.py, leaf_options.py, lock.py, query.py, terminal.py。
|
||||||
|
- **前端介面**: `index.html` 與 `static/` 目錄。 包含 api.js, app.js, edit-mode.js, mac-domain.js, style.css, terminal.js, tree-ui.js, utils.js。
|
||||||
|
- **核心邏輯**:
|
||||||
|
- `cmts_scraper.py`: 負責底層爬蟲與資料處理。
|
||||||
|
- `shared.py`: 放置共用函式 (如兩階段解析法)。
|
||||||
|
- `config.py`: 配置轉譯器 (CLI Generator)。
|
||||||
|
- **資料庫**: 連線與 ORM 邏輯在 `database.py`,初始化腳本為 `init_db.py`。
|
||||||
|
- **併發控制**: `lock.py` (路徑階層鎖), `leaf_options.py` (SSE 頻道分流)。
|
||||||
|
|
||||||
|
## 2. 核心架構與業務邏輯 (Architecture & Logic)
|
||||||
|
- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。
|
||||||
|
- **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `BATCH_SIZE = 30` 搭配 `asyncio.sleep()` 進行非同步批次處理。
|
||||||
|
- **絕對路徑與 Commit 機制 (Absolute Path)**: Harmonic CMTS 支援從 Global 模式直接寫入帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 ...`),無須層層進入子模式。所有變更指令發送完畢後,必須執行 `commit` 才能生效。
|
||||||
|
- **智慧差異還原 (Smart Diff Recovery)**: 設備還原不採用盲目覆蓋,而是透過深度比對 (Deep Diff) 當前設備狀態與備份快照,動態生成包含 `no` 的反向刪除指令與新增/修改指令,確保配置精準還原且無殘留。
|
||||||
|
- **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。
|
||||||
|
- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。
|
||||||
|
|
||||||
|
## 3. 🚨 AI 開發絕對約束 (Directives for AI)
|
||||||
|
|
||||||
|
### ⚙️ 系統與環境規範
|
||||||
|
1. **套件管理**: 若需安裝新套件,請提醒我手動在 `cmts_api_env` 中安裝,並更新 `requirements.txt`。
|
||||||
|
2. **資料讀取限制**: 請勿隨意讀取 `*_cache.json` 檔案的內容,若需了解資料結構,請參考 `cmts_scraper.py` 中的定義。
|
||||||
|
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"(?:#|>)"`) 進行正規表達式匹配,以最高速釋放資源。
|
||||||
|
|
||||||
|
### 🎨 前端規範
|
||||||
|
11. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。
|
||||||
|
12. **UI 狀態安全還原**: 動態修改 DOM 的 CSS 屬性(如 `flex`, `width`)後,若需恢復原狀,嚴禁寫死預設值,必須將屬性設為空字串 `''`,讓瀏覽器自然退回 HTML 定義的樣式,避免改 A 壞 B。
|
||||||
|
13. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時,必須透過 SSE 或 ReadableStream 即時回報進度。
|
||||||
|
14. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy(保留策略/定期清理機制),不可只寫入不刪除。
|
||||||
|
15. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。
|
||||||
|
16. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()` 與 `?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Harmonic CMTS Manager - AI Architect Guidelines & System Blueprint
|
||||||
|
|
||||||
|
> **⚠️ AI 角色與絕對約束 (AI Persona & Absolute Directives)**
|
||||||
|
> 你現在是一位資深的系統架構師與全端工程師。
|
||||||
|
> 1. **唯一真理**:絕對禁止自行幻想任何不存在的模組、變數或第三方套件。所有修改必須基於現有架構。
|
||||||
|
> 2. **語言規範**:註解、對話與 Git Commit 一律使用**繁體中文 (zh-TW)**。
|
||||||
|
> 3. **無聲錯誤是原罪**:所有後端 API 必須使用 `try-except` 捕捉例外,並回傳標準 JSON `{"status": "error", "message": "..."}`,嚴禁 FastAPI 直接拋出 HTTP 500 導致前端崩潰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 1. 系統總體架構 (System Architecture)
|
||||||
|
|
||||||
|
本系統為專為 Harmonic CableOS 設計的企業級 Web 管理介面,採用前後端分離架構,並透過 WebSocket 與 SSE 實現即時雙向通訊。
|
||||||
|
|
||||||
|
* **後端 (Backend)**: Python 3.10+, FastAPI (Async-first), `asyncssh` (核心連線引擎), `asyncpg` (資料庫連線池)。
|
||||||
|
* **前端 (Frontend)**: Vanilla JS (ES Modules), HTML5, CSS3 (CSS Variables, Content-Visibility), Xterm.js, Chart.js。
|
||||||
|
* **資料庫 (Database)**: PostgreSQL (`cmts_nms` 資料庫)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 2. 模組化切割與檔案關聯 (Module Map)
|
||||||
|
|
||||||
|
### 🟢 後端核心模組 (Backend Core)
|
||||||
|
* `main.py`: 系統進入點。負責掛載靜態檔案、初始化 DB Pool (`lifespan`),並將所有 API 路由統一掛載於 `/api/v1` 前綴之下。
|
||||||
|
* `database.py`: PostgreSQL 非同步連線池管理。嚴格區分 `running` 與 `full` 的 `config_type` 雙軌隔離設計。
|
||||||
|
* `cmts_scraper.py`: 底層 SSH 爬蟲引擎。負責發送 `?` 探測設備選項、解析終端機分頁 (`--More--`),並清理 ANSI 控制碼。
|
||||||
|
* `shared.py`: 純邏輯共用區。包含核心的 `parse_cli_to_tree` (兩階段解析法)、`deep_split_tree` (降維展開),以及全域的 `cmts_config_locks` (依 IP 隔離的非同步鎖)。
|
||||||
|
* `logger.py`: 具備 ANSI 色彩的自訂日誌系統,支援透過 API 動態調整各模組的 Log Level。
|
||||||
|
|
||||||
|
### 🔵 後端路由模組 (Routers - `/routers/`)
|
||||||
|
* `config.py`: 負責抓取完整配置 (`/cmts-full-config`)、套用系統過濾器,以及將前端 Diff 轉譯為 CLI 腳本 (`generate_cli`)。
|
||||||
|
* `leaf_options.py`: 負責選項快取的背景掃描,並透過 `asyncio.Queue` 實作 SSE (Server-Sent Events) 頻道分流,即時推播掃描進度。
|
||||||
|
* `lock.py`: 實作 In-Memory 的路徑階層鎖 (`ACTIVE_LOCKS`),支援 Heartbeat 續命與過期自動清理。
|
||||||
|
* `backup.py`: 設備快照與還原中心。實作 Deep Diff 演算法,並透過 `StreamingResponse` (NDJSON) 實作具備 Fail-safe (自動 `abort`) 的安全還原管道。
|
||||||
|
* `query.py`: 處理標準 `show` 指令查詢,以及 MAC Domain 的互動式解析。
|
||||||
|
* `diagnostics.py`: 深度解析 CM 狀態,包含 PHY 功率、SNR 與 OFDM MER 陣列。
|
||||||
|
* `terminal.py`: WebSocket 代理,將前端 Xterm.js 的輸入轉發至 `asyncssh` 的 PTY。
|
||||||
|
|
||||||
|
### 🟡 前端模組 (Frontend - `/static/`)
|
||||||
|
* `app.js`: 主協調器 (Orchestrator)。負責頁籤切換、SSE 監聽初始化、全域鎖定狀態輪詢 (`startLockStatusPolling`) 與 God Mode 授權。
|
||||||
|
* `api.js`: 純粹的 Fetch API 封裝層,負責與後端 `/api/v1` 溝通。
|
||||||
|
* `tree-ui.js`: **效能核心**。負責將 JSON 轉換為 HTML 樹狀圖。採用「記憶體遞迴渲染」與「延遲載入 (`lazyLoadFolder`)」。
|
||||||
|
* `edit-mode.js`: 編輯狀態機。處理鎖定獲取、UI 狀態切換 (✏️ -> 🔒 -> ⏳)、生成 Diff 陣列,以及右側 CLI 預覽面板的控制。
|
||||||
|
* `mac-domain.js`: 獨立的 MAC Domain 狀態感知配置精靈邏輯。
|
||||||
|
* `terminal.js`: Xterm.js 實例化、WebSocket 連線管理與終端機字串上色 (`colorizeTerminalStream`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 3. 核心演算法與開發規範 (Core Mechanisms & Rules)
|
||||||
|
|
||||||
|
### ⚡ 3.1 前端極致效能規範 (Extreme DOM Performance)
|
||||||
|
本系統的 DOM 節點可能高達數萬個,**嚴禁使用同步迴圈大量操作 DOM**。
|
||||||
|
1. **記憶體遞迴渲染 (In-Memory Rendering)**:在 `tree-ui.js` 中,必須先在 JS 記憶體中將 HTML 字串完全組裝完畢,最後只執行 **1 次** `innerHTML` 寫入。
|
||||||
|
2. **非同步 UI 保護**:任何大型渲染(如展開全部、初始載入),必須先顯示 `⏳ 載入中...` 並將游標設為 `wait`,接著使用 `setTimeout(..., 20)` 讓出主執行緒,確保瀏覽器不卡死。
|
||||||
|
3. **CSS 渲染隔離**:依賴 `style.css` 中的 `content-visibility: auto;`,嚴禁在 JS 中破壞 `.tree-folder-content` 的結構。
|
||||||
|
|
||||||
|
### 🔒 3.2 併發與鎖定機制 (Concurrency & Locking)
|
||||||
|
1. **IP 隔離原則**:所有的鎖定 Key 必須是 `host@@path` 格式,確保不同設備間的鎖定互不干擾。
|
||||||
|
2. **父子階層鎖 (Cascading Lock)**:前端在輪詢鎖定狀態時,必須檢查「自身」、「父節點」與「子節點」的鎖定衝突。
|
||||||
|
3. **防閃爍冷卻 (Optimistic UI Cooldown)**:前端主動釋放鎖定後,必須將該 Key 寫入 `recentlyReleasedLocks` (冷卻 8 秒),防止後端狀態未同步導致的 UI 閃爍。
|
||||||
|
|
||||||
|
### 🛡️ 3.3 設備寫入與安全還原 (Safe SSH Execution)
|
||||||
|
1. **絕對路徑策略 (Absolute Path)**:寫入設備時,一律生成帶有完整上下文的絕對路徑指令(如 `cable mac-domain 13:0/0.0 admin-state down`),嚴禁依賴傳統的層層進入模式。
|
||||||
|
2. **Fail-safe 撤銷機制**:在 `backup.py` 與 `config.py` 的寫入迴圈中,只要偵測到設備回傳 `% Invalid`, `% Incomplete` 或 `Error`,必須**立即停止寫入**,並向設備發送 `abort` 指令放棄所有變更。
|
||||||
|
3. **精準 Prompt 偵測**:使用 `asyncssh` 讀取輸出時,嚴禁盲目等待 Timeout。必須在 `read_until_quiet` 中傳入精準的 `prompt_pattern` (如 `r"\(config.*\)#"` 或 `r"(?:#|>)"`)。
|
||||||
|
|
||||||
|
### 🌳 3.4 樹狀圖解析與資料結構 (Tree Parsing)
|
||||||
|
1. **兩階段解析**:`shared.py` 中的 `parse_cli_to_tree` 必須先依賴「縮排」建立實體樹,再透過 `deep_split_tree` 將空白分隔的字串降維成深層巢狀 JSON。
|
||||||
|
2. **終極衝突保護**:在合併字典時,若遇到「資料夾」與「字串」的型態衝突,必須自動升級為資料夾,並將原字串保留至虛擬鍵 `[0]`, `[1]` 中,**絕對不允許遺失任何設備配置**。
|
||||||
|
3. **雙軌記憶體**:前端 `window.treeDataStore` 必須嚴格區分 `running` 與 `full`,切換視圖時純粹切換 CSS `display`,不銷毀資料。
|
||||||
|
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -1,3 +1,13 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
cmts_api_env/
|
cmts_api_env/
|
||||||
|
*_cache.json
|
||||||
|
filters_*.json
|
||||||
|
*.bk
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
target_code.txt
|
||||||
|
all_code.txt
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"editor.inlineSuggest.enabled": false,
|
||||||
|
"json.schemaDownload.enable": false,
|
||||||
|
"monica-code.showInlineTip": false,
|
||||||
|
"monica-code.telemetryEnabled": false,
|
||||||
|
"monica-code.enableTabAutocomplete": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
# 📖 Harmonic CMTS Manager - Project State (Living Document)
|
||||||
|
|
||||||
|
> ⚠️ **AI 助手請注意**:本專案的核心架構與開發規範已移至 `.clinerules`。此檔案僅作為「專案進度存檔」與「待辦事項追蹤」使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 已完成開發階段 (Completed Phases)
|
||||||
|
|
||||||
|
### Phase 1: PostgreSQL 高可用性架構升級
|
||||||
|
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
|
||||||
|
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
|
||||||
|
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
|
||||||
|
|
||||||
|
### Phase 2: 設備配置備份與快照機制
|
||||||
|
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。
|
||||||
|
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表。
|
||||||
|
- [x] **前端 UI 優化與防禦性編程**:實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
|
||||||
|
|
||||||
|
### Phase 3: 智慧差異還原與歷史預覽
|
||||||
|
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。
|
||||||
|
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
|
||||||
|
- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**:實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應),並具備 Fail-safe `abort` 撤銷機制。
|
||||||
|
|
||||||
|
### Phase 3.5: 企業級前端效能重構 (Extreme Performance Optimization)
|
||||||
|
- [x] **記憶體遞迴渲染 (In-Memory Recursive Rendering)**:徹底重構 `tree-ui.js`,將數千次 DOM 寫入壓縮為單次 `innerHTML` 寫入,解決「展開全部」導致瀏覽器卡死的問題。
|
||||||
|
- [x] **非同步 UI 保護機制**:在所有大型渲染場景 (初始載入、單點展開、全部展開) 導入 `setTimeout` 讓出主執行緒,並搭配沙漏游標與橘色讀取提示,確保 UI 絕對滑順。
|
||||||
|
- [x] **CSS 渲染隔離**:導入 `content-visibility: auto`,讓不在可視範圍內的 DOM 節點暫停渲染計算。
|
||||||
|
- [x] **精準 DOM 查詢**:將鎖定狀態輪詢 (`startLockStatusPolling`) 的搜尋範圍限縮於當前啟用的視圖內,消除全域搜尋造成的卡頓 (Jank)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 即將到來的里程碑 (Upcoming Milestones Summary)
|
||||||
|
|
||||||
|
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
|
||||||
|
- [x] **智能視覺診斷 (Visual Diagnostics)**:
|
||||||
|
- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
|
||||||
|
- [x] **深度代碼審查與並發加固 (Deep Code Review & Concurrency Hardening)**
|
||||||
|
- [x] **備份保留策略 (Retention Policy)**:
|
||||||
|
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時,採用 FIFO (先進先出) 機制自動清理舊資料,防止資料庫無限膨脹。
|
||||||
|
- [x] **系統日誌動態儀表板 (Log Viewer UI)** :
|
||||||
|
- 基於 FastAPI 實作一個 WebSocket Log Streamer。
|
||||||
|
1. **Custom Log Handler**: 在現有的 Python `logging` 模組中,撰寫一個自訂的 Handler,能夠攔截系統的 Log 訊息(包含 ANSI 色碼)。
|
||||||
|
2. **WebSocket Endpoint**: 建立一個 FastAPI WebSocket 路由 `/ws/logs`。
|
||||||
|
3. **Broadcaster**: 實作一個簡單的機制,當 Custom Log Handler 收到新日誌時,能非同步地將訊息推播給所有連線中的 WebSocket 客戶端。
|
||||||
|
|
||||||
|
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
|
||||||
|
- [ ] **RPD 樣板克隆引擎 (Template Cloning)**:
|
||||||
|
- 於「MAC Domain 狀態感知配置精靈」中新增 RPD 擴容模式。允許使用者選擇現有設備上已配置完成的 RPD (支援 FDX, FDD, D3.1+) 作為基準樣板。
|
||||||
|
- [ ] **Tree 節點複製與參數替換 (Node Duplication & Modification)**:
|
||||||
|
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
|
||||||
|
- [ ] **安全寫入與校驗 (Safe Provisioning)**:
|
||||||
|
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
|
||||||
|
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**:
|
||||||
|
- 重新 Review `generate_diff_commands` 演算法。
|
||||||
|
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 已知問題與技術債 (Known Issues & Tech Debt)
|
||||||
|
- 目前系統運行極度穩定,前端效能瓶頸已徹底消除,各項併發鎖定與 UI 狀態連動皆已完善。準備進入 Phase 4 的 Diff 引擎強化開發。
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
108522
cmts_leaf_options_cache.json
108522
cmts_leaf_options_cache.json
File diff suppressed because it is too large
Load Diff
253
cmts_scraper.py
253
cmts_scraper.py
|
|
@ -5,52 +5,52 @@ import re
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import database
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("app.scraper")
|
||||||
|
|
||||||
def parse_question_mark_output(output: str) -> dict:
|
def parse_question_mark_output(output: str) -> dict:
|
||||||
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
|
"""解析 '?' 回傳內容,無差別掃描支援所有標題排列組合"""
|
||||||
hint_lines = []
|
hint_lines = []
|
||||||
options = []
|
options = []
|
||||||
current_value = None
|
current_value = None
|
||||||
format_desc = None
|
format_desc = None
|
||||||
is_format_only = False
|
is_format_only = False
|
||||||
|
|
||||||
state = "INIT"
|
|
||||||
has_description = False
|
|
||||||
has_bracket_value = False
|
has_bracket_value = False
|
||||||
|
|
||||||
# 🌟 Case-5 特殊處理:dhcp-relay 混合型欄位 (選項 + IP輸入)
|
|
||||||
# 直接在開頭掃描完整輸出,若包含 dhcp-relay,強制轉為純文字輸入框
|
|
||||||
if "dhcp-relay" in output.lower():
|
if "dhcp-relay" in output.lower():
|
||||||
is_format_only = True
|
is_format_only = True
|
||||||
format_desc = "IP address or options"
|
format_desc = "IP address or options"
|
||||||
|
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
# 過濾雜訊與終端機提示字元
|
|
||||||
if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
|
# ==========================================
|
||||||
|
# 🛡️ 1. 終極雜訊與 Echo 過濾器
|
||||||
|
# ==========================================
|
||||||
|
if not line or line.startswith('%') or line.startswith('^'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith('Description:'):
|
# 攔截 Echo 的問號指令 (例如 "logging buffered ?")
|
||||||
state = "DESC"
|
if line.endswith('?'):
|
||||||
has_description = True
|
|
||||||
hint_lines.append(line)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith('Possible completions:'):
|
# 攔截終端機 Prompt (例如 "admin@SERCOMM-COS-02(config)# ..." 或 "admin@SERCOMM-COS-02>")
|
||||||
state = "COMPLETIONS"
|
if re.search(r"[a-zA-Z0-9_.@-]+\(.*\)#", line) or re.search(r"^[a-zA-Z0-9_.@-]+>", line):
|
||||||
# 🌟 解除限制:無論有沒有 Description,都把這行加進 hint
|
continue
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看)
|
||||||
hint_lines.append(line)
|
hint_lines.append(line)
|
||||||
|
|
||||||
|
# 3. 略過純標題行,不進行選項解析
|
||||||
|
if line.startswith('Description:') or line.startswith('Possible completions:'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if state == "DESC":
|
# 4. 解析格式與選項 (無差別掃描每一行)
|
||||||
hint_lines.append(line)
|
|
||||||
|
|
||||||
elif state == "COMPLETIONS":
|
# 格式 A: <格式說明>[當前值] (例如: <string, min: 0 chars, max: 128 chars>[Cold Start])
|
||||||
# Case 1~4: 無 Description 時,將後續選項說明也納入提示
|
|
||||||
# 🌟 解除限制:無論有沒有 Description,都把這行加進 hint
|
|
||||||
hint_lines.append(line)
|
|
||||||
|
|
||||||
# 規則 1: <格式說明>[當前值]
|
|
||||||
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
|
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
|
||||||
if match_format:
|
if match_format:
|
||||||
is_format_only = True
|
is_format_only = True
|
||||||
|
|
@ -59,55 +59,55 @@ def parse_question_mark_output(output: str) -> dict:
|
||||||
current_value = match_format.group(2).strip()
|
current_value = match_format.group(2).strip()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 規則 1.5: 型態, 最小值 .. 最大值
|
# 格式 B: 型態, 最小值 .. 最大值 (例如: unsignedInt, 1 .. 3000)
|
||||||
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
|
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
|
||||||
if match_range:
|
if match_range:
|
||||||
is_format_only = True
|
is_format_only = True
|
||||||
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
|
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 規則 1.6: IP address 等純文字關鍵字
|
# 格式 C: 純文字關鍵字
|
||||||
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
|
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
|
||||||
if match_keyword:
|
if match_keyword:
|
||||||
is_format_only = True
|
is_format_only = True
|
||||||
format_desc = match_keyword.group(1)
|
format_desc = match_keyword.group(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 規則 2: 選項列表處理
|
# 格式 D: 選項列表 [現值] 選項1 選項2
|
||||||
match_current = re.search(r"^\[([^\]]+)\]", line)
|
match_current = re.search(r"^\[([^\]]+)\]", line)
|
||||||
if match_current:
|
if match_current:
|
||||||
has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單
|
has_bracket_value = True
|
||||||
if not current_value:
|
if not current_value:
|
||||||
current_value = match_current.group(1).strip()
|
current_value = match_current.group(1).strip()
|
||||||
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
|
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
|
||||||
else:
|
else:
|
||||||
clean_line = line.strip()
|
clean_line = line.strip()
|
||||||
|
|
||||||
# 🌟 Case-6 防呆:過濾垂直列表的說明文字
|
# 分離選項與說明 (完美支援水平列表,如 severity size-mb)
|
||||||
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線
|
parts = re.split(r'\s{2,}', clean_line)
|
||||||
if re.search(r"\s{3,}", clean_line):
|
valid_options = []
|
||||||
clean_line = re.split(r"\s{3,}", clean_line)[0]
|
|
||||||
|
|
||||||
parts = clean_line.split()
|
|
||||||
for p in parts:
|
for p in parts:
|
||||||
if p and p not in ["|", ".."]:
|
p = p.strip()
|
||||||
|
if not p: continue
|
||||||
|
# 如果這個片段包含空白,代表它是說明文字 (Description),停止解析後續片段
|
||||||
|
if ' ' in p:
|
||||||
|
break
|
||||||
|
valid_options.append(p)
|
||||||
|
|
||||||
|
for p in valid_options:
|
||||||
|
if p not in ["|", ".."]:
|
||||||
options.append(p)
|
options.append(p)
|
||||||
|
|
||||||
# 🌟 Case 2, 3, 4: 子命令防呆機制
|
# 子命令防呆:如果抓到一堆單字,但沒有 [現值],且不是已知格式,很可能是子命令列表
|
||||||
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字
|
if not has_bracket_value and not is_format_only and options:
|
||||||
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
|
|
||||||
is_format_only = True
|
is_format_only = True
|
||||||
options = []
|
options = []
|
||||||
|
|
||||||
# 確保現值一定包含在選項中(如果它是一般的下拉選單)
|
|
||||||
if current_value and options and current_value not in options:
|
if current_value and options and current_value not in options:
|
||||||
options.append(current_value)
|
options.append(current_value)
|
||||||
|
|
||||||
hint_text = "\n".join(hint_lines).strip()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"hint": hint_text,
|
"hint": "\n".join(hint_lines).strip(),
|
||||||
# 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
|
|
||||||
"options": list(dict.fromkeys(options)) if not is_format_only else [],
|
"options": list(dict.fromkeys(options)) if not is_format_only else [],
|
||||||
"current_value": current_value,
|
"current_value": current_value,
|
||||||
"format_desc": format_desc,
|
"format_desc": format_desc,
|
||||||
|
|
@ -115,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)
|
||||||
|
|
@ -139,7 +138,6 @@ 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()}
|
||||||
|
|
||||||
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
|
|
@ -150,15 +148,22 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
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"
|
||||||
|
|
||||||
for batch_idx, batch in enumerate(batches):
|
for batch_idx, batch in enumerate(batches):
|
||||||
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
||||||
conn = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
async def read_until_quiet(timeout=1.0):
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
output = ""
|
output = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -168,27 +173,44 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
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":
|
||||||
|
process.stdin.write("show version | nomore\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
match = re.search(r"(?:infra|vcmts-cd-0|CableOS)\s+([\w\.\-]+)", version_output, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
cmts_version = match.group(1)
|
||||||
|
else:
|
||||||
|
cmts_version = "parse_failed" # 🌟 避免正則失敗導致每批次都重查
|
||||||
|
|
||||||
|
# 🌟 關鍵修復 2:確保成功進入 config 模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5)
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
for path in batch:
|
for path in batch:
|
||||||
# --- 步驟 1:發送 '?' 查詢 ---
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
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"],
|
||||||
|
|
@ -197,49 +219,39 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
"current_value": q_data.get("current_value")
|
"current_value": q_data.get("current_value")
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 步驟 2:判斷是否需要發送 Enter ---
|
|
||||||
# 🌟 配合新規則:如果 '?' 已經明確告訴我們這是 <格式> 欄位,直接跳過 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"
|
||||||
# if q_data.get("format_desc"):
|
|
||||||
# parsed_data["hint"] += f"\nFormat: {q_data['format_desc']}"
|
|
||||||
|
|
||||||
elif not q_data["options"]:
|
elif not q_data["options"]:
|
||||||
# 原本的 Enter 邏輯
|
|
||||||
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"]
|
||||||
parsed_data["options"] = enter_data["options"]
|
parsed_data["options"] = enter_data["options"]
|
||||||
parsed_data["current_value"] = enter_data["current_value"]
|
parsed_data["current_value"] = enter_data["current_value"]
|
||||||
|
|
||||||
# if "format_desc" in enter_data:
|
|
||||||
# parsed_data["hint"] += f"\nFormat: {enter_data['format_desc']}"
|
|
||||||
|
|
||||||
if enter_data["type"] != "unknown":
|
if enter_data["type"] != "unknown":
|
||||||
process.stdin.write("\x03")
|
# 如果進入了互動式輸入 (例如 prompt 變成 (val): ),按 Enter 接受預設值並退出
|
||||||
await process.stdin.drain()
|
|
||||||
else:
|
|
||||||
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
|
|
||||||
process.stdin.write("\n")
|
process.stdin.write("\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
else:
|
||||||
|
# 如果只是印出錯誤,我們不需要做什麼
|
||||||
|
pass
|
||||||
|
|
||||||
await read_until_quiet(timeout=0.5)
|
await read_until_quiet(timeout=0.5)
|
||||||
|
|
||||||
result_data[path] = parsed_data
|
result_data[path] = parsed_data
|
||||||
|
|
||||||
# 🌟 核心修改:每處理完一個路徑,就推播一次進度!
|
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
event_data = {
|
yield json.dumps({
|
||||||
"event": "progress",
|
"event": "progress",
|
||||||
"current": processed_count,
|
"current": processed_count,
|
||||||
"total": total_paths,
|
"total": total_paths,
|
||||||
"current_path": path
|
"current_path": path
|
||||||
}
|
}) + "\n"
|
||||||
yield json.dumps(event_data) + "\n"
|
|
||||||
|
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
|
@ -248,27 +260,25 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
|
yield json.dumps({"event": "error", "message": f"批次錯誤: {str(e)}"}) + "\n"
|
||||||
continue
|
continue
|
||||||
finally:
|
|
||||||
if conn: conn.close()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache_file = "cmts_leaf_options_cache.json"
|
|
||||||
cache_data = {}
|
|
||||||
if os.path.exists(cache_file):
|
|
||||||
with open(cache_file, "r", encoding="utf-8") as f:
|
|
||||||
cache_data = json.load(f)
|
|
||||||
|
|
||||||
current_ts = int(time.time())
|
current_ts = int(time.time())
|
||||||
|
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
|
|
||||||
|
# 寫入資料庫
|
||||||
|
if cmts_version not in ["unknown", "parse_failed"]:
|
||||||
|
await database.upsert_device_status(host, {"cmts_version": cmts_version, "last_scanned": formatted_time})
|
||||||
|
else:
|
||||||
|
await database.upsert_device_status(host, {"last_scanned": formatted_time})
|
||||||
|
|
||||||
for p in batch:
|
for p in batch:
|
||||||
if p in result_data:
|
if p in result_data:
|
||||||
result_data[p]["updated_at"] = current_ts
|
result_data[p]["updated_at"] = current_ts
|
||||||
cache_data[p] = result_data[p]
|
await database.upsert_leaf_option(host, p, result_data[p])
|
||||||
|
|
||||||
with open(cache_file, "w", encoding="utf-8") as f:
|
logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已寫入資料庫!")
|
||||||
json.dump(cache_data, f, indent=4, ensure_ascii=False)
|
|
||||||
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已提早寫入快取檔!")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ 提早寫入快取失敗: {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)
|
||||||
|
|
@ -277,3 +287,74 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
|
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
|
||||||
|
|
||||||
|
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
|
||||||
|
try:
|
||||||
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
|
async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
|
||||||
|
output = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||||||
|
if not chunk: break
|
||||||
|
output += chunk
|
||||||
|
if "--More--" in chunk or "More" in chunk:
|
||||||
|
process.stdin.write(" ")
|
||||||
|
await process.stdin.drain()
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
|
if config_type == "full":
|
||||||
|
process.stdin.write("config\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
|
cmd = "show full-configuration | nomore"
|
||||||
|
process.stdin.write(f"{cmd}\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
|
process.stdin.write("exit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
|
||||||
|
else:
|
||||||
|
cmd = "show running-config | nomore"
|
||||||
|
process.stdin.write(f"{cmd}\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
raw_output = await read_until_quiet(timeout=3.0)
|
||||||
|
|
||||||
|
process.stdin.write("exit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
|
||||||
|
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
|
||||||
|
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
|
||||||
|
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
|
||||||
|
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
|
||||||
|
|
||||||
|
lines = cleaned_output.splitlines()
|
||||||
|
|
||||||
|
clean_lines = [
|
||||||
|
line for line in lines
|
||||||
|
if not line.strip().startswith(cmd)
|
||||||
|
and not line.strip().startswith("admin@")
|
||||||
|
]
|
||||||
|
return "\n".join(clean_lines).strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
|
||||||
|
|
||||||
|
def parse_config_to_tree(raw_cli: str) -> dict:
|
||||||
|
return {"_raw_length": len(raw_cli), "status": "pending_parser"}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,461 @@
|
||||||
|
import asyncpg
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 💡 PostgreSQL 連線與操作 (高可用性版)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
logger = get_logger("app.database")
|
||||||
|
|
||||||
|
# 🌟 2. 同步改用 os.getenv 讀取環境變數
|
||||||
|
DB_CONFIG = {
|
||||||
|
"database": os.getenv("DB_NAME", "cmts_nms"),
|
||||||
|
"user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
|
||||||
|
"password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
|
||||||
|
"host": os.getenv("DB_HOST", "127.0.0.1"),
|
||||||
|
"port": os.getenv("DB_PORT", "5432")
|
||||||
|
}
|
||||||
|
|
||||||
|
_pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
||||||
|
async def init_db_pool():
|
||||||
|
"""初始化非同步連線池"""
|
||||||
|
global _pool
|
||||||
|
try:
|
||||||
|
if _pool is None:
|
||||||
|
_pool = await asyncpg.create_pool(
|
||||||
|
database=DB_CONFIG["database"],
|
||||||
|
user=DB_CONFIG["user"],
|
||||||
|
password=DB_CONFIG["password"],
|
||||||
|
host=DB_CONFIG["host"],
|
||||||
|
port=int(DB_CONFIG["port"]),
|
||||||
|
min_size=1,
|
||||||
|
max_size=10
|
||||||
|
)
|
||||||
|
logger.info("✅ PostgreSQL Connection Pool Initialized.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to initialize DB Pool: {e}")
|
||||||
|
|
||||||
|
async def close_db_pool():
|
||||||
|
"""關閉非同步連線池"""
|
||||||
|
global _pool
|
||||||
|
if _pool:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
logger.info("🔌 PostgreSQL Connection Pool Closed.")
|
||||||
|
|
||||||
|
async def get_pool() -> Optional[asyncpg.Pool]:
|
||||||
|
if _pool is None:
|
||||||
|
await init_db_pool()
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# CRUD Functions for cmts_options
|
||||||
|
# ------------------------------------------
|
||||||
|
|
||||||
|
async def upsert_leaf_option(host: str, path: str, data: dict) -> bool:
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool: return False
|
||||||
|
query = """
|
||||||
|
INSERT INTO cmts_options (host, path, data)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (host, path)
|
||||||
|
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(query, host, path, json.dumps(data))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (upsert_leaf_option): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_all_leaf_options(host: str) -> Optional[Dict[str, Any]]:
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool: return None
|
||||||
|
query = "SELECT path, data FROM cmts_options WHERE host = $1;"
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
records = await conn.fetch(query, host)
|
||||||
|
result = {}
|
||||||
|
for record in records:
|
||||||
|
data_val = record['data']
|
||||||
|
result[record['path']] = json.loads(data_val) if isinstance(data_val, str) else data_val
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (get_all_leaf_options): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_leaf_options(host: str, paths: List[str]) -> int:
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool or not paths: return -1
|
||||||
|
query = "DELETE FROM cmts_options WHERE host = $1 AND path = ANY($2);"
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
status = await conn.execute(query, host, paths)
|
||||||
|
return int(status.split()[-1])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (delete_leaf_options): {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# CRUD Functions for device_status
|
||||||
|
# ------------------------------------------
|
||||||
|
|
||||||
|
async def upsert_device_status(host: str, metadata: dict) -> bool:
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool: return False
|
||||||
|
cmts_version = metadata.get("cmts_version")
|
||||||
|
last_scanned = metadata.get("last_scanned", None)
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if cmts_version and cmts_version != "unknown":
|
||||||
|
query = """
|
||||||
|
INSERT INTO device_status (host, cmts_version, last_scanned)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (host)
|
||||||
|
DO UPDATE SET cmts_version = EXCLUDED.cmts_version, last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
await conn.execute(query, host, cmts_version, last_scanned)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
INSERT INTO device_status (host, last_scanned)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (host)
|
||||||
|
DO UPDATE SET last_scanned = EXCLUDED.last_scanned, updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
await conn.execute(query, host, last_scanned)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (upsert_device_status): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_device_status(host: str) -> Optional[Dict[str, Any]]:
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool: return None
|
||||||
|
query = "SELECT cmts_version, last_scanned FROM device_status WHERE host = $1;"
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
record = await conn.fetchrow(query, host)
|
||||||
|
if record:
|
||||||
|
return {"cmts_version": record["cmts_version"], "last_scanned": record["last_scanned"]}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (get_device_status): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# CRUD Functions for system_filters (Tree Filters)
|
||||||
|
# ------------------------------------------
|
||||||
|
async def upsert_tree_filters(config_type: str, hidden_keys: List[str]) -> bool:
|
||||||
|
"""寫入全域的樹狀圖隱藏節點名單"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Use 'global' as a dummy host to keep schema simple if needed,
|
||||||
|
# but a dedicated system_filters table is better.
|
||||||
|
query = """
|
||||||
|
INSERT INTO system_filters (config_type, hidden_keys)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (config_type)
|
||||||
|
DO UPDATE SET hidden_keys = EXCLUDED.hidden_keys, updated_at = CURRENT_TIMESTAMP;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(query, config_type, hidden_keys)
|
||||||
|
return True
|
||||||
|
except asyncpg.PostgresError as e:
|
||||||
|
logger.error(f"❌ DB Error (upsert_tree_filters): {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Unknown Error (upsert_tree_filters): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_tree_filters(config_type: str) -> Optional[List[str]]:
|
||||||
|
"""讀取全域的樹狀圖隱藏節點名單"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT hidden_keys FROM system_filters
|
||||||
|
WHERE config_type = $1;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
record = await conn.fetchrow(query, config_type)
|
||||||
|
if record:
|
||||||
|
return record["hidden_keys"]
|
||||||
|
return []
|
||||||
|
except asyncpg.PostgresError as e:
|
||||||
|
logger.error(f"❌ DB Error (get_tree_filters): {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Unknown Error (get_tree_filters): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# CRUD Functions for config_backups (Phase 1 & 2)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
async def insert_config_backup(
|
||||||
|
host: str,
|
||||||
|
config_type: str,
|
||||||
|
raw_cli: str,
|
||||||
|
parsed_tree: dict,
|
||||||
|
snapshot_name: Optional[str] = None,
|
||||||
|
description: str = "", # 🟢 新增描述參數 (預設為空字串)
|
||||||
|
is_auto: bool = False
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""新增一筆設備配置備份,回傳產生的 Backup ID"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
backup_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# 🟢 SQL 語句加入 description 與對應的 $5
|
||||||
|
query = """
|
||||||
|
INSERT INTO config_backups
|
||||||
|
(id, host, config_type, snapshot_name, description, is_auto, raw_cli, parsed_tree)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
query,
|
||||||
|
backup_id,
|
||||||
|
host,
|
||||||
|
config_type,
|
||||||
|
snapshot_name,
|
||||||
|
description, # 🟢 傳入描述參數
|
||||||
|
is_auto,
|
||||||
|
raw_cli,
|
||||||
|
json.dumps(parsed_tree)
|
||||||
|
)
|
||||||
|
return backup_id
|
||||||
|
except asyncpg.PostgresError as e:
|
||||||
|
logger.error(f"❌ DB Error (insert_config_backup): {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Unknown Error (insert_config_backup): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""取得歷史快照列表 (支援 config_type='all' 撈取全部,包含 is_pinned 狀態)"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if config_type == "all":
|
||||||
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
|
query = """
|
||||||
|
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
|
||||||
|
FROM config_backups
|
||||||
|
WHERE host = $1
|
||||||
|
ORDER BY timestamp DESC;
|
||||||
|
"""
|
||||||
|
records = await conn.fetch(query, host)
|
||||||
|
else:
|
||||||
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
|
query = """
|
||||||
|
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
|
||||||
|
FROM config_backups
|
||||||
|
WHERE host = $1 AND config_type = $2
|
||||||
|
ORDER BY timestamp DESC;
|
||||||
|
"""
|
||||||
|
records = await conn.fetch(query, host, config_type)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"host": r["host"],
|
||||||
|
"config_type": r["config_type"],
|
||||||
|
"timestamp": r["timestamp"].isoformat(),
|
||||||
|
"snapshot_name": r["snapshot_name"],
|
||||||
|
"description": r["description"],
|
||||||
|
"is_auto": r["is_auto"],
|
||||||
|
"is_pinned": r["is_pinned"] # 🌟 放入回傳字典
|
||||||
|
}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
except asyncpg.PostgresError as e:
|
||||||
|
logger.error(f"❌ DB Error (get_config_backup_list): {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Unknown Error (get_config_backup_list): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""取得特定快照的完整內容 (包含 parsed_tree,供 Phase 3 還原使用)"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 🟢 SELECT 加入 description
|
||||||
|
query = """
|
||||||
|
SELECT id, host, timestamp, snapshot_name, description, is_auto, parsed_tree
|
||||||
|
FROM config_backups
|
||||||
|
WHERE id = $1;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
record = await conn.fetchrow(query, backup_id)
|
||||||
|
if record:
|
||||||
|
data_val = record['parsed_tree']
|
||||||
|
parsed_tree = json.loads(data_val) if isinstance(data_val, str) else data_val
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(record["id"]),
|
||||||
|
"host": record["host"],
|
||||||
|
"timestamp": record["timestamp"].isoformat(),
|
||||||
|
"snapshot_name": record["snapshot_name"],
|
||||||
|
"description": record["description"], # 🟢 將資料庫的描述放入回傳字典
|
||||||
|
"is_auto": record["is_auto"],
|
||||||
|
"parsed_tree": parsed_tree
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except asyncpg.PostgresError as e:
|
||||||
|
logger.error(f"❌ DB Error (get_config_backup_detail): {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Unknown Error (get_config_backup_detail): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_config_backup(backup_id: str) -> bool:
|
||||||
|
"""刪除指定的快照"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
query = "DELETE FROM config_backups WHERE id = $1;"
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
status = await conn.execute(query, backup_id)
|
||||||
|
# status 會是 'DELETE 1' 或 'DELETE 0'
|
||||||
|
return int(status.split()[-1]) > 0
|
||||||
|
except asyncpg.PostgresError as e:
|
||||||
|
logger.error(f"❌ DB Error (delete_config_backup): {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 🧹 備份滾動淘汰機制 (Retention Policy)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def cleanup_old_backups(pool, host: str) -> None:
|
||||||
|
"""
|
||||||
|
執行手動備份的滾動淘汰機制 (Retention Policy)
|
||||||
|
- 每個設備 (host) 保留最新 20 筆未釘選 (is_pinned=FALSE) 的手動備份
|
||||||
|
- 釘選的備份 (is_pinned=True) 永遠不刪除
|
||||||
|
"""
|
||||||
|
if not pool:
|
||||||
|
logger.warning("⚠️ [Retention] 無法執行備份清理:Database Pool 未初始化。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用 PostgreSQL CTE (Common Table Expression) 語法
|
||||||
|
# 先選出該設備最新 20 筆需要保留的 ID,再將其餘未釘選的舊備份一次性刪除
|
||||||
|
query = """
|
||||||
|
WITH kept_backups AS (
|
||||||
|
SELECT id FROM config_backups
|
||||||
|
WHERE host = $1 AND is_pinned = FALSE
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
DELETE FROM config_backups
|
||||||
|
WHERE host = $1 AND is_pinned = FALSE
|
||||||
|
AND id NOT IN (SELECT id FROM kept_backups)
|
||||||
|
RETURNING id;
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
deleted_records = await conn.fetch(query, host)
|
||||||
|
total_deleted = len(deleted_records)
|
||||||
|
|
||||||
|
if total_deleted > 0:
|
||||||
|
logger.info(f"🧹 [Retention Policy] 設備 {host} 清理完畢:已自動淘汰 {total_deleted} 筆過期手動備份。")
|
||||||
|
else:
|
||||||
|
logger.debug(f"ℹ️ [Retention Policy] 設備 {host} 備份數量未達 20 筆上限,無需清理。")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [Retention Policy] 清理設備 {host} 過期備份時發生錯誤: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 📌 釘選防護與容量指標計算 (Pinning & Metrics)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def toggle_config_backup_pin(backup_id: str) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
切換特定備份的釘選狀態 (True -> False, False -> True)
|
||||||
|
回傳更新後的 is_pinned 狀態,若失敗則回傳 None
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 使用 PostgreSQL 的 UPDATE ... RETURNING 語法,一步完成切換與讀取,保證原子性
|
||||||
|
query = """
|
||||||
|
UPDATE config_backups
|
||||||
|
SET is_pinned = NOT is_pinned
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING is_pinned;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
new_state = await conn.fetchval(query, backup_id)
|
||||||
|
return new_state
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (toggle_config_backup_pin): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_backup_metrics(pool, host: str) -> dict:
|
||||||
|
"""
|
||||||
|
計算特定設備的備份容量指標
|
||||||
|
- total: 總備份數 (含釘選與未釘選)
|
||||||
|
- pinned: 已釘選保護的數量
|
||||||
|
- unpinned: 未釘選的數量 (上限為 20 筆)
|
||||||
|
- remaining: 剩餘可用手動備份額度 (20 - unpinned)
|
||||||
|
"""
|
||||||
|
if not pool:
|
||||||
|
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE is_pinned = TRUE) as pinned,
|
||||||
|
COUNT(*) FILTER (WHERE is_pinned = FALSE) as unpinned
|
||||||
|
FROM config_backups
|
||||||
|
WHERE host = $1;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
r = await conn.fetchrow(query, host)
|
||||||
|
total = r["total"] or 0
|
||||||
|
pinned = r["pinned"] or 0
|
||||||
|
unpinned = r["unpinned"] or 0
|
||||||
|
remaining = max(0, 20 - unpinned)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"pinned": pinned,
|
||||||
|
"unpinned": unpinned,
|
||||||
|
"remaining": remaining
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (get_backup_metrics): {e}")
|
||||||
|
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
|
||||||
486
index.html
486
index.html
|
|
@ -4,26 +4,30 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Harmonic CMTS Manager</title>
|
<title>Harmonic CMTS Manager</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/my_icon.png">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
|
||||||
<!-- 引入獨立的 CSS 樣式表 -->
|
<!-- 引入獨立的 CSS 樣式表 -->
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1>🎙️ Harmonic CMTS Admin</h1>
|
<!-- 1. 標題加上 id="app-title" -->
|
||||||
|
<h1 id="app-title" style="cursor: default;">🎙️ Harmonic CMTS Admin</h1>
|
||||||
|
|
||||||
<div class="global-settings">
|
<div class="global-settings">
|
||||||
<label><strong>🌐 目標設備:</strong></label>
|
<label><strong>🌐 目標設備:</strong></label>
|
||||||
<input type="text" id="cmtsHost" placeholder="IP 地址" value="10.14.110.4" style="width: 130px;">
|
<!-- 🌟 修改後 (徹底淨化) -->
|
||||||
<input type="text" id="cmtsUser" placeholder="帳號" value="admin" style="width: 100px;">
|
<input type="text" id="cmtsHost" aria-label="IP 地址" placeholder="IP 地址" style="width: 130px;">
|
||||||
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;">
|
<input type="text" id="cmtsUser" aria-label="帳號" placeholder="帳號" style="width: 100px;">
|
||||||
|
<input type="password" id="cmtsPass" aria-label="密碼" placeholder="密碼" style="width: 100px;">
|
||||||
|
|
||||||
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
|
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
|
||||||
<button onclick="connectWebSocket()" id="btnConnect" style="background-color: #27ae60;">連線至 CMTS</button>
|
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
|
||||||
<button onclick="disconnectWebSocket()" id="btnDisconnect" style="background-color: #c0392b; display: none;">斷開連線</button>
|
<button onclick="disconnectWebSocket()" id="btnDisconnect" class="btn-modern btn-disconnect" style="display: none;">斷開連線</button>
|
||||||
<span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
|
<span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -31,24 +35,26 @@
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
|
<button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
|
||||||
<button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
|
<button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
|
||||||
<button class="tab-btn" onclick="switchTab('config-tab', this)">⚙️ CMTS 設備配置</button>
|
<button class="tab-btn" onclick="switchTab('config-tab', this)">🛠️ CMTS 設備配置</button>
|
||||||
<!-- 💡 新增:系統設定頁籤 -->
|
<!-- 💡 新增:系統設定頁籤 -->
|
||||||
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
|
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
|
||||||
|
<!-- 💡 新增:備份與還原頁籤 -->
|
||||||
|
<button class="tab-btn" onclick="switchTab('backup-tab', this); loadBackupHistory();">💾 設備備份與還原</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 1: True SSH Terminal -->
|
<!-- Tab 1: True SSH Terminal -->
|
||||||
<div id="cli-tab" class="tab-content active">
|
<div id="cli-tab" class="tab-content active">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label><strong>快捷指令:</strong></label>
|
<label><strong>快捷指令:</strong></label>
|
||||||
<select id="fixedCmd">
|
<select id="fixedCmd" aria-label="選擇快捷指令" >
|
||||||
<option value="show cable modem">數據機狀態 (show cable modem)</option>
|
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
|
||||||
<option value="show cable rpd">RPD 狀態 (show cable rpd)</option>
|
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
|
||||||
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
|
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
|
||||||
</select>
|
</select>
|
||||||
<button onclick="injectCommand()" style="background-color: #8e44ad;">送出指令</button>
|
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
|
||||||
|
|
||||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
<button onclick="downloadTerminal()" style="background-color: #7f8c8d;">💾 下載紀錄</button>
|
<button onclick="downloadTerminal()" class="btn-modern btn-slate">💾 下載紀錄</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -61,9 +67,10 @@
|
||||||
|
|
||||||
<div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
<div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
||||||
<label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
|
<label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
|
||||||
<select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
|
<select id="queryTask" onchange="switchQueryTask()" aria-label="選擇查詢任務" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
|
||||||
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
|
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
|
||||||
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
|
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
|
||||||
|
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -72,12 +79,13 @@
|
||||||
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
|
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>目標設備 (Cable Modem MAC)</label>
|
<label for="queryTargetCm">目標設備 (Cable Modem MAC)</label>
|
||||||
<input type="text" id="queryTargetCm" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||||||
|
<datalist id="cm-mac-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>查詢動作 (Action)</label>
|
<label>查詢動作 (Action)</label>
|
||||||
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')">
|
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')" aria-label="選擇 Cable Modem 查詢動作">
|
||||||
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
|
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
|
||||||
<option value="base">基本狀態 (show cable modem)</option>
|
<option value="base">基本狀態 (show cable modem)</option>
|
||||||
<option value="cpe">CPE 資訊 (cpe)</option>
|
<option value="cpe">CPE 資訊 (cpe)</option>
|
||||||
|
|
@ -108,7 +116,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button onclick="executeQuery('Cm')">執行查詢</button>
|
<button onclick="executeQuery('Cm')" class="btn-modern btn-load">執行查詢</button>
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center;">
|
||||||
<input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
<input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
||||||
<label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
<label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
||||||
|
|
@ -121,12 +129,13 @@
|
||||||
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
|
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>目標設備 (RPD VC:VS)</label>
|
<label for="queryTargetRpd">目標設備 (RPD VC:VS)</label>
|
||||||
<input type="text" id="queryTargetRpd" placeholder="例: 13:0 (留白則查詢全體)">
|
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
|
||||||
|
<datalist id="rpd-vcvs-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>查詢動作 (Action)</label>
|
<label>查詢動作 (Action)</label>
|
||||||
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')">
|
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')" aria-label="選擇 RPD 查詢動作">
|
||||||
<option value="rpd_base">基本狀態 (show cable rpd)</option>
|
<option value="rpd_base">基本狀態 (show cable rpd)</option>
|
||||||
<option value="rpd_verbose">詳細資訊 (verbose)</option>
|
<option value="rpd_verbose">詳細資訊 (verbose)</option>
|
||||||
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
|
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
|
||||||
|
|
@ -143,13 +152,82 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button onclick="executeQuery('Rpd')" style="background-color: #e67e22;">執行查詢</button>
|
<button onclick="executeQuery('Rpd')" class="btn-modern btn-load">執行查詢</button>
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center;">
|
||||||
<input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
<input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
||||||
<label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
<label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單 3:CM 一鍵診斷中心 -->
|
||||||
|
<div id="form-cm-diagnostics" class="task-form">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
|
||||||
|
|
||||||
|
<!-- 搜尋區塊 -->
|
||||||
|
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
|
||||||
|
<label for="diagMacInput" style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC:</label>
|
||||||
|
<input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
|
||||||
|
<datalist id="diag-cm-mac-list"></datalist>
|
||||||
|
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
|
||||||
|
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 結果顯示區塊 (預設隱藏) -->
|
||||||
|
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
|
||||||
|
|
||||||
|
<!-- 上半部:基本資訊與 PHY 狀態 -->
|
||||||
|
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||||||
|
<!-- 基本資訊卡片 -->
|
||||||
|
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||||||
|
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
|
||||||
|
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
|
||||||
|
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PHY 狀態卡片 -->
|
||||||
|
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||||||
|
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
|
||||||
|
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;">
|
||||||
|
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
|
||||||
|
<td id="diagResTx" style="font-weight: bold;">-</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
|
||||||
|
<td id="diagResRx" style="font-weight: bold;">-</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:上行通道標籤區塊 -->
|
||||||
|
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
|
||||||
|
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
|
||||||
|
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
|
<!-- 動態生成標籤 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下半部:Chart.js MER 圖表 -->
|
||||||
|
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||||||
|
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
|
||||||
|
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
|
||||||
|
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
|
||||||
|
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<!-- 動態生成按鈕 -->
|
||||||
|
</div>
|
||||||
|
</h4>
|
||||||
|
<div style="position: relative; height: 280px; width: 100%;">
|
||||||
|
<canvas id="merChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -157,13 +235,15 @@
|
||||||
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
|
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
|
||||||
<div class="manager-container">
|
<div class="manager-container">
|
||||||
|
|
||||||
<div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
||||||
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">🛠️ 選擇配置任務:</label>
|
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
|
||||||
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
|
<select id="configTask" aria-label="選擇配置任務" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
|
||||||
|
<!-- 🌟 拆分為兩個獨立的選項 -->
|
||||||
|
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
|
||||||
|
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
|
||||||
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
|
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
|
||||||
<option value="form-full-config">🌳 完整設備配置樹狀圖</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button id="btn-load-task" onclick="startConfigTask()" style="background-color: #95a5a6; color: white; margin-left: 10px; cursor: not-allowed;" disabled>🚀 載入任務</button>
|
<button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 表單 3:MAC Domain 配置精靈 -->
|
<!-- 表單 3:MAC Domain 配置精靈 -->
|
||||||
|
|
@ -171,11 +251,10 @@
|
||||||
<h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
|
<h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
|
||||||
|
|
||||||
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
|
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
|
||||||
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain:</label>
|
<label for="cfgMacDomain" style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain:</label>
|
||||||
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
<input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
||||||
<option value="">請先點擊上方載入任務...</option>
|
<datalist id="mac-domain-list"></datalist>
|
||||||
</select>
|
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
|
||||||
<button onclick="fetchMacDomainConfig()" style="background-color: #d35400;">🔍 讀取現有配置</button>
|
|
||||||
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
|
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -183,39 +262,39 @@
|
||||||
<!-- Common Settings -->
|
<!-- Common Settings -->
|
||||||
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
|
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row"><label>IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
|
<div class="form-row"><label for="g_ip_prov">IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
|
||||||
<div class="form-row"><label>Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_diplexer">Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_bat31">CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_bat30">CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_docsis40">DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_ds_dyn">DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_us_dyn">US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- [Basic] DS/US Channel Sets -->
|
<!-- [Basic] DS/US Channel Sets -->
|
||||||
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
|
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row"><label>Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
<div class="form-row"><label for="b_admin">Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
<div class="form-row"><label>DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
|
<div class="form-row"><label for="b_ds_pri">DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
|
||||||
<div class="form-row"><label>DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
|
<div class="form-row"><label for="b_ds_non_pri">DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
|
||||||
<div class="form-row"><label>US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
|
<div class="form-row"><label for="b_us_phy">US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
|
||||||
<div class="form-row"><label>DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
|
<div class="form-row"><label for="b_ds_ofdm">DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
|
||||||
<div class="form-row"><label>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
|
<div class="form-row"><label for="b_us_ofdma">US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- [Static] Downstream Bonding Groups -->
|
<!-- [Static] Downstream Bonding Groups -->
|
||||||
<div id="section_group_ds" style="margin-top: 30px;">
|
<div id="section_group_ds" style="margin-top: 30px;">
|
||||||
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
|
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
|
||||||
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||||||
<label>選擇要編輯的 DS Group:</label>
|
<label for="select_ds_group">選擇要編輯的 DS Group:</label>
|
||||||
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
|
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
|
||||||
<input type="text" id="input_new_ds_group" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
|
<input type="text" id="input_new_ds_group" aria-label="輸入新 DS Group 名稱" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row"><label>Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
<div class="form-row"><label for="ds_g_admin">Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
<div class="form-row"><label>Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
|
<div class="form-row"><label for="ds_g_down">Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
|
||||||
<div class="form-row"><label>OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
|
<div class="form-row"><label for="ds_g_ofdm">OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
|
||||||
<div class="form-row"><label>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
|
<div class="form-row"><label for="ds_g_fdx">FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -223,15 +302,15 @@
|
||||||
<div id="section_group_us" style="margin-top: 30px;">
|
<div id="section_group_us" style="margin-top: 30px;">
|
||||||
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
|
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
|
||||||
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||||||
<label>選擇要編輯的 US Group:</label>
|
<label for="select_us_group">選擇要編輯的 US Group:</label>
|
||||||
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
|
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
|
||||||
<input type="text" id="input_new_us_group" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
|
<input type="text" id="input_new_us_group" aria-label="輸入新 US Group 名稱" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row"><label>Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
<div class="form-row"><label for="us_g_admin">Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
<div class="form-row"><label>US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
|
<div class="form-row"><label for="us_g_us">US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
|
||||||
<div class="form-row"><label>OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
|
<div class="form-row"><label for="us_g_ofdma">OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
|
||||||
<div class="form-row"><label>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
|
<div class="form-row"><label for="us_g_fdx">FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -239,83 +318,71 @@
|
||||||
<div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>
|
<div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button id="btnRevert" onclick="revertFormChanges()" style="background-color: #7f8c8d; color: white; margin-right: 15px;">
|
<button id="btnRevert" onclick="revertFormChanges()" class="btn-modern btn-disconnect" style="margin-right: 15px;">🔄 復原重填</button>
|
||||||
🔄 復原重填
|
<button onclick="generateMacDomainCLI()" class="btn-modern btn-scan">⚙️ 產生絕對路徑指令預覽</button>
|
||||||
</button>
|
<button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
|
||||||
<button onclick="generateMacDomainCLI()" style="background-color: #f39c12; color: white;">⚙️ 產生絕對路徑指令預覽</button>
|
|
||||||
<button id="btnDeployConfig" onclick="executeBondingConfig()" style="background-color: #c0392b; color: white; display: none;" disabled>🚀 正式套用設定</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 表單 4:完整設備配置樹狀圖 -->
|
<!-- 表單 4:設備配置樹狀圖 (共用容器) -->
|
||||||
<div id="form-full-config" class="task-form" style="display: none;">
|
<div id="form-tree-config" class="task-form" style="display: none;">
|
||||||
<h3 style="display: flex; align-items: center; margin-bottom: 15px;">
|
<!-- 頂部:標題與按鈕區塊 (維持原樣) -->
|
||||||
🌳 完整設備配置樹狀圖
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
<!-- 🌟 一鍵掃描按鈕 -->
|
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
|
||||||
<button id="global-scan-btn" onclick="scanAllMissingOptions()" disabled
|
<span id="tree-form-title">🌲 設備配置樹狀圖</span>
|
||||||
style="padding: 6px 12px; background-color: #8e44ad; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; margin-left: 20px; transition: all 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
</h3>
|
||||||
🔍 一鍵掃描缺失選項
|
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
|
||||||
</button>
|
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
|
||||||
|
|
||||||
<!-- 🌟 請在這裡加入這段全新的按鈕 -->
|
|
||||||
<button id="clearCacheBtn" onclick="clearVisibleCache()"
|
|
||||||
style="padding: 6px 12px; background-color: #f39c12; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; margin-left: 10px; transition: all 0.3s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
|
||||||
🔄 清除畫面選項快取
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 🌟 新增:掃描進度提示 -->
|
|
||||||
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; margin-left: 15px; font-weight: normal;"></span>
|
|
||||||
|
|
||||||
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; margin-left: 15px; font-weight: normal;">
|
|
||||||
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
<!-- 🌟 左右分屏容器 -->
|
<button id="btn-scan-options" onclick="scanMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">🚀 掃描缺失選項</button>
|
||||||
<div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;">
|
<button id="btn-clear-options" onclick="clearOptionsCache()" class="btn-modern btn-clear" style="display: none;">🗑️ 清除選項快取</button>
|
||||||
|
</div>
|
||||||
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) -->
|
|
||||||
<!-- <div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; box-sizing: border-box; overflow-x: auto;">
|
|
||||||
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) -->
|
|
||||||
<div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
|
||||||
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🖱️ 拖曳分隔線 (預設隱藏) -->
|
<!-- 🌟 左右分屏容器 (關鍵修改:align-items: stretch 讓左右強制等高) -->
|
||||||
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; margin: 0 5px; flex-shrink: 0; align-items: center; justify-content: center; position: sticky; top: 20px; height: 400px; z-index: 10;" title="左右拖曳調整寬度">
|
<div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
|
||||||
|
|
||||||
|
<!-- 左側:樹狀圖 (保留 max-height: 600px,超過才出捲軸) -->
|
||||||
|
<div id="tree-container-running" class="tree-view-instance" style="flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
||||||
|
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tree-container-full" class="tree-view-instance" style="display: none; flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
||||||
|
<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🖱️ 拖曳分隔線 (拔除寫死的 height: 400px) -->
|
||||||
|
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; flex-shrink: 0; align-items: center; justify-content: center; z-index: 10;" title="左右拖曳調整寬度">
|
||||||
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
|
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右側:CLI 預覽與執行結果 (預設隱藏,顯示時佔 50%) -->
|
<!-- 右側:CLI 預覽與執行結果外框 -->
|
||||||
<div id="side-cli-preview" style="width: 50%; position: sticky; top: 20px; background: #2c3e50; padding: 12px 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: none; border: 1px solid #34495e; box-sizing: border-box; flex-shrink: 0;">
|
<div id="side-cli-preview" style="flex: 1; min-width: 300px; max-width: 50%; display: none; box-sizing: border-box;">
|
||||||
|
|
||||||
|
<!-- 🌟 內部黑底容器 (使用 flex column 與 height: 100% 完美填滿外框) -->
|
||||||
|
<div style="display: flex; flex-direction: column; height: 100%; background: #1e1e1e; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #34495e; box-sizing: border-box;">
|
||||||
|
|
||||||
<!-- 頂部標題列 -->
|
<!-- 頂部標題列 -->
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
|
||||||
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
|
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px;">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
|
||||||
<!-- 預覽模式按鈕 -->
|
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
|
||||||
<button id="btn-side-cancel" onclick="hideSideCLI()" style="background-color: #7f8c8d; padding: 5px 12px; font-size: 13px;">取消</button>
|
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
|
||||||
<button id="btn-side-confirm" onclick="executeSideCLI()" style="background-color: #27ae60; padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
|
|
||||||
|
|
||||||
<!-- 執行結果模式按鈕 (預設隱藏) -->
|
|
||||||
<button id="btn-side-close" onclick="hideSideCLI()" style="background-color: #3498db; padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
|
|
||||||
|
|
||||||
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
|
|
||||||
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">×</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模式一:指令預覽框 (可編輯) -->
|
<!-- 模式一:指令預覽框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||||||
<textarea id="side-cli-textarea" style="width: 100%; height: 400px; background: #1e1e1e; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 12px 15px; border: 1px solid #7f8c8d; border-radius: 4px; box-sizing: border-box; white-space: pre; overflow-wrap: normal; overflow-x: scroll; margin-bottom: 0; display: block;"></textarea>
|
<textarea id="side-cli-textarea" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; outline: none; box-sizing: border-box; white-space: pre; overflow-x: auto; overflow-y: auto; margin: 0; display: block; resize: none; min-height: 150px;"></textarea>
|
||||||
|
|
||||||
<!-- 模式二:執行結果框 (唯讀,升級字體與行高) -->
|
<!-- 模式二:執行結果框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||||||
<div id="side-execution-result" style="width: 100%; height: 400px; background: #1e1e1e; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 12px 15px; border: 1px solid #7f8c8d; border-radius: 4px; box-sizing: border-box; overflow-y: auto; margin-bottom: 0; display: none; white-space: pre-wrap; word-wrap: break-word;"></div>
|
<div id="side-execution-result" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; box-sizing: border-box; overflow-x: auto; overflow-y: auto; margin: 0; display: none; white-space: pre; min-height: 150px;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -338,34 +405,184 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick="loadRealConfigForFilters()" style="background-color: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin-bottom: 15px;">
|
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
|
||||||
|
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<label for="filter-mode-select" style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
|
||||||
|
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
|
||||||
|
<option value="running">Running 配置過濾器</option>
|
||||||
|
<option value="full">Full 配置過濾器</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button onclick="loadRealConfigForFilters()" class="btn-modern btn-load">
|
||||||
📥 載入設備配置以設定過濾
|
📥 載入設備配置以設定過濾
|
||||||
</button>
|
</button>
|
||||||
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
|
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 樹狀 Checkbox 容器 -->
|
<!-- 樹狀 Checkbox 容器 -->
|
||||||
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
|
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
|
<div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
|
||||||
<button onclick="saveSystemSettings()" style="background-color: #27ae60; color: white; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 15px; font-weight: bold;">
|
<button onclick="saveSystemSettings()" class="btn-modern btn-connect" style="padding: 10px 20px; font-size: 15px;">
|
||||||
💾 儲存並套用設定
|
💾 儲存並套用設定
|
||||||
</button>
|
</button>
|
||||||
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
|
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:伺服器日誌管控面板 -->
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
|
||||||
|
🎛️ 伺服器日誌管控 (Server Log Management)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
|
||||||
|
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
|
||||||
|
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,<b>無需重啟伺服器</b>。<br>
|
||||||
|
<span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b> 或 <b>ERROR</b>,僅在需要排查問題時開啟 <b>DEBUG</b>。</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 矩陣式下拉選單容器 -->
|
||||||
|
<div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
|
||||||
|
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:🎙️ 系統實時日誌面板 (System Live Logs) -->
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>🎙️ 系統實時日誌 (System Live Logs)</span>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<span id="log-ws-status" style="font-size: 13px; color: #7f8c8d; font-weight: normal;">狀態:已暫停 ⚪</span>
|
||||||
|
<button id="btnToggleLog" onclick="toggleLogStream()" class="btn-modern btn-disconnect" style="padding: 4px 12px; font-size: 12px;">🔌 啟動監聽</button>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- 日誌終端機容器 -->
|
||||||
|
<div id="log-terminal-container" style="width: 100%; height: 300px; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); overflow: hidden; position: relative; padding: 10px; box-sizing: border-box;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 💡 Tab 5: 設備備份與還原 -->
|
||||||
|
<div id="backup-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
|
||||||
|
<div class="manager-container">
|
||||||
|
|
||||||
|
<!-- 上半部:建立新快照 -->
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
|
||||||
|
|
||||||
|
<!-- 💡 修正:移除負 margin,讓底線與內容邊界對齊 (如同系統設定頁籤) -->
|
||||||
|
<div style="display: flex; justify-content: flex-start; align-items: center; gap: 15px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px;">
|
||||||
|
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px;">
|
||||||
|
📸 建立新快照
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save" style="padding: 8px 20px; font-size: 14px; background-color: #2980b9; border: none; box-shadow: 0 2px 4px rgba(41, 128, 185, 0.3);">
|
||||||
|
🚀 立即執行備份
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none; font-weight: bold;">
|
||||||
|
⏳ 正在連線設備並抓取設定,請稍候...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單網格區 -->
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
|
||||||
|
<div>
|
||||||
|
<label for="snapshotName" style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
|
快照名稱 <span style="color: #e74c3c;">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="backupConfigType" style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
|
配置類型
|
||||||
|
</label>
|
||||||
|
<select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
|
||||||
|
<option value="running">Running Config (運行中配置)</option>
|
||||||
|
<option value="full">Full Config (完整配置)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述區 -->
|
||||||
|
<div style="margin-bottom: 0;">
|
||||||
|
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
|
備份描述 <span style="color: #7f8c8d; font-weight: normal; font-size: 13px;">(選填)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="snapshotDescription" placeholder="請簡述此次備份的目的,例如:升級至 Unify FDD firmware,驗收通過" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下半部:歷史紀錄列表 -->
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
|
||||||
|
|
||||||
|
<!-- 💡 修正:移除負 margin,讓底線與內容邊界對齊 -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px; flex-wrap: wrap;">
|
||||||
|
|
||||||
|
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px; white-space: nowrap;">
|
||||||
|
📁 歷史備份紀錄
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<input type="text" id="filter-keyword" aria-label="搜尋名稱或描述" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
|
||||||
|
|
||||||
|
<select id="filter-type" aria-label="選擇過濾類型" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
|
||||||
|
<option value="">所有類型</option>
|
||||||
|
<option value="running">running</option>
|
||||||
|
<option value="full">full</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
|
||||||
|
<input type="date" id="filter-date-start" aria-label="開始日期" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||||||
|
<span style="color: #7f8c8d; font-size: 13px;">至</span>
|
||||||
|
<input type="date" id="filter-date-end" aria-label="結束日期" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
|
||||||
|
🔄 重新整理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格區塊 -->
|
||||||
|
<table style="width: 100%; border-collapse: collapse; text-align: left;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 8%; text-align: center;">保護</th> <!-- 🌟 新增:獨立的保護欄位 -->
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 22%;">快照名稱</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 22%;">描述</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; text-align: right; width: 18%;">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="backup-history-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 獨立的彈出式輸出視窗 (Modal) -->
|
<!-- 獨立的彈出式輸出視窗 (Modal) -->
|
||||||
<div id="outputModal" class="modal-overlay" onclick="closeModal(event)">
|
<div id="outputModal" class="modal-overlay">
|
||||||
<div class="modal-container" onclick="event.stopPropagation()">
|
<div class="modal-container">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">
|
<div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
|
||||||
<span>📄 執行結果</span>
|
<span>📄 執行結果</span>
|
||||||
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal;"></span>
|
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
|
||||||
|
</div>
|
||||||
|
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
|
||||||
|
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close" onclick="closeModal()">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
|
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
|
||||||
|
|
@ -373,6 +590,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:God Mode 授權視窗 (Modal) -->
|
||||||
|
<div id="godModeModal" class="modal-overlay">
|
||||||
|
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
|
||||||
|
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
|
||||||
|
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
|
||||||
|
<div class="modal-title" style="justify-content: center; width: 100%;">
|
||||||
|
<span>🔐 系統進階授權</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
|
||||||
|
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
|
||||||
|
請輸入維護者密碼以解鎖隱藏功能
|
||||||
|
</p>
|
||||||
|
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
|
||||||
|
|
||||||
|
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 15px; justify-content: center;">
|
||||||
|
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
|
||||||
|
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 引入獨立的 JavaScript 邏輯 -->
|
<!-- 引入獨立的 JavaScript 邏輯 -->
|
||||||
<script type="module" src="/static/app.js"></script>
|
<script type="module" src="/static/app.js"></script>
|
||||||
|
|
||||||
|
|
|
||||||
161
init_db.py
161
init_db.py
|
|
@ -1,66 +1,133 @@
|
||||||
import psycopg2
|
import asyncio
|
||||||
from psycopg2.extras import RealDictCursor
|
import asyncpg
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
|
||||||
|
|
||||||
# 💡 資料庫連線設定 (請確認密碼與步驟一設定的相同)
|
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 🌟 2. 改用 os.getenv 讀取環境變數,並保留原本的設定作為安全預設值
|
||||||
DB_CONFIG = {
|
DB_CONFIG = {
|
||||||
"dbname": "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", # 如果 DB 在同一台機器上
|
"host": os.getenv("DB_HOST", "127.0.0.1"),
|
||||||
"port": "5432"
|
"port": os.getenv("DB_PORT", "5432")
|
||||||
}
|
}
|
||||||
|
|
||||||
def init_database():
|
async def init_database(force_reset: bool = False):
|
||||||
try:
|
try:
|
||||||
# 建立連線
|
logger.info("🔄 正在連線到 PostgreSQL (asyncpg)...")
|
||||||
print("🔄 正在連線到 PostgreSQL...")
|
conn = await asyncpg.connect(
|
||||||
conn = psycopg2.connect(**DB_CONFIG)
|
database=DB_CONFIG["database"],
|
||||||
cursor = conn.cursor()
|
user=DB_CONFIG["user"],
|
||||||
|
password=DB_CONFIG["password"],
|
||||||
|
host=DB_CONFIG["host"],
|
||||||
|
port=int(DB_CONFIG["port"])
|
||||||
|
)
|
||||||
|
|
||||||
# 1. 建立 CLI 字典表 (Global-Ready Schema)
|
# ==========================================
|
||||||
print("🛠️ 正在建立 cli_schema_dictionary 資料表...")
|
# 💣 核彈模式:清除舊有資料表
|
||||||
cursor.execute("""
|
# ==========================================
|
||||||
CREATE TABLE IF NOT EXISTS cli_schema_dictionary (
|
if force_reset:
|
||||||
id SERIAL PRIMARY KEY,
|
logger.warning("⚠️ 警告:啟動強制重建模式,正在刪除現有資料表...")
|
||||||
path VARCHAR(255) UNIQUE NOT NULL,
|
await conn.execute("DROP TABLE IF EXISTS cmts_options;")
|
||||||
node_type VARCHAR(50) DEFAULT 'leaf',
|
await conn.execute("DROP TABLE IF EXISTS device_status;")
|
||||||
options JSONB DEFAULT '[]'::jsonb,
|
await conn.execute("DROP TABLE IF EXISTS system_filters;")
|
||||||
description TEXT,
|
# 💡 備份表通常極度重要,即使 reset 也不建議輕易 DROP,除非你確定要連備份一起砍
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
# await conn.execute("DROP TABLE IF EXISTS config_backups;")
|
||||||
|
logger.info("🗑️ 舊資料表已清除完畢。")
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🏗️ 安全建置模式:建立資料表 (IF NOT EXISTS)
|
||||||
|
# ==========================================
|
||||||
|
# 1. 建立選項快取表 cmts_options (無 config_type)
|
||||||
|
logger.info("🛠️ 正在檢查/建立 cmts_options 資料表...")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS cmts_options (
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
path VARCHAR(500) NOT NULL,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (host, path)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_cli_schema_path ON cli_schema_dictionary(path);
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# 2. 建立系統狀態表
|
# 2. 建立設備狀態表 device_status (無 config_type)
|
||||||
print("🛠️ 正在建立 system_metadata 資料表...")
|
logger.info("🛠️ 正在檢查/建立 device_status 資料表...")
|
||||||
cursor.execute("""
|
await conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS system_metadata (
|
CREATE TABLE IF NOT EXISTS device_status (
|
||||||
key VARCHAR(50) PRIMARY KEY,
|
host VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
value VARCHAR(255) NOT NULL,
|
cmts_version VARCHAR(100) DEFAULT 'unknown',
|
||||||
|
last_scanned VARCHAR(100),
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# 3. 寫入初始狀態預設值 (使用 ON CONFLICT 避免重複執行時報錯)
|
# 3. 建立過濾器設定表 system_filters
|
||||||
print("📝 寫入系統狀態預設值...")
|
logger.info("🛠️ 正在檢查/建立 system_filters 資料表...")
|
||||||
cursor.execute("""
|
await conn.execute("""
|
||||||
INSERT INTO system_metadata (key, value)
|
CREATE TABLE IF NOT EXISTS system_filters (
|
||||||
VALUES ('last_schema_sync_time', '1970-01-01 00:00:00')
|
config_type VARCHAR(50) PRIMARY KEY,
|
||||||
ON CONFLICT (key) DO NOTHING;
|
hidden_keys TEXT[] DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
INSERT INTO system_metadata (key, value)
|
);
|
||||||
VALUES ('last_schema_sync_cos_version', 'Unknown')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# 提交變更並關閉連線
|
# 4. 建立設備配置備份表 config_backups (手動備份 + 釘選防護版)
|
||||||
conn.commit()
|
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
|
||||||
cursor.close()
|
await conn.execute("""
|
||||||
conn.close()
|
CREATE TABLE IF NOT EXISTS config_backups (
|
||||||
print("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
id UUID PRIMARY KEY,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
config_type VARCHAR(50) NOT NULL DEFAULT 'running',
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
snapshot_name VARCHAR(255),
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- 🌟 新增:釘選防護欄位
|
||||||
|
raw_cli TEXT,
|
||||||
|
parsed_tree JSONB,
|
||||||
|
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 建立基礎查詢索引
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
||||||
|
ON config_backups (host, config_type, timestamp DESC);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 🌟 新增:建立滾動淘汰專用索引
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_backups_retention
|
||||||
|
ON config_backups (host, is_pinned, timestamp DESC);
|
||||||
|
""")
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 資料庫初始化失敗: {e}")
|
logger.error(f"❌ 資料庫初始化失敗: {e}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# --- logger.py ---
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 定義終端機輸出的 ANSI 顏色
|
||||||
|
COLORS = {
|
||||||
|
"DEBUG": "\033[36m", # 青色 (Cyan)
|
||||||
|
"INFO": "\033[32m", # 綠色 (Green)
|
||||||
|
"WARNING": "\033[33m", # 黃色 (Yellow)
|
||||||
|
"ERROR": "\033[31m", # 紅色 (Red)
|
||||||
|
"CRITICAL": "\033[1;31m",# 粗體紅色 (Bold Red)
|
||||||
|
"RESET": "\033[0m" # 重置顏色
|
||||||
|
}
|
||||||
|
|
||||||
|
class ColoredFormatter(logging.Formatter):
|
||||||
|
def format(self, record):
|
||||||
|
log_color = COLORS.get(record.levelname, COLORS["RESET"])
|
||||||
|
# 格式:時間 | 等級 | 模組名稱 | 訊息
|
||||||
|
format_str = f"{log_color}%(asctime)s | %(levelname)-7s | %(name)-15s | %(message)s{COLORS['RESET']}"
|
||||||
|
formatter = logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
|
return formatter.format(record)
|
||||||
|
|
||||||
|
# 定義系統中受管控的模組清單
|
||||||
|
MANAGED_LOGGERS = [
|
||||||
|
"app.database", # 資料庫操作
|
||||||
|
"app.scraper", # 爬蟲與快取
|
||||||
|
"app.diagnostics", # CM 診斷
|
||||||
|
"app.backup", # 備份與還原
|
||||||
|
"app.ssh", # 🔌 SSH 連線引擎 (注意這裡要有逗號!)
|
||||||
|
"app.auth" # 🔐 上帝模式與安全授權
|
||||||
|
]
|
||||||
|
|
||||||
|
def setup_logger(name: str, level=logging.INFO):
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
logger.setLevel(level)
|
||||||
|
# 避免重複添加 Handler 導致日誌印兩次
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(ColoredFormatter())
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.propagate = False # 防止日誌往上傳遞給 root logger (避免被 FastAPI 預設格式覆蓋)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
# 系統啟動時,初始化所有模組,預設等級為 INFO
|
||||||
|
for name in MANAGED_LOGGERS:
|
||||||
|
setup_logger(name, logging.INFO)
|
||||||
|
|
||||||
|
def get_logger(name: str):
|
||||||
|
"""供各個檔案引入 logger 使用"""
|
||||||
|
if name not in MANAGED_LOGGERS:
|
||||||
|
return setup_logger(name)
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
def get_all_log_levels():
|
||||||
|
"""取得目前所有模組的日誌等級 (供前端 UI 顯示)"""
|
||||||
|
return {name: logging.getLevelName(logging.getLogger(name).level) for name in MANAGED_LOGGERS}
|
||||||
|
|
||||||
|
def set_log_level(name: str, level_str: str):
|
||||||
|
"""動態設定特定模組的日誌等級"""
|
||||||
|
if name in MANAGED_LOGGERS:
|
||||||
|
level = getattr(logging, level_str.upper(), logging.INFO)
|
||||||
|
logging.getLogger(name).setLevel(level)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def register_websocket_handler(handler: logging.Handler):
|
||||||
|
"""🌟 新增:註冊自訂 Handler 到所有受管控的 Logger,並防止重複添加"""
|
||||||
|
for name in MANAGED_LOGGERS:
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
if handler not in logger.handlers:
|
||||||
|
logger.addHandler(handler)
|
||||||
42
main.py
42
main.py
|
|
@ -3,11 +3,45 @@ import os
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入
|
||||||
|
import database
|
||||||
|
import asyncio
|
||||||
|
|
||||||
# 引入我們剛剛拆分出來的路由模組
|
# 引入我們剛剛拆分出來的路由模組
|
||||||
from routers import query, config, terminal, lock, leaf_options
|
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth, logs
|
||||||
|
|
||||||
app = FastAPI(title="Harmonic CMTS Manager", version="2.0")
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
await database.init_db_pool()
|
||||||
|
# 🌟 新增:初始化 Log Broadcaster 的 Event Loop 參考,確保跨執行緒排程安全
|
||||||
|
logs.log_broadcaster.loop = asyncio.get_running_loop()
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
await database.close_db_pool()
|
||||||
|
|
||||||
|
app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
# 🌟 新增:全域安全與快取 Middleware
|
||||||
|
class SecurityAndCacheMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# 1. 補上安全性標頭 (圖片 7 的警告)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
|
||||||
|
# 2. 針對靜態檔案補上 Cache-Control (圖片 2 的警告)
|
||||||
|
# 讓 JS/CSS 快取 1 小時 (3600秒),提升前端載入效能
|
||||||
|
if request.url.path.startswith("/static/"):
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||||
|
else:
|
||||||
|
# API 請求不快取,確保拿到最新資料
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.add_middleware(SecurityAndCacheMiddleware) # 🌟 掛載 Middleware
|
||||||
|
|
||||||
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
|
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
@ -17,9 +51,13 @@ app.include_router(query.router, prefix="/api/v1")
|
||||||
app.include_router(config.router, prefix="/api/v1")
|
app.include_router(config.router, prefix="/api/v1")
|
||||||
app.include_router(leaf_options.router, prefix="/api/v1")
|
app.include_router(leaf_options.router, prefix="/api/v1")
|
||||||
app.include_router(lock.router, prefix="/api/v1")
|
app.include_router(lock.router, prefix="/api/v1")
|
||||||
|
app.include_router(backup.router, prefix="/api/v1")
|
||||||
|
app.include_router(diagnostics.router, prefix="/api/v1")
|
||||||
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
|
||||||
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
||||||
app.include_router(terminal.router)
|
app.include_router(terminal.router)
|
||||||
|
app.include_router(logs.router) # 🌟 新增:掛載日誌 WebSocket 路由
|
||||||
|
|
||||||
# 根目錄路由:回傳前端 UI
|
# 根目錄路由:回傳前端 UI
|
||||||
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 設定區
|
||||||
|
# ==========================================
|
||||||
|
# ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄)
|
||||||
|
EXCLUDE_DIRS = {
|
||||||
|
"__pycache__",
|
||||||
|
".git",
|
||||||
|
".vscode", # VS Code 設定檔
|
||||||
|
".continue", # Continue.dev 設定檔
|
||||||
|
"cmts_api_env", # 🚨 Python 虛擬環境
|
||||||
|
"venv",
|
||||||
|
".venv",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ⚠️ 排除的檔案副檔名 (不讀取這些格式)
|
||||||
|
EXCLUDE_EXTENSIONS = {
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
||||||
|
".pyc", ".pyo", ".pyd",
|
||||||
|
".exe", ".dll", ".so", ".dylib",
|
||||||
|
".zip", ".tar", ".gz", ".rar",
|
||||||
|
".pdf", ".doc", ".docx",
|
||||||
|
".sqlite3", ".db"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ⚠️ 排除的特定檔案名稱
|
||||||
|
EXCLUDE_FILES = {
|
||||||
|
"merge_code.py", # 排除這支腳本自己
|
||||||
|
"all_code.txt", # 排除全域輸出的檔案
|
||||||
|
"target_code.txt", # 排除指定輸出的檔案 (新增)
|
||||||
|
".DS_Store",
|
||||||
|
".clineignore",
|
||||||
|
".clinerules",
|
||||||
|
".gitignore",
|
||||||
|
"requirements.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
def should_process_file(filename):
|
||||||
|
"""判斷該檔案是否應該被處理 (用於全域掃描)"""
|
||||||
|
if filename in EXCLUDE_FILES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext in EXCLUDE_EXTENSIONS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def merge_all_files():
|
||||||
|
"""模式一:全域掃描 (無參數時觸發)"""
|
||||||
|
output_file = "all_code.txt"
|
||||||
|
root_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
with open(output_file, "w", encoding="utf-8") as outfile:
|
||||||
|
outfile.write("=" * 80 + "\n")
|
||||||
|
outfile.write("PROJECT SOURCE CODE EXPORT (FULL)\n")
|
||||||
|
outfile.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root_dir):
|
||||||
|
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
if should_process_file(filename):
|
||||||
|
file_path = os.path.join(dirpath, filename)
|
||||||
|
rel_path = os.path.relpath(file_path, root_dir)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as infile:
|
||||||
|
content = infile.read()
|
||||||
|
outfile.write("\n" + "=" * 80 + "\n")
|
||||||
|
outfile.write(f"FILE: {rel_path}\n")
|
||||||
|
outfile.write("=" * 80 + "\n")
|
||||||
|
outfile.write(content)
|
||||||
|
outfile.write("\n")
|
||||||
|
print(f"✅ 已合併: {rel_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 讀取失敗 {rel_path}: {e}")
|
||||||
|
|
||||||
|
print(f"\n🎉 全域合併完成!所有程式碼已儲存至: {output_file}")
|
||||||
|
|
||||||
|
def merge_target_files(file_list):
|
||||||
|
"""模式二:指定檔案合併 (-t 參數觸發)"""
|
||||||
|
output_file = "target_code.txt"
|
||||||
|
root_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
with open(output_file, "w", encoding="utf-8") as outfile:
|
||||||
|
outfile.write("=" * 80 + "\n")
|
||||||
|
outfile.write("TARGET SOURCE CODE EXPORT (SPECIFIC)\n")
|
||||||
|
outfile.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
for rel_path in file_list:
|
||||||
|
file_path = os.path.join(root_dir, rel_path)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"❌ 找不到檔案 (已跳過): {rel_path}")
|
||||||
|
continue
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
print(f"❌ 不是有效檔案 (已跳過): {rel_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as infile:
|
||||||
|
content = infile.read()
|
||||||
|
outfile.write("\n" + "=" * 80 + "\n")
|
||||||
|
outfile.write(f"FILE: {rel_path}\n")
|
||||||
|
outfile.write("=" * 80 + "\n")
|
||||||
|
outfile.write(content)
|
||||||
|
outfile.write("\n")
|
||||||
|
print(f"✅ 已合併: {rel_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 讀取失敗 {rel_path}: {e}")
|
||||||
|
|
||||||
|
print(f"\n🎯 指定合併完成!選定的程式碼已儲存至: {output_file}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 使用 argparse 來解析終端機指令
|
||||||
|
parser = argparse.ArgumentParser(description="合併專案程式碼供 AI 讀取")
|
||||||
|
parser.add_argument(
|
||||||
|
'-t', '--target',
|
||||||
|
nargs='+', # 允許接收一個或多個參數
|
||||||
|
help="指定要合併的檔案路徑 (例如: -t main.py routers/api.py)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 根據是否有傳入 -t 參數,決定執行哪一種模式
|
||||||
|
if args.target:
|
||||||
|
print("🔍 啟動 [指定模式]...")
|
||||||
|
merge_target_files(args.target)
|
||||||
|
else:
|
||||||
|
print("🔍 啟動 [全域模式]...")
|
||||||
|
merge_all_files()
|
||||||
|
|
@ -36,3 +36,4 @@ uvicorn==0.46.0
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
watchfiles==1.1.1
|
watchfiles==1.1.1
|
||||||
websockets==16.0
|
websockets==16.0
|
||||||
|
asyncpg==0.30.0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("app.auth")
|
||||||
|
router = APIRouter(tags=["Auth"])
|
||||||
|
|
||||||
|
# 定義請求本體格式
|
||||||
|
class GodModeRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
|
||||||
|
async def verify_god_mode(req: GodModeRequest):
|
||||||
|
# 從環境變數讀取正確密碼,預設為 "19760107@Serc0mm"
|
||||||
|
expected_password = os.getenv("GOD_MODE_SECRET", "19760107@Serc0mm")
|
||||||
|
|
||||||
|
# 使用 compare_digest 防禦計時攻擊
|
||||||
|
if secrets.compare_digest(req.password, expected_password):
|
||||||
|
logger.info("🔓 上帝模式解鎖成功!")
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid authorization code")
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
# --- routers/backup.py ---
|
||||||
|
import asyncssh
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
# 引入 DB 函數
|
||||||
|
from database import (
|
||||||
|
get_pool,
|
||||||
|
insert_config_backup,
|
||||||
|
get_config_backup_list,
|
||||||
|
get_config_backup_detail,
|
||||||
|
delete_config_backup,
|
||||||
|
cleanup_old_backups,
|
||||||
|
toggle_config_backup_pin, # 🌟 新增
|
||||||
|
get_backup_metrics # 🌟 新增
|
||||||
|
)
|
||||||
|
|
||||||
|
from cmts_scraper import fetch_raw_config
|
||||||
|
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
||||||
|
from shared import parse_cli_to_tree, get_cmts_lock
|
||||||
|
|
||||||
|
logger = get_logger("app.backup")
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/backups",
|
||||||
|
tags=["Backups"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 📦 Pydantic Models
|
||||||
|
# ==========================================
|
||||||
|
class SnapshotRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
snapshot_name: str
|
||||||
|
description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述
|
||||||
|
config_type: str = "running"
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class ExecuteRestoreRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
commands: List[str] # 接收前端確認後的差異指令清單
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🛠️ 核心演算法:深度差異比對 (Deep Diff)
|
||||||
|
# ==========================================
|
||||||
|
# 🌟 新增小工具:拔除 [0], [1] 虛擬後綴,還原真實 CLI
|
||||||
|
def clean_virtual_key(key: str) -> str:
|
||||||
|
return re.sub(r'\s*\[\d+\]$', '', key)
|
||||||
|
|
||||||
|
def build_add_commands(node: dict, current_path: str) -> list:
|
||||||
|
"""遞迴展開所有需要新增的絕對路徑指令"""
|
||||||
|
commands = []
|
||||||
|
if not node:
|
||||||
|
commands.append(current_path)
|
||||||
|
return commands
|
||||||
|
|
||||||
|
for key, val in node.items():
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 🌟 使用乾淨的 key 來組合路徑
|
||||||
|
real_key = clean_virtual_key(key)
|
||||||
|
path = f"{current_path} {real_key}".strip()
|
||||||
|
|
||||||
|
if isinstance(val, dict):
|
||||||
|
commands.extend(build_add_commands(val, path))
|
||||||
|
else:
|
||||||
|
val_str = f" {val}" if val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
return commands
|
||||||
|
|
||||||
|
def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list:
|
||||||
|
"""比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
for key, curr_val in current_node.items():
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 🌟 使用乾淨的 key
|
||||||
|
real_key = clean_virtual_key(key)
|
||||||
|
path = f"{current_path} {real_key}".strip()
|
||||||
|
|
||||||
|
if key not in snapshot_node:
|
||||||
|
commands.append(f"no {path}")
|
||||||
|
else:
|
||||||
|
snap_val = snapshot_node[key]
|
||||||
|
if isinstance(curr_val, dict) and isinstance(snap_val, dict):
|
||||||
|
commands.extend(generate_diff_commands(curr_val, snap_val, path))
|
||||||
|
elif isinstance(curr_val, dict) or isinstance(snap_val, dict):
|
||||||
|
commands.append(f"no {path}")
|
||||||
|
if isinstance(snap_val, dict):
|
||||||
|
commands.extend(build_add_commands(snap_val, path))
|
||||||
|
else:
|
||||||
|
val_str = f" {snap_val}" if snap_val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
elif curr_val != snap_val:
|
||||||
|
val_str = f" {snap_val}" if snap_val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
|
||||||
|
for key, snap_val in snapshot_node.items():
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 🌟 使用乾淨的 key
|
||||||
|
real_key = clean_virtual_key(key)
|
||||||
|
path = f"{current_path} {real_key}".strip()
|
||||||
|
|
||||||
|
if key not in current_node:
|
||||||
|
if isinstance(snap_val, dict):
|
||||||
|
commands.extend(build_add_commands(snap_val, path))
|
||||||
|
else:
|
||||||
|
val_str = f" {snap_val}" if snap_val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
|
||||||
|
return commands
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🚀 API 路由
|
||||||
|
# ==========================================
|
||||||
|
@router.get("/", summary="取得歷史快照列表")
|
||||||
|
async def list_backups(host: str, config_type: str = "running"):
|
||||||
|
records = await get_config_backup_list(host, config_type)
|
||||||
|
if records is None:
|
||||||
|
return {"status": "error", "message": "資料庫查詢失敗"}
|
||||||
|
return {"status": "success", "data": records}
|
||||||
|
|
||||||
|
@router.get("/{backup_id}", summary="取得特定快照詳細內容")
|
||||||
|
async def get_backup_detail(backup_id: str):
|
||||||
|
record = await get_config_backup_detail(backup_id)
|
||||||
|
if not record:
|
||||||
|
return {"status": "error", "message": "找不到該筆備份資料"}
|
||||||
|
return {"status": "success", "data": record}
|
||||||
|
|
||||||
|
@router.delete("/{backup_id}", summary="刪除特定快照")
|
||||||
|
async def delete_backup(backup_id: str):
|
||||||
|
success = await delete_config_backup(backup_id)
|
||||||
|
if not success:
|
||||||
|
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
||||||
|
return {"status": "success", "message": "快照已成功刪除"}
|
||||||
|
|
||||||
|
@router.post("/{backup_id}/toggle-pin", summary="切換快照的釘選防護狀態")
|
||||||
|
async def toggle_backup_pin(backup_id: str):
|
||||||
|
new_state = await toggle_config_backup_pin(backup_id)
|
||||||
|
if new_state is None:
|
||||||
|
raise HTTPException(status_code=500, detail="資料庫更新失敗")
|
||||||
|
|
||||||
|
status_str = "已啟用釘選保護,系統絕不自動淘汰。" if new_state else "已取消釘選,該備份將納入滾動淘汰範圍。"
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"is_pinned": new_state,
|
||||||
|
"message": f"快照狀態更新成功!{status_str}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
|
||||||
|
async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
|
||||||
|
async def backup_streamer():
|
||||||
|
try:
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
||||||
|
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
||||||
|
|
||||||
|
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
||||||
|
|
||||||
|
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
||||||
|
backup_id = await insert_config_backup(
|
||||||
|
host=req.host,
|
||||||
|
config_type=req.config_type,
|
||||||
|
raw_cli=raw_cli,
|
||||||
|
parsed_tree=parsed_tree,
|
||||||
|
snapshot_name=req.snapshot_name,
|
||||||
|
description=req.description,
|
||||||
|
is_auto=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not backup_id:
|
||||||
|
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# 🌟 關鍵變更:在同一個串流中「立即執行」清理與指標計算,確保回傳最新數據
|
||||||
|
yield json.dumps({"status": "progress", "message": "🧹 正在執行備份滾動淘汰與容量計算..."}) + "\n"
|
||||||
|
if pool:
|
||||||
|
# 1. 立即執行清理
|
||||||
|
await cleanup_old_backups(pool, req.host)
|
||||||
|
# 2. 立即計算最新指標
|
||||||
|
metrics = await get_backup_metrics(pool, req.host)
|
||||||
|
else:
|
||||||
|
metrics = {"total": 1, "pinned": 0, "unpinned": 1, "remaining": 19}
|
||||||
|
|
||||||
|
# 🌟 3. 在成功訊息中,將 metrics 字典一併回傳給前端
|
||||||
|
yield json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
||||||
|
"backup_id": backup_id,
|
||||||
|
"host": req.host,
|
||||||
|
"metrics": metrics # 🌟 包含容量指標
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 建立快照失敗: {e}")
|
||||||
|
yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n"
|
||||||
|
|
||||||
|
return StreamingResponse(backup_streamer(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
|
||||||
|
async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
|
||||||
|
try:
|
||||||
|
backup_record = await get_config_backup_detail(backup_id)
|
||||||
|
if not backup_record:
|
||||||
|
return {"status": "error", "message": "找不到指定的備份紀錄"}
|
||||||
|
|
||||||
|
snapshot_tree = backup_record.get("parsed_tree")
|
||||||
|
if not snapshot_tree:
|
||||||
|
return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
|
||||||
|
|
||||||
|
# 🌟 關鍵修正:動態取得該快照的 config_type,避免蘋果比橘子
|
||||||
|
snapshot_config_type = backup_record.get("config_type", "running")
|
||||||
|
|
||||||
|
# 🌟 關鍵修正:將硬編碼的 "running" 改為 snapshot_config_type
|
||||||
|
raw_current = await fetch_raw_config(req.host, req.username, req.password, snapshot_config_type)
|
||||||
|
|
||||||
|
if not raw_current:
|
||||||
|
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
|
||||||
|
|
||||||
|
# ✅ 安全升級:將 CPU 密集運算移交給 ThreadPool,保護 Event Loop 不卡死
|
||||||
|
current_tree = await asyncio.to_thread(parse_cli_to_tree, raw_current)
|
||||||
|
diff_commands = await asyncio.to_thread(generate_diff_commands, current_tree, snapshot_tree)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"commands": diff_commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"差異分析失敗: {str(e)}")
|
||||||
|
return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{backup_id}/restore", summary="執行差異還原 (串流即時回報)")
|
||||||
|
async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
|
commands = req.commands
|
||||||
|
|
||||||
|
if not commands:
|
||||||
|
async def empty_success():
|
||||||
|
yield json.dumps({"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}) + "\n"
|
||||||
|
return StreamingResponse(empty_success(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
async def restore_generator():
|
||||||
|
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
||||||
|
host_lock = get_cmts_lock[req.host]
|
||||||
|
|
||||||
|
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
|
||||||
|
if host_lock.locked():
|
||||||
|
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
async with host_lock:
|
||||||
|
try:
|
||||||
|
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
|
||||||
|
|
||||||
|
# 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
|
||||||
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
|
output = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||||||
|
if not chunk: break
|
||||||
|
output += chunk
|
||||||
|
if "--More--" in chunk or "More" in chunk:
|
||||||
|
process.stdin.write(" ")
|
||||||
|
await process.stdin.drain()
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
# 2. 進入設定模式
|
||||||
|
process.stdin.write("config\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||||||
|
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
|
||||||
|
|
||||||
|
# 3. 逐行寫入並進行 Fail-safe 檢查
|
||||||
|
for cmd in commands:
|
||||||
|
process.stdin.write(f"{cmd}\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
|
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
|
||||||
|
|
||||||
|
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
|
||||||
|
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
|
||||||
|
# 1. 先通知前端正在撤銷
|
||||||
|
yield json.dumps({
|
||||||
|
"status": "progress",
|
||||||
|
"message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
# 2. 對設備下達 abort 指令
|
||||||
|
process.stdin.write("abort\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
|
||||||
|
|
||||||
|
# 3. 回報最終錯誤並關閉連線
|
||||||
|
yield json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
|
||||||
|
"output": clean_out
|
||||||
|
}) + "\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
yield json.dumps({
|
||||||
|
"status": "progress",
|
||||||
|
"message": f"執行: {cmd}",
|
||||||
|
"output": clean_out
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
# 稍微讓出控制權,確保串流順暢
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 4. 提交變更 (Commit)
|
||||||
|
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
|
||||||
|
process.stdin.write("commit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
|
# 清理 Commit 回傳的雜訊
|
||||||
|
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
|
||||||
|
|
||||||
|
# 5. 退出並關閉連線
|
||||||
|
process.stdin.write("exit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
# 🌟 移除手動 conn.close(),交由 async with 處理
|
||||||
|
|
||||||
|
yield json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"message": "✅ 所有差異還原指令已成功送出並 Commit!",
|
||||||
|
"output": clean_commit
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"還原執行失敗: {str(e)}")
|
||||||
|
yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n"
|
||||||
|
|
||||||
|
return StreamingResponse(restore_generator(), media_type="application/x-ndjson")
|
||||||
|
|
@ -1,15 +1,46 @@
|
||||||
# --- routers/config.py ---
|
# --- routers/config.py ---
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import database
|
||||||
|
import asyncio
|
||||||
|
import asyncssh
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List
|
from typing import List
|
||||||
from netmiko import ConnectHandler
|
from netmiko import ConnectHandler
|
||||||
# 💡 確保從 shared 引入 deep_split_tree
|
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
||||||
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings
|
from shared import CMTS_DEVICE, get_cmts_lock, parse_cli_to_tree, deep_split_tree
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from logger import get_all_log_levels, set_log_level
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🌟 雙軌過濾器檔案讀寫輔助函數 (加入高可用性 Fallback)
|
||||||
|
# ==========================================
|
||||||
|
def get_filter_file_path(config_type: str) -> str:
|
||||||
|
# 確保檔名安全,只允許 'running' 或 'full'
|
||||||
|
safe_type = "full" if config_type == "full" else "running"
|
||||||
|
return f"filters_{safe_type}.json"
|
||||||
|
|
||||||
|
async def load_tree_filters(config_type: str) -> list:
|
||||||
|
try:
|
||||||
|
db_filters = await database.get_tree_filters(config_type)
|
||||||
|
return db_filters if db_filters is not None else []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"資料庫讀取過濾器發生例外: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def save_tree_filters(config_type: str, hidden_keys: list):
|
||||||
|
try:
|
||||||
|
await database.upsert_tree_filters(config_type, hidden_keys)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"資料庫寫入過濾器發生例外: {e}")
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
class ConfigRequest(BaseModel):
|
class ConfigRequest(BaseModel):
|
||||||
script: str
|
script: str
|
||||||
host: str
|
host: str
|
||||||
|
|
@ -24,51 +55,90 @@ class DiffItem(BaseModel):
|
||||||
|
|
||||||
class GenerateCliRequest(BaseModel):
|
class GenerateCliRequest(BaseModel):
|
||||||
diffs: List[DiffItem]
|
diffs: List[DiffItem]
|
||||||
interfaces_with_admin_state: List[str] = [] # 💡 接收前端傳來的動態探測名單
|
interfaces_with_admin_state: List[str] = []
|
||||||
|
|
||||||
@router.post("/cmts-config")
|
@router.post("/cmts-config")
|
||||||
async def execute_cmts_config(req: ConfigRequest):
|
async def execute_cmts_config(req: ConfigRequest):
|
||||||
# 使用非同步鎖,確保同一時間只有一個寫入任務執行
|
|
||||||
async with cmts_config_lock:
|
|
||||||
try:
|
|
||||||
device = CMTS_DEVICE.copy()
|
|
||||||
device.update({'host': req.host, 'username': req.username, 'password': req.password})
|
|
||||||
|
|
||||||
# 過濾掉空行與註解 (! 開頭的行)
|
# 過濾掉空行與註解 (! 開頭的行)
|
||||||
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
|
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
|
||||||
|
|
||||||
net_connect = ConnectHandler(**device)
|
async def config_streamer():
|
||||||
# 🌟 1. 手動進入設定模式 (恢復您原本正確的寫法)
|
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
||||||
net_connect.send_command_timing("config")
|
host_lock = get_cmts_lock[req.host]
|
||||||
|
async with host_lock:
|
||||||
|
try:
|
||||||
|
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
|
||||||
|
|
||||||
# 🌟 2. 批次送出設定指令
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
# 加上 enter_config_mode=False 與 exit_config_mode=False
|
conn = await asyncio.wait_for(
|
||||||
# 這樣 Netmiko 就不會自作主張去打 config terminal 了
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
output = net_connect.send_config_set(
|
timeout=10.0
|
||||||
commands,
|
|
||||||
read_timeout=60,
|
|
||||||
cmd_verify=False,
|
|
||||||
enter_config_mode=False,
|
|
||||||
exit_config_mode=False
|
|
||||||
)
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
|
async with conn.create_process() as process:
|
||||||
|
|
||||||
# 🌟 3. 手動退出設定模式
|
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
||||||
net_connect.send_command_timing("exit")
|
async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
|
||||||
net_connect.disconnect()
|
out_data = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 利用 wait_for 設定超時,時間內沒資料就當作設備吐完了
|
||||||
|
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out_data += str(chunk)
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, out_data):
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
return out_data
|
||||||
|
|
||||||
# 🌟 新增:檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
|
# 1. 進入設定模式
|
||||||
|
process.stdin.write("config\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
|
||||||
|
|
||||||
|
# 2. 逐行送出指令並回報進度
|
||||||
|
for cmd in commands:
|
||||||
|
yield json.dumps({"status": "progress", "message": f"▶️ 執行: {cmd}"}) + "\n"
|
||||||
|
process.stdin.write(cmd + "\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
|
||||||
|
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
|
||||||
|
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
|
||||||
|
output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
|
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
|
||||||
lower_output = output.lower()
|
lower_output = output.lower()
|
||||||
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output:
|
if "% invalid" in lower_output or "% incomplete" in lower_output or "error" in lower_output or "aborted" in lower_output:
|
||||||
# 回傳錯誤狀態,前端就會顯示紅色字體且「不會」自動關閉視窗
|
yield json.dumps({
|
||||||
return {
|
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "CMTS 拒絕了設定 (Commit Aborted)",
|
"message": f"❌ 設備拒絕指令: {cmd}",
|
||||||
"detail": output
|
"output": output.strip()
|
||||||
}
|
}) + "\n"
|
||||||
|
|
||||||
|
process.stdin.write("abort\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
return # 發生錯誤,提早結束串流
|
||||||
|
|
||||||
|
# 3. 退出設定模式
|
||||||
|
process.stdin.write("exit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
|
yield json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"message": "✅ 所有配置已成功寫入並 Commit!",
|
||||||
|
"output": final_output.strip()
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
return {"status": "success", "data": output}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"CMTS Config Error: {str(e)}")
|
yield json.dumps({"status": "error", "message": f"SSH 執行發生例外錯誤: {str(e)}"}) + "\n"
|
||||||
|
|
||||||
|
# 回傳 NDJSON 串流格式
|
||||||
|
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
@router.post("/cmts-generate-cli")
|
@router.post("/cmts-generate-cli")
|
||||||
async def generate_cli(req: GenerateCliRequest):
|
async def generate_cli(req: GenerateCliRequest):
|
||||||
|
|
@ -76,21 +146,16 @@ async def generate_cli(req: GenerateCliRequest):
|
||||||
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
|
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
|
||||||
並自動處理 admin-state 的安全生命週期 (先 down 後 up)
|
並自動處理 admin-state 的安全生命週期 (先 down 後 up)
|
||||||
"""
|
"""
|
||||||
# 用來將同一個介面的變更群組化
|
|
||||||
interface_groups = defaultdict(list)
|
interface_groups = defaultdict(list)
|
||||||
|
|
||||||
# 用來記錄該介面最終的 admin-state 應該是什麼
|
|
||||||
interface_admin_states = {}
|
interface_admin_states = {}
|
||||||
|
|
||||||
for diff in req.diffs:
|
for diff in req.diffs:
|
||||||
# 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency']
|
|
||||||
parts = diff.path.split('::')
|
parts = diff.path.split('::')
|
||||||
|
|
||||||
param_name = parts[-1]
|
param_name = parts[-1]
|
||||||
interface_path = " ".join(parts[:-1])
|
interface_path = " ".join(parts[:-1])
|
||||||
|
|
||||||
# 🌟 新增這行:全域清除路徑中間可能夾帶的虛擬資料夾索引 (例如 [0], [1])
|
# 全域清除路徑中間可能夾帶的虛擬資料夾索引
|
||||||
# 這樣 "logging evt 1024 [1] active" 就會還原成 "logging evt 1024 active"
|
|
||||||
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
|
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
|
||||||
|
|
||||||
if param_name == "admin-state":
|
if param_name == "admin-state":
|
||||||
|
|
@ -98,21 +163,17 @@ async def generate_cli(req: GenerateCliRequest):
|
||||||
else:
|
else:
|
||||||
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
|
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
|
||||||
|
|
||||||
# 開始組裝最終的 CLI 腳本
|
|
||||||
cli_lines = []
|
cli_lines = []
|
||||||
cli_lines.append("! --- Auto-Generated Configuration Script ---")
|
cli_lines.append("! --- Auto-Generated Configuration Script ---")
|
||||||
|
|
||||||
for interface, changes in interface_groups.items():
|
for interface, changes in interface_groups.items():
|
||||||
cli_lines.append(f"! Configuring: {interface}")
|
cli_lines.append(f"! Configuring: {interface}")
|
||||||
|
|
||||||
# 💡 核心邏輯:判斷這個特定區塊是否支援 admin-state
|
|
||||||
supports_admin_state = interface in req.interfaces_with_admin_state
|
supports_admin_state = interface in req.interfaces_with_admin_state
|
||||||
|
|
||||||
# 1. 安全機制:如果支援,才強制將介面 admin-state down
|
|
||||||
if supports_admin_state:
|
if supports_admin_state:
|
||||||
cli_lines.append(f"{interface} admin-state down")
|
cli_lines.append(f"{interface} admin-state down")
|
||||||
|
|
||||||
# 2. 寫入所有被修改的參數
|
|
||||||
for param, old_val, new_val in changes:
|
for param, old_val, new_val in changes:
|
||||||
if re.match(r"^\[\d+\]$", param):
|
if re.match(r"^\[\d+\]$", param):
|
||||||
if new_val == "":
|
if new_val == "":
|
||||||
|
|
@ -128,16 +189,13 @@ async def generate_cli(req: GenerateCliRequest):
|
||||||
else:
|
else:
|
||||||
cli_lines.append(f"{interface} {param} {new_val}")
|
cli_lines.append(f"{interface} {param} {new_val}")
|
||||||
|
|
||||||
# 3. 恢復狀態:如果支援,才恢復為 up 或使用者指定的值
|
|
||||||
if supports_admin_state:
|
if supports_admin_state:
|
||||||
final_state = interface_admin_states.get(interface, "up")
|
final_state = interface_admin_states.get(interface, "up")
|
||||||
cli_lines.append(f"{interface} admin-state {final_state}")
|
cli_lines.append(f"{interface} admin-state {final_state}")
|
||||||
|
|
||||||
# Harmonic 必須要有 commit 才會生效
|
|
||||||
cli_lines.append("commit")
|
cli_lines.append("commit")
|
||||||
cli_lines.append("!") # 空行分隔
|
cli_lines.append("!")
|
||||||
|
|
||||||
# 將獨立修改 admin-state (沒有修改其他參數) 的情況也補上
|
|
||||||
for interface, state in interface_admin_states.items():
|
for interface, state in interface_admin_states.items():
|
||||||
if interface not in interface_groups:
|
if interface not in interface_groups:
|
||||||
cli_lines.append(f"! Configuring state only: {interface}")
|
cli_lines.append(f"! Configuring state only: {interface}")
|
||||||
|
|
@ -145,30 +203,25 @@ async def generate_cli(req: GenerateCliRequest):
|
||||||
cli_lines.append("commit")
|
cli_lines.append("commit")
|
||||||
cli_lines.append("!")
|
cli_lines.append("!")
|
||||||
|
|
||||||
# 將陣列組合成字串回傳
|
|
||||||
final_script = "\n".join(cli_lines)
|
final_script = "\n".join(cli_lines)
|
||||||
|
|
||||||
return {"status": "success", "data": final_script}
|
return {"status": "success", "data": final_script}
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 💡 以下為 DS RF Port 動態樹狀查詢 API
|
# DS RF Port 動態樹狀查詢 API
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@router.get("/cmts-ds-rf-port-list")
|
@router.get("/cmts-ds-rf-port-list")
|
||||||
async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
|
async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
|
||||||
"""獲取設備上所有的 DS RF Port 清單"""
|
"""獲取設備上所有的 DS RF Port 清單"""
|
||||||
|
net_connect = None
|
||||||
try:
|
try:
|
||||||
device = CMTS_DEVICE.copy()
|
device = CMTS_DEVICE.copy()
|
||||||
device.update({'host': host, 'username': username, 'password': password})
|
device.update({'host': host, 'username': username, 'password': password})
|
||||||
|
|
||||||
net_connect = ConnectHandler(**device)
|
net_connect = ConnectHandler(**device)
|
||||||
|
|
||||||
# 使用 include 只抓取關鍵字,並把等待時間延長至 60 秒
|
|
||||||
command = 'show running-config | include "cable ds-rf-port"'
|
command = 'show running-config | include "cable ds-rf-port"'
|
||||||
raw_output = net_connect.send_command(command, read_timeout=60)
|
raw_output = str(net_connect.send_command(command, read_timeout=60))
|
||||||
net_connect.disconnect()
|
|
||||||
|
|
||||||
# 使用正規表達式擷取 port 號碼 (例如 62:0/0)
|
|
||||||
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
|
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
|
||||||
ports = re.findall(pattern, raw_output)
|
ports = re.findall(pattern, raw_output)
|
||||||
unique_ports = sorted(list(set(ports)))
|
unique_ports = sorted(list(set(ports)))
|
||||||
|
|
@ -180,58 +233,73 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
finally:
|
||||||
|
if net_connect:
|
||||||
|
net_connect.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cmts-ds-rf-port-config")
|
@router.get("/cmts-ds-rf-port-config")
|
||||||
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
|
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
|
||||||
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
|
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
|
||||||
|
net_connect = None
|
||||||
try:
|
try:
|
||||||
device = CMTS_DEVICE.copy()
|
device = CMTS_DEVICE.copy()
|
||||||
device.update({'host': host, 'username': username, 'password': password})
|
device.update({'host': host, 'username': username, 'password': password})
|
||||||
|
|
||||||
net_connect = ConnectHandler(**device)
|
net_connect = ConnectHandler(**device)
|
||||||
command = f"show running-config interface cable ds-rf-port {target} | nomore"
|
command = f"show running-config interface cable ds-rf-port {target} | nomore"
|
||||||
raw_output = net_connect.send_command(command, read_timeout=60)
|
raw_output = str(net_connect.send_command(command, read_timeout=60))
|
||||||
net_connect.disconnect()
|
|
||||||
|
|
||||||
# 1. 初步解析縮排
|
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
||||||
base_tree = parse_cli_to_tree(raw_output)
|
base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
|
||||||
# 2. 💡 深層解析空白字元,建立完美樹狀結構
|
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
||||||
perfect_tree = deep_split_tree(base_tree)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": perfect_tree, # 回傳完美的樹狀結構
|
"data": perfect_tree,
|
||||||
"raw_cli": raw_output.strip()
|
"raw_cli": raw_output.strip()
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
finally:
|
||||||
|
if net_connect:
|
||||||
|
net_connect.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cmts-full-config")
|
@router.get("/cmts-full-config")
|
||||||
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False):
|
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
|
||||||
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
|
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
|
||||||
|
net_connect = None
|
||||||
try:
|
try:
|
||||||
device = CMTS_DEVICE.copy()
|
device = CMTS_DEVICE.copy()
|
||||||
device.update({'host': host, 'username': username, 'password': password})
|
device.update({'host': host, 'username': username, 'password': password})
|
||||||
|
|
||||||
net_connect = ConnectHandler(**device)
|
net_connect = ConnectHandler(**device)
|
||||||
command = "show running-config | nomore"
|
|
||||||
raw_output = net_connect.send_command(command, read_timeout=60)
|
|
||||||
net_connect.disconnect()
|
|
||||||
|
|
||||||
base_tree = parse_cli_to_tree(raw_output)
|
if config_type == "full":
|
||||||
perfect_tree = deep_split_tree(base_tree)
|
net_connect.send_command_timing("config")
|
||||||
|
command = "show full-configuration | nomore"
|
||||||
|
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
||||||
|
net_connect.send_command_timing("exit")
|
||||||
|
else:
|
||||||
|
command = "show running-config | nomore"
|
||||||
|
raw_output = str(net_connect.send_command(command, read_timeout=180))
|
||||||
|
|
||||||
|
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
|
||||||
|
clean_output = clean_output.lstrip()
|
||||||
|
|
||||||
|
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
||||||
|
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
|
||||||
|
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 💡 深度過濾攔截器:支援多層級路徑 (如 cable.mac-domain)
|
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
|
||||||
# ==========================================
|
# ==========================================
|
||||||
if not skip_filter:
|
if not skip_filter:
|
||||||
settings = load_settings()
|
hidden_keys = await load_tree_filters(config_type)
|
||||||
hidden_keys = settings.get("hidden_keys", [])
|
|
||||||
|
|
||||||
for path in hidden_keys:
|
for path in hidden_keys:
|
||||||
keys = path.split('.')
|
keys = path.split('::')
|
||||||
current = perfect_tree
|
current = perfect_tree
|
||||||
# 走到倒數第二層
|
# 走到倒數第二層
|
||||||
for k in keys[:-1]:
|
for k in keys[:-1]:
|
||||||
|
|
@ -248,21 +316,122 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
|
||||||
return {"status": "success", "data": perfect_tree}
|
return {"status": "success", "data": perfect_tree}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
finally:
|
||||||
|
if net_connect:
|
||||||
|
net_connect.disconnect()
|
||||||
|
|
||||||
|
@router.get("/cmts-full-config/stream")
|
||||||
|
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
|
||||||
|
"""獲取整台 CMTS 的完整配置 (串流進度回報版)"""
|
||||||
|
|
||||||
|
async def config_streamer():
|
||||||
|
net_connect = None
|
||||||
|
try:
|
||||||
|
# 1. 廣播:正在連線
|
||||||
|
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1) # 讓前端有時間渲染
|
||||||
|
|
||||||
|
device = CMTS_DEVICE.copy()
|
||||||
|
device.update({'host': host, 'username': username, 'password': password})
|
||||||
|
|
||||||
|
# 使用 asyncio.to_thread 將阻塞的 netmiko 連線放到背景執行,避免卡住其他非同步任務
|
||||||
|
net_connect = await asyncio.to_thread(ConnectHandler, **device)
|
||||||
|
|
||||||
|
# 2. 廣播:正在下載
|
||||||
|
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
||||||
|
|
||||||
|
if config_type == "full":
|
||||||
|
await asyncio.to_thread(net_connect.send_command_timing, "config")
|
||||||
|
command = "show full-configuration | nomore"
|
||||||
|
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
||||||
|
await asyncio.to_thread(net_connect.send_command_timing, "exit")
|
||||||
|
else:
|
||||||
|
command = "show running-config | nomore"
|
||||||
|
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
||||||
|
|
||||||
|
# 3. 廣播:正在解析
|
||||||
|
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
|
||||||
|
clean_output = clean_output.lstrip()
|
||||||
|
|
||||||
|
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
|
||||||
|
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
|
||||||
|
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 💡 系統設定 API (System Settings)
|
# 🌟 深度過濾攔截器 (維持原樣)
|
||||||
|
# ==========================================
|
||||||
|
if not skip_filter:
|
||||||
|
yield json.dumps({"status": "progress", "message": "🔍 正在套用系統過濾器規則..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
hidden_keys = await load_tree_filters(config_type)
|
||||||
|
|
||||||
|
for path in hidden_keys:
|
||||||
|
keys = path.split('::')
|
||||||
|
current = perfect_tree
|
||||||
|
for k in keys[:-1]:
|
||||||
|
if isinstance(current, dict) and k in current:
|
||||||
|
current = current[k]
|
||||||
|
else:
|
||||||
|
current = None
|
||||||
|
break
|
||||||
|
if isinstance(current, dict) and keys[-1] in current:
|
||||||
|
current.pop(keys[-1], None)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 4. 廣播:完成並傳送最終資料
|
||||||
|
yield json.dumps({"status": "success", "data": perfect_tree}) + "\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
|
||||||
|
finally:
|
||||||
|
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
|
||||||
|
if net_connect:
|
||||||
|
await asyncio.to_thread(net_connect.disconnect)
|
||||||
|
|
||||||
|
# 回傳 NDJSON 串流格式
|
||||||
|
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🌟 系統設定 API (System Settings)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
class SettingsRequest(BaseModel):
|
class SettingsRequest(BaseModel):
|
||||||
hidden_keys: list[str]
|
hidden_keys: list[str]
|
||||||
|
config_type: str = "running" # 🌟 加入 config_type 參數
|
||||||
|
|
||||||
@router.get("/settings/tree-filters")
|
@router.get("/settings/tree-filters")
|
||||||
async def get_tree_filters():
|
async def get_tree_filters(config_type: str = "running"):
|
||||||
"""獲取目前的樹狀圖隱藏名單"""
|
"""獲取目前的樹狀圖隱藏名單"""
|
||||||
settings = load_settings()
|
# 🌟 根據 config_type 讀取對應的 JSON
|
||||||
return {"status": "success", "data": settings["hidden_keys"]}
|
hidden_keys = await load_tree_filters(config_type)
|
||||||
|
return {"status": "success", "data": hidden_keys}
|
||||||
|
|
||||||
@router.post("/settings/tree-filters")
|
@router.post("/settings/tree-filters")
|
||||||
async def update_tree_filters(req: SettingsRequest):
|
async def update_tree_filters(req: SettingsRequest):
|
||||||
"""更新樹狀圖隱藏名單並存檔"""
|
"""更新樹狀圖隱藏名單並存檔"""
|
||||||
save_settings({"hidden_keys": req.hidden_keys})
|
# 🌟 根據 config_type 寫入對應的 JSON
|
||||||
return {"status": "success", "message": "系統設定已更新"}
|
await save_tree_filters(req.config_type, req.hidden_keys)
|
||||||
|
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🌟 伺服器日誌管控 API (Server Log Management)
|
||||||
|
# ==========================================
|
||||||
|
class LogLevelRequest(BaseModel):
|
||||||
|
module: str
|
||||||
|
level: str
|
||||||
|
|
||||||
|
@router.get("/settings/logs")
|
||||||
|
async def api_get_log_levels():
|
||||||
|
"""獲取目前所有模組的日誌等級"""
|
||||||
|
return {"status": "success", "data": get_all_log_levels()}
|
||||||
|
|
||||||
|
@router.post("/settings/logs")
|
||||||
|
async def api_set_log_level(req: LogLevelRequest):
|
||||||
|
"""動態修改特定模組的日誌等級"""
|
||||||
|
success = set_log_level(req.module, req.level)
|
||||||
|
if success:
|
||||||
|
return {"status": "success", "message": f"已將 {req.module} 的日誌等級設為 {req.level}"}
|
||||||
|
raise HTTPException(status_code=400, detail="未知的模組名稱")
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
# --- routers/diagnostics.py ---
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import asyncssh
|
||||||
|
from logger import get_logger
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
logger = get_logger("app.diagnostics")
|
||||||
|
router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"])
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_cm_diagnostics(
|
||||||
|
host: str = Query(...),
|
||||||
|
username: str = Query(...),
|
||||||
|
password: str = Query(...),
|
||||||
|
mac: str = Query(..., description="Cable Modem MAC Address")
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
|
||||||
|
mac = mac.strip().lower()
|
||||||
|
if not mac:
|
||||||
|
raise HTTPException(status_code=400, detail="MAC Address is required")
|
||||||
|
|
||||||
|
result_data = {
|
||||||
|
"mac": mac,
|
||||||
|
"ip": "N/A",
|
||||||
|
"state": "N/A",
|
||||||
|
"cpe_count": 0,
|
||||||
|
"phy": {
|
||||||
|
"tx_power": None,
|
||||||
|
"rx_power": None
|
||||||
|
},
|
||||||
|
"upstreams": [],
|
||||||
|
"mer_channels": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
|
|
||||||
|
logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 1. 查詢基本狀態
|
||||||
|
# ==========================================
|
||||||
|
cmd_base = "show cable modem " + mac + " | nomore"
|
||||||
|
res_base = await conn.run(cmd_base, check=False)
|
||||||
|
if res_base.exit_status == 0 and res_base.stdout:
|
||||||
|
for line in res_base.stdout.splitlines():
|
||||||
|
if mac in line.lower():
|
||||||
|
tokens = line.split()
|
||||||
|
for t in tokens:
|
||||||
|
if re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$", t):
|
||||||
|
result_data["ip"] = t
|
||||||
|
elif re.match(r"^[a-z]-online|online|offline|init.*|reject", t, re.IGNORECASE):
|
||||||
|
result_data["state"] = t
|
||||||
|
nums = [t for t in tokens if t.isdigit()]
|
||||||
|
if nums:
|
||||||
|
result_data["cpe_count"] = int(nums[-1])
|
||||||
|
break
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. 查詢 PHY 狀態 (💡 修復:移除寫死的 us/oad 判斷,改用通用特徵)
|
||||||
|
# ==========================================
|
||||||
|
cmd_phy = "show cable modem " + mac + " phy | nomore"
|
||||||
|
res_phy = await conn.run(cmd_phy, check=False)
|
||||||
|
if res_phy.exit_status == 0 and res_phy.stdout:
|
||||||
|
tx_list = []
|
||||||
|
rx_list = []
|
||||||
|
for line in res_phy.stdout.splitlines():
|
||||||
|
line_lower = line.lower()
|
||||||
|
if mac in line_lower:
|
||||||
|
tokens = line.split()
|
||||||
|
if len(tokens) >= 6:
|
||||||
|
ch_name = tokens[1]
|
||||||
|
# 💡 只要名稱包含 ":" 和 "/",就認定是合法的通道 (例如 Oa32, Oad32, Us32)
|
||||||
|
if ":" in ch_name and "/" in ch_name:
|
||||||
|
try:
|
||||||
|
snr_val = float(tokens[4])
|
||||||
|
result_data["upstreams"].append({"channel": ch_name, "snr": snr_val})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
tx_val = float(tokens[3].split('/')[0])
|
||||||
|
rx_list_val = float(tokens[5].split('/')[0])
|
||||||
|
tx_list.append(tx_val)
|
||||||
|
rx_list.append(rx_list_val)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if tx_list: result_data["phy"]["tx_power"] = round(sum(tx_list)/len(tx_list), 2)
|
||||||
|
if rx_list: result_data["phy"]["rx_power"] = round(sum(rx_list)/len(rx_list), 2)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. 尋找所有 OFDM 通道
|
||||||
|
# ==========================================
|
||||||
|
cmd_help = "show cable modem " + mac + " ofdm-channel ?"
|
||||||
|
res_help = await conn.run(cmd_help, check=False)
|
||||||
|
text_to_search = (res_help.stdout or "") + "\n" + (res_help.stderr or "")
|
||||||
|
|
||||||
|
cmd_verb = "show cable modem " + mac + " verbose | nomore"
|
||||||
|
res_verb = await conn.run(cmd_verb, check=False)
|
||||||
|
text_to_search += "\n" + (res_verb.stdout or "")
|
||||||
|
|
||||||
|
matches = re.findall(r"\b(Of(?:dm)?\d*[\d/:]+)\b", text_to_search, re.IGNORECASE)
|
||||||
|
ofdm_channels = []
|
||||||
|
for m in matches:
|
||||||
|
clean_m = m.lower()
|
||||||
|
if clean_m.startswith("of"):
|
||||||
|
clean_m = "Of" + clean_m[2:]
|
||||||
|
if clean_m not in ofdm_channels:
|
||||||
|
ofdm_channels.append(clean_m)
|
||||||
|
ofdm_channels.sort()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. 對每一個 OFDM 通道查詢 MER
|
||||||
|
# ==========================================
|
||||||
|
for ch in ofdm_channels:
|
||||||
|
cmd_mer = "show cable modem " + mac + " ofdm-channel " + ch + " mer | nomore"
|
||||||
|
res_mer = await conn.run(cmd_mer, check=False)
|
||||||
|
|
||||||
|
histogram = {}
|
||||||
|
min_mer = 999
|
||||||
|
|
||||||
|
if res_mer.exit_status == 0 and res_mer.stdout:
|
||||||
|
mer_lines = re.finditer(r"(\d+)\s*(?:db|dB)?\s*\|[^|]*\|?\s*(\**)", res_mer.stdout, re.IGNORECASE)
|
||||||
|
for match in mer_lines:
|
||||||
|
db_val = match.group(1)
|
||||||
|
stars = match.group(2)
|
||||||
|
if stars:
|
||||||
|
histogram[db_val] = len(stars) * 100
|
||||||
|
db_int = int(db_val)
|
||||||
|
if db_int < min_mer:
|
||||||
|
min_mer = db_int
|
||||||
|
|
||||||
|
health = "unknown"
|
||||||
|
if histogram and min_mer != 999:
|
||||||
|
if min_mer >= 41: health = "good"
|
||||||
|
elif min_mer >= 38: health = "warning"
|
||||||
|
else: health = "critical"
|
||||||
|
|
||||||
|
result_data["mer_channels"][ch] = {
|
||||||
|
"histogram": histogram,
|
||||||
|
"health": health,
|
||||||
|
"min_mer": min_mer if min_mer != 999 else "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"✅ [Diagnostics] 解析結果: {result_data}")
|
||||||
|
return {"status": "success", "data": result_data}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("CM Diagnostics Error: " + str(e))
|
||||||
|
raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e))
|
||||||
|
|
@ -1,49 +1,112 @@
|
||||||
# --- routers/leaf_options.py ---
|
# --- routers/leaf_options.py ---
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Body
|
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, time, re
|
import json, asyncio, re
|
||||||
|
import database
|
||||||
# 🌟 引入新的 asyncssh 版本爬蟲
|
|
||||||
from cmts_scraper import sync_cmts_leaves_async
|
from cmts_scraper import sync_cmts_leaves_async
|
||||||
from shared import CMTS_DEVICE
|
from shared import CMTS_DEVICE
|
||||||
|
|
||||||
# 🌟 移除原本硬寫的 prefix="/api/v1",只留下 tags
|
|
||||||
router = APIRouter(tags=["Options"])
|
router = APIRouter(tags=["Options"])
|
||||||
CACHE_FILE = "cmts_leaf_options_cache.json"
|
|
||||||
|
|
||||||
# 🌟 1. 改用全域布林變數,狀態切換更即時,消滅非同步時間差
|
SCAN_STATUS = {}
|
||||||
IS_SCANNING = False
|
active_clients = {}
|
||||||
|
|
||||||
|
async def broadcast_message(message_dict: dict, host: str):
|
||||||
|
channel_key = host
|
||||||
|
dead_clients = set()
|
||||||
|
message_str = f"data: {json.dumps(message_dict)}\n\n"
|
||||||
|
|
||||||
|
for client_queue in active_clients.get(channel_key, set()):
|
||||||
|
try:
|
||||||
|
await client_queue.put(message_str)
|
||||||
|
except Exception:
|
||||||
|
dead_clients.add(client_queue)
|
||||||
|
for dead in dead_clients:
|
||||||
|
active_clients[channel_key].discard(dead)
|
||||||
|
|
||||||
|
@router.get("/cmts-leaf-options/stream")
|
||||||
|
async def sse_stream(request: Request, host: str):
|
||||||
|
channel_key = host
|
||||||
|
client_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
if channel_key not in active_clients:
|
||||||
|
active_clients[channel_key] = set()
|
||||||
|
active_clients[channel_key].add(client_queue)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(client_queue.get(), timeout=5.0)
|
||||||
|
yield message
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if channel_key in active_clients:
|
||||||
|
active_clients[channel_key].discard(client_queue)
|
||||||
|
# ✅ 安全升級:如果該設備已經沒有任何客戶端監聽,徹底刪除 Key 釋放記憶體
|
||||||
|
if not active_clients[channel_key]:
|
||||||
|
del active_clients[channel_key]
|
||||||
|
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
@router.get("/scan-status")
|
||||||
|
async def get_scan_status(host: str):
|
||||||
|
return {"is_scanning": SCAN_STATUS.get(host, False)}
|
||||||
|
|
||||||
class SyncOptionsRequest(BaseModel):
|
class SyncOptionsRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
leaf_paths: List[str]
|
leaf_paths: List[str]
|
||||||
|
|
||||||
@router.get("/cmts-leaf-options")
|
@router.get("/cmts-leaf-options")
|
||||||
async def get_leaf_options():
|
async def get_leaf_options(host: str):
|
||||||
if not os.path.exists(CACHE_FILE): return {}
|
try:
|
||||||
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
db_options = await database.get_all_leaf_options(host)
|
||||||
return json.load(f)
|
if db_options is not None:
|
||||||
|
metadata = await database.get_device_status(host)
|
||||||
|
if metadata:
|
||||||
|
db_options["__metadata__"] = metadata
|
||||||
|
return db_options
|
||||||
|
raise HTTPException(status_code=500, detail="資料庫連線異常")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"資料庫讀取失敗: {e}")
|
||||||
|
|
||||||
|
async def run_scan_task(host: str, username: str, password: str, paths: List[str]):
|
||||||
|
global SCAN_STATUS
|
||||||
|
channel_key = host
|
||||||
|
try:
|
||||||
|
await broadcast_message({"event": "scan_start"}, host)
|
||||||
|
async for chunk in sync_cmts_leaves_async(host=host, username=username, password=password, leaf_paths=paths):
|
||||||
|
if isinstance(chunk, str):
|
||||||
|
await broadcast_message(json.loads(chunk), host)
|
||||||
|
else:
|
||||||
|
await broadcast_message(chunk, host)
|
||||||
|
await broadcast_message({"event": "done"}, host)
|
||||||
|
except Exception as e:
|
||||||
|
await broadcast_message({"event": "error", "message": str(e)}, host)
|
||||||
|
finally:
|
||||||
|
SCAN_STATUS[channel_key] = False
|
||||||
|
|
||||||
@router.post("/cmts-leaf-options/sync")
|
@router.post("/cmts-leaf-options/sync")
|
||||||
async def sync_leaf_options(request: SyncOptionsRequest):
|
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
|
||||||
global IS_SCANNING # 宣告我們要修改全域變數
|
global SCAN_STATUS
|
||||||
|
|
||||||
if not request.leaf_paths:
|
if not request.leaf_paths:
|
||||||
raise HTTPException(status_code=400)
|
raise HTTPException(status_code=400)
|
||||||
|
|
||||||
# 🌟 2. 第一時間檢查並「立即」阻擋
|
channel_key = request.host
|
||||||
if IS_SCANNING:
|
if SCAN_STATUS.get(channel_key, False):
|
||||||
async def busy_stream():
|
return {"status": "busy", "message": "⚠️ 該設備的掃描任務正在執行中,請勿重複點擊!"}
|
||||||
yield json.dumps({"event": "error", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}) + "\n"
|
|
||||||
return StreamingResponse(busy_stream(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
# 如果沒人掃描,立刻把狀態改為 True (在建立串流之前就上鎖)
|
SCAN_STATUS[channel_key] = True
|
||||||
IS_SCANNING = True
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
global IS_SCANNING
|
|
||||||
try:
|
|
||||||
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("::", " ")
|
||||||
|
|
@ -51,51 +114,18 @@ async def sync_leaf_options(request: SyncOptionsRequest):
|
||||||
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))
|
||||||
|
user = request.username or CMTS_DEVICE.get("username") or ""
|
||||||
|
pwd = request.password or CMTS_DEVICE.get("password") or ""
|
||||||
|
|
||||||
# 🌟 因為爬蟲自己會存檔,我們只要專心把進度轉發給前端就好!
|
background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths)
|
||||||
async for chunk in sync_cmts_leaves_async(
|
return {"status": "started"}
|
||||||
host=CMTS_DEVICE.get("host"),
|
|
||||||
username=CMTS_DEVICE.get("username"),
|
|
||||||
password=CMTS_DEVICE.get("password"),
|
|
||||||
leaf_paths=cmts_query_paths
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
yield json.dumps({"event": "error", "message": str(e)}) + "\n"
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# 🌟 3. 無論成功、失敗、或使用者提早關閉網頁,都確保會解鎖
|
|
||||||
IS_SCANNING = False
|
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
# 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE)
|
|
||||||
@router.post("/clear_cache")
|
@router.post("/clear_cache")
|
||||||
async def clear_specific_cache(paths_to_clear: list = Body(...)):
|
async def clear_specific_cache(host: str, paths_to_clear: list = Body(...)):
|
||||||
cleared_count = 0
|
|
||||||
|
|
||||||
# 1. 檢查快取檔案是否存在
|
|
||||||
if not os.path.exists(CACHE_FILE):
|
|
||||||
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 2. 讀取現有的 JSON 快取資料
|
cleared_count = await database.delete_leaf_options(host, paths_to_clear)
|
||||||
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
if cleared_count >= 0:
|
||||||
cache_data = json.load(f)
|
|
||||||
|
|
||||||
# 3. 逐一比對並刪除指定的路徑
|
|
||||||
for path in paths_to_clear:
|
|
||||||
if path in cache_data:
|
|
||||||
del cache_data[path]
|
|
||||||
cleared_count += 1
|
|
||||||
|
|
||||||
# 4. 如果有刪除資料,將更新後的內容寫回 JSON 檔案
|
|
||||||
if cleared_count > 0:
|
|
||||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(cache_data, f, ensure_ascii=False, indent=4)
|
|
||||||
|
|
||||||
return {"status": "success", "cleared_count": cleared_count}
|
return {"status": "success", "cleared_count": cleared_count}
|
||||||
|
raise HTTPException(status_code=500, detail="資料庫清除失敗")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": f"清除快取時發生錯誤: {str(e)}"}
|
raise HTTPException(status_code=500, detail=f"資料庫清除失敗: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,50 @@
|
||||||
# --- routers/lock.py ---
|
# --- routers/lock.py ---
|
||||||
import time
|
import time
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
# 🌟 只定義自己的子路徑
|
|
||||||
router = APIRouter(prefix="/locks", tags=["Lock Management"])
|
router = APIRouter(prefix="/locks", tags=["Lock Management"])
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 💡 全域鎖定表 (In-Memory Lock Table)
|
# 💡 全域鎖定表 (In-Memory Lock Table)
|
||||||
# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} }
|
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
|
||||||
|
# ⚠️ 嚴格規則:Lock Key 絕對不包含 config_type,確保 running 與 full 視圖共用同一把鎖!
|
||||||
# ==========================================
|
# ==========================================
|
||||||
ACTIVE_LOCKS: Dict[str, dict] = {}
|
ACTIVE_LOCKS: Dict[str, dict] = {}
|
||||||
|
LOCK_TIMEOUT = 120
|
||||||
|
|
||||||
# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat,否則自動釋放
|
# 🌟 請求模型嚴格排除 config_type
|
||||||
LOCK_TIMEOUT = 30
|
|
||||||
|
|
||||||
class LockAcquireReq(BaseModel):
|
class LockAcquireReq(BaseModel):
|
||||||
|
host: str
|
||||||
path: str
|
path: str
|
||||||
user_id: str # 前端產生的 UUID (Session ID)
|
user_id: str
|
||||||
username: str # 登入的帳號 (用於 UI 提示,例如 "admin")
|
username: str
|
||||||
|
|
||||||
class LockActionReq(BaseModel):
|
class LockActionReq(BaseModel):
|
||||||
|
host: str
|
||||||
path: str
|
path: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|
||||||
def clean_expired_locks():
|
def clean_expired_locks():
|
||||||
"""清除已經超時的鎖 (被動式清理)"""
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
expired_paths = [path for path, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
|
expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
|
||||||
for path in expired_paths:
|
for k in expired_keys:
|
||||||
del ACTIVE_LOCKS[path]
|
del ACTIVE_LOCKS[k]
|
||||||
|
|
||||||
def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[dict], str]:
|
def is_path_locked_by_others(target_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
|
||||||
"""
|
|
||||||
檢查路徑是否被其他人鎖定,並回傳 (衝突的鎖定資訊, 具體錯誤訊息)
|
|
||||||
"""
|
|
||||||
clean_expired_locks()
|
clean_expired_locks()
|
||||||
|
prefix = f"{target_host}@@"
|
||||||
|
|
||||||
for locked_path, lock_info in ACTIVE_LOCKS.items():
|
for locked_key, lock_info in ACTIVE_LOCKS.items():
|
||||||
|
# 🌟 只檢查同一台設備 (IP) 的鎖定
|
||||||
|
if not locked_key.startswith(prefix):
|
||||||
|
continue
|
||||||
|
|
||||||
|
locked_path = locked_key.split("@@", 1)[1]
|
||||||
if lock_info["user_id"] == user_id:
|
if lock_info["user_id"] == user_id:
|
||||||
continue # 自己鎖定的不算衝突
|
continue
|
||||||
|
|
||||||
# 💡 拆分判斷,回傳更精準的錯誤訊息
|
|
||||||
if target_path == locked_path:
|
if target_path == locked_path:
|
||||||
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
|
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
|
||||||
|
|
||||||
|
|
@ -54,24 +56,16 @@ def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[d
|
||||||
|
|
||||||
return None, ""
|
return None, ""
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 💡 API Endpoints
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
@router.post("/acquire")
|
@router.post("/acquire")
|
||||||
async def acquire_lock(req: LockAcquireReq):
|
async def acquire_lock(req: LockAcquireReq):
|
||||||
"""請求鎖定特定路徑"""
|
conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
|
||||||
# 接收回傳的鎖定資訊與具體訊息
|
|
||||||
conflict_lock, error_msg = is_path_locked_by_others(req.path, req.user_id)
|
|
||||||
|
|
||||||
if conflict_lock:
|
if conflict_lock:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=409, detail=error_msg)
|
||||||
status_code=409,
|
|
||||||
detail=error_msg # 💡 將精準的錯誤訊息傳給前端
|
|
||||||
)
|
|
||||||
|
|
||||||
# 寫入或更新鎖定狀態
|
# ⚠️ 嚴格綁定 host 與 path,跨視圖共用
|
||||||
ACTIVE_LOCKS[req.path] = {
|
lock_key = f"{req.host}@@{req.path}"
|
||||||
|
ACTIVE_LOCKS[lock_key] = {
|
||||||
"user_id": req.user_id,
|
"user_id": req.user_id,
|
||||||
"username": req.username,
|
"username": req.username,
|
||||||
"expires_at": time.time() + LOCK_TIMEOUT
|
"expires_at": time.time() + LOCK_TIMEOUT
|
||||||
|
|
@ -80,28 +74,33 @@ async def acquire_lock(req: LockAcquireReq):
|
||||||
|
|
||||||
@router.post("/heartbeat")
|
@router.post("/heartbeat")
|
||||||
async def heartbeat_lock(req: LockActionReq):
|
async def heartbeat_lock(req: LockActionReq):
|
||||||
"""延長鎖定時間 (心跳偵測)"""
|
|
||||||
clean_expired_locks()
|
clean_expired_locks()
|
||||||
|
lock_key = f"{req.host}@@{req.path}"
|
||||||
|
|
||||||
if req.path not in ACTIVE_LOCKS:
|
if lock_key not in ACTIVE_LOCKS:
|
||||||
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
|
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
|
||||||
|
|
||||||
if ACTIVE_LOCKS[req.path]["user_id"] != req.user_id:
|
if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
|
||||||
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
|
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
|
||||||
|
|
||||||
# 延長鎖定時間
|
ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
|
||||||
ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT
|
|
||||||
return {"status": "success", "message": "心跳更新成功"}
|
return {"status": "success", "message": "心跳更新成功"}
|
||||||
|
|
||||||
@router.post("/release")
|
@router.post("/release")
|
||||||
async def release_lock(req: LockActionReq):
|
async def release_lock(req: LockActionReq):
|
||||||
"""主動釋放鎖定"""
|
lock_key = f"{req.host}@@{req.path}"
|
||||||
if req.path in ACTIVE_LOCKS and ACTIVE_LOCKS[req.path]["user_id"] == req.user_id:
|
if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
|
||||||
del ACTIVE_LOCKS[req.path]
|
del ACTIVE_LOCKS[lock_key]
|
||||||
return {"status": "success", "message": "鎖定已釋放"}
|
return {"status": "success", "message": "鎖定已釋放"}
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def get_lock_status():
|
async def get_lock_status(host: str = Query(None)):
|
||||||
"""獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)"""
|
"""獲取特定設備的鎖定狀態"""
|
||||||
clean_expired_locks()
|
clean_expired_locks()
|
||||||
return {"status": "success", "data": ACTIVE_LOCKS}
|
if host:
|
||||||
|
prefix = f"{host}@@"
|
||||||
|
# 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 path,UI 不用改!
|
||||||
|
filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
|
||||||
|
return {"status": "success", "data": filtered_locks}
|
||||||
|
|
||||||
|
return {"status": "success", "data": {}}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# --- routers/logs.py ---
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from logger import ColoredFormatter, register_websocket_handler
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
class LogBroadcaster:
|
||||||
|
"""管理所有活躍 WebSocket 連線的廣播器 (Connection Manager)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: set[WebSocket] = set()
|
||||||
|
self.loop: asyncio.AbstractEventLoop = None
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections.add(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket):
|
||||||
|
self.active_connections.discard(websocket)
|
||||||
|
|
||||||
|
async def broadcast(self, message: str):
|
||||||
|
"""非同步推播日誌給所有連線中的客戶端"""
|
||||||
|
if not self.active_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 併發發送,並透過 return_exceptions=True 確保單一連線異常不影響其他客戶端
|
||||||
|
tasks = [self._safe_send(conn, message) for conn in self.active_connections]
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
async def _safe_send(self, websocket: WebSocket, message: str):
|
||||||
|
try:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
self.disconnect(websocket)
|
||||||
|
|
||||||
|
# 建立全域廣播器實例
|
||||||
|
log_broadcaster = LogBroadcaster()
|
||||||
|
|
||||||
|
class WebSocketLogHandler(logging.Handler):
|
||||||
|
"""自訂 Log Handler,攔截系統日誌並安全地派發至非同步廣播器"""
|
||||||
|
def __init__(self, broadcaster: LogBroadcaster):
|
||||||
|
super().__init__()
|
||||||
|
self.broadcaster = broadcaster
|
||||||
|
# 沿用系統 ColoredFormatter,完美保留 ANSI 色碼
|
||||||
|
self.setFormatter(ColoredFormatter())
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
try:
|
||||||
|
msg = self.format(record)
|
||||||
|
loop = self.broadcaster.loop
|
||||||
|
# 確保在 Event Loop 處於執行狀態時,安全地跨執行緒派發任務
|
||||||
|
if loop and loop.is_running():
|
||||||
|
loop.call_soon_threadsafe(
|
||||||
|
lambda: asyncio.create_task(self.broadcaster.broadcast(msg))
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
# 建立 Handler 實例並註冊至所有受管控的 Logger
|
||||||
|
websocket_log_handler = WebSocketLogHandler(log_broadcaster)
|
||||||
|
register_websocket_handler(websocket_log_handler)
|
||||||
|
|
||||||
|
@router.websocket("/ws/logs")
|
||||||
|
async def websocket_logs(websocket: WebSocket):
|
||||||
|
"""WebSocket 實時日誌串流端點"""
|
||||||
|
# 若尚未綁定 Event Loop,則於首次連線時動態綁定
|
||||||
|
if not log_broadcaster.loop:
|
||||||
|
log_broadcaster.loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
await log_broadcaster.connect(websocket)
|
||||||
|
try:
|
||||||
|
# 保持連線,監聽客戶端斷線狀態
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
log_broadcaster.disconnect(websocket)
|
||||||
|
except Exception:
|
||||||
|
log_broadcaster.disconnect(websocket)
|
||||||
223
routers/query.py
223
routers/query.py
|
|
@ -1,55 +1,106 @@
|
||||||
# --- routers/query.py ---
|
# --- routers/query.py ---
|
||||||
import re
|
import re
|
||||||
|
import asyncssh
|
||||||
|
import asyncio
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from netmiko import ConnectHandler
|
|
||||||
from shared import CMTS_DEVICE
|
from shared import CMTS_DEVICE
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
def parse_cm_output(raw_text: str) -> list:
|
# ==========================================
|
||||||
parsed_data = []
|
# 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko)
|
||||||
lines = raw_text.splitlines()
|
# ==========================================
|
||||||
data_started = False
|
|
||||||
for line in lines:
|
|
||||||
if not line.strip(): continue
|
|
||||||
if line.strip().startswith('---'):
|
|
||||||
data_started = True
|
|
||||||
continue
|
|
||||||
if line.strip().startswith('==='): break
|
|
||||||
if data_started:
|
|
||||||
columns = re.split(r'\s{2,}', line.strip())
|
|
||||||
if len(columns) >= 8:
|
|
||||||
cm_info = {
|
|
||||||
"downstream": columns[0], "upstream": columns[1],
|
|
||||||
"bond_cap": columns[2], "ofdm_cap": columns[3],
|
|
||||||
"mac_address": columns[4], "ip_address": columns[5],
|
|
||||||
"num_cpe": int(columns[6]), "state": columns[7]
|
|
||||||
}
|
|
||||||
parsed_data.append(cm_info)
|
|
||||||
return parsed_data
|
|
||||||
|
|
||||||
@router.get("/cable-modems")
|
async def execute_single_command(host, username, password, command, timeout=15.0):
|
||||||
async def get_cable_modems():
|
"""
|
||||||
|
執行單一查詢指令 (適用於 90% 的標準 show 指令)
|
||||||
|
自動防呆:確保指令帶有取消分頁的後綴,防止 Event Loop 卡死
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
net_connect = ConnectHandler(**CMTS_DEVICE)
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
raw_output = net_connect.send_command("show cable modem")
|
conn = await asyncio.wait_for(
|
||||||
net_connect.disconnect()
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
structured_data = parse_cm_output(raw_output)
|
timeout=10.0
|
||||||
return {"status": "success", "total_count": len(structured_data), "data": structured_data}
|
)
|
||||||
except Exception as e:
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
|
async with conn:
|
||||||
|
# 確保指令包含取消分頁的參數
|
||||||
|
if "| nomore" not in command.lower():
|
||||||
|
command += " | nomore"
|
||||||
|
|
||||||
@router.get("/configuration")
|
result = await conn.run(command, check=False, timeout=timeout)
|
||||||
async def get_configuration():
|
return result.stdout or ""
|
||||||
try:
|
|
||||||
net_connect = ConnectHandler(**CMTS_DEVICE)
|
except asyncssh.Error as e:
|
||||||
net_connect.send_command_timing("config")
|
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
|
||||||
raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120)
|
except asyncio.TimeoutError:
|
||||||
net_connect.send_command_timing("exit")
|
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
|
||||||
net_connect.disconnect()
|
|
||||||
return {"status": "success", "data": raw_output}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}")
|
raise Exception(f"SSH Unexpected Error: {str(e)}")
|
||||||
|
|
||||||
|
async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
|
||||||
|
"""
|
||||||
|
執行互動式指令 (適用於需要進入 config 模式,或使用 '?' 觸發補全的指令)
|
||||||
|
動態處理終端機的 --More-- 提示
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
|
||||||
|
|
||||||
|
async def read_until_quiet(wait_time):
|
||||||
|
output = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=wait_time)
|
||||||
|
if not chunk: break
|
||||||
|
output += chunk
|
||||||
|
# 動態處理分頁符號
|
||||||
|
if "--More--" in chunk or "More" in chunk:
|
||||||
|
process.stdin.write(" ")
|
||||||
|
await process.stdin.drain()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
# 1. 清除登入 MOTD
|
||||||
|
await read_until_quiet(1.5)
|
||||||
|
|
||||||
|
# 2. 執行取消分頁指令 (雙重保險)
|
||||||
|
process.stdin.write("terminal length 0\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
await read_until_quiet(0.5)
|
||||||
|
|
||||||
|
# 3. 執行目標指令
|
||||||
|
final_output = ""
|
||||||
|
for cmd in commands:
|
||||||
|
process.stdin.write(cmd)
|
||||||
|
await process.stdin.drain()
|
||||||
|
final_output += await read_until_quiet(timeout)
|
||||||
|
|
||||||
|
# 4. 退出 session
|
||||||
|
process.stdin.write("exit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
|
||||||
|
# 清理 ANSI 控制碼與退格鍵雜訊
|
||||||
|
clean_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', final_output)
|
||||||
|
return clean_output
|
||||||
|
|
||||||
|
except asyncssh.Error as e:
|
||||||
|
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"SSH Unexpected Error: {str(e)}")
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🚀 API 路由
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
@router.get("/cmts-query")
|
@router.get("/cmts-query")
|
||||||
async def get_cmts_query(
|
async def get_cmts_query(
|
||||||
|
|
@ -60,8 +111,6 @@ async def get_cmts_query(
|
||||||
password: str = Query(...)
|
password: str = Query(...)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
device = CMTS_DEVICE.copy()
|
|
||||||
device.update({'host': host, 'username': username, 'password': password})
|
|
||||||
target_str = f" {target.strip()}" if target.strip() else ""
|
target_str = f" {target.strip()}" if target.strip() else ""
|
||||||
|
|
||||||
commands = {
|
commands = {
|
||||||
|
|
@ -105,9 +154,7 @@ async def get_cmts_query(
|
||||||
raise ValueError(f"未知的查詢類型: {query_type}")
|
raise ValueError(f"未知的查詢類型: {query_type}")
|
||||||
|
|
||||||
cli_command = commands[query_type] + " | nomore"
|
cli_command = commands[query_type] + " | nomore"
|
||||||
net_connect = ConnectHandler(**device)
|
raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
|
||||||
raw_output = net_connect.send_command(cli_command, read_timeout=15)
|
|
||||||
net_connect.disconnect()
|
|
||||||
|
|
||||||
return {"status": "success", "command": cli_command, "data": raw_output}
|
return {"status": "success", "command": cli_command, "data": raw_output}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -116,13 +163,8 @@ async def get_cmts_query(
|
||||||
@router.get("/cmts-mac-domain-config")
|
@router.get("/cmts-mac-domain-config")
|
||||||
async def get_mac_domain_config(target: str, host: str, username: str, password: str):
|
async def get_mac_domain_config(target: str, host: str, username: str, password: str):
|
||||||
try:
|
try:
|
||||||
device = CMTS_DEVICE.copy()
|
|
||||||
device.update({'host': host, 'username': username, 'password': password})
|
|
||||||
|
|
||||||
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
|
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
|
||||||
net_connect = ConnectHandler(**device)
|
raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
|
||||||
raw_output = net_connect.send_command(cli_command, read_timeout=15)
|
|
||||||
net_connect.disconnect()
|
|
||||||
|
|
||||||
# 💡 正名工程:更新字典的 Key 為一致性的命名
|
# 💡 正名工程:更新字典的 Key 為一致性的命名
|
||||||
config = {
|
config = {
|
||||||
|
|
@ -186,17 +228,80 @@ async def get_cmts_mac_domain_list(
|
||||||
password: str = Query(...)
|
password: str = Query(...)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
device = CMTS_DEVICE.copy()
|
# 這裡使用 '?' 觸發補全,必須使用互動式引擎
|
||||||
device.update({'host': host, 'username': username, 'password': password})
|
commands = ["show running-config cable mac-domain ?"]
|
||||||
net_connect = ConnectHandler(**device)
|
raw_output = await execute_interactive_command(host, username, password, commands, timeout=5.0)
|
||||||
|
|
||||||
raw_output = net_connect.send_command_timing("show running-config cable mac-domain ?")
|
|
||||||
net_connect.disconnect()
|
|
||||||
|
|
||||||
import re
|
|
||||||
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
|
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
|
||||||
mac_domains = list(dict.fromkeys(matches))
|
mac_domains = list(dict.fromkeys(matches))
|
||||||
|
|
||||||
return {"status": "success", "data": mac_domains}
|
return {"status": "success", "data": mac_domains}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@router.get("/cmts-version")
|
||||||
|
async def get_cmts_version(
|
||||||
|
host: str = Query(...),
|
||||||
|
username: str = Query(...),
|
||||||
|
password: str = Query(...)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0)
|
||||||
|
|
||||||
|
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
|
||||||
|
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
return {"status": "success", "version": match.group(1)}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": "無法解析版本號", "raw_output": raw_output}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
|
||||||
|
|
||||||
|
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
|
||||||
|
async def list_cms(host: str, username: str, password: str):
|
||||||
|
try:
|
||||||
|
raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
|
||||||
|
|
||||||
|
if not raw_output:
|
||||||
|
return {"cms": []}
|
||||||
|
|
||||||
|
# 嚴謹的 Regex:匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
|
||||||
|
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
|
||||||
|
macs = []
|
||||||
|
|
||||||
|
for line in raw_output.splitlines():
|
||||||
|
match = mac_pattern.search(line)
|
||||||
|
if match:
|
||||||
|
macs.append(match.group(1).lower())
|
||||||
|
|
||||||
|
# 去重複並排序
|
||||||
|
return {"cms": sorted(list(set(macs)))}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
|
||||||
|
return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列
|
||||||
|
|
||||||
|
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
|
||||||
|
async def list_rpds(host: str, username: str, password: str):
|
||||||
|
try:
|
||||||
|
raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
|
||||||
|
|
||||||
|
if not raw_output:
|
||||||
|
return {"rpds": []}
|
||||||
|
|
||||||
|
# 嚴謹的 Regex:匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
|
||||||
|
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
|
||||||
|
rpds = []
|
||||||
|
|
||||||
|
for line in raw_output.splitlines():
|
||||||
|
match = vcvs_pattern.search(line)
|
||||||
|
if match:
|
||||||
|
rpds.append(match.group(1))
|
||||||
|
|
||||||
|
return {"rpds": sorted(list(set(rpds)))}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
|
||||||
|
return {"rpds": []}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import asyncssh
|
import asyncssh
|
||||||
import traceback
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("app.ssh")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.websocket("/ws/terminal")
|
@router.websocket("/ws/terminal")
|
||||||
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
|
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
conn = None
|
conn = None
|
||||||
|
process = None
|
||||||
|
ws_task = None
|
||||||
|
ssh_task = None
|
||||||
try:
|
try:
|
||||||
# 建立連線
|
# 建立連線
|
||||||
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
|
||||||
# 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height)
|
# 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height)
|
||||||
process = await conn.create_process(
|
process = await conn.create_process(
|
||||||
|
|
@ -25,14 +32,15 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
while True:
|
while True:
|
||||||
data_bytes = await process.stdout.read(8192)
|
data_bytes = await process.stdout.read(8192)
|
||||||
if not data_bytes:
|
if not data_bytes:
|
||||||
print("[DEBUG] 設備端主動關閉了 stdout 通道")
|
logger.debug("設備端主動關閉了 stdout 通道")
|
||||||
break
|
break
|
||||||
|
|
||||||
safe_text = data_bytes.decode('utf-8', errors='replace')
|
safe_text = data_bytes.decode('utf-8', errors='replace')
|
||||||
await websocket.send_text(safe_text)
|
await websocket.send_text(safe_text)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
|
logger.error(f"❌ [WS Forward Error] 讀取設備畫面時發生錯誤: {str(e)}")
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
async def forward_to_ssh():
|
async def forward_to_ssh():
|
||||||
try:
|
try:
|
||||||
|
|
@ -47,10 +55,15 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
process.stdin.write(data_bytes)
|
process.stdin.write(data_bytes)
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
# 主動拋出,讓外層捕捉以進行資源回收
|
||||||
|
# raise
|
||||||
|
# 🌟 修正:不要再 raise 拋出去了,直接 return 結束任務,讓外層自然回收
|
||||||
|
logger.debug("前端 WebSocket 正常斷開 (使用者重整或關閉網頁)")
|
||||||
|
return
|
||||||
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
|
logger.error(f"❌ [SSH Forward Error] 寫入指令到設備時發生錯誤: {str(e)}")
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
ws_task = asyncio.create_task(forward_to_ws())
|
ws_task = asyncio.create_task(forward_to_ws())
|
||||||
ssh_task = asyncio.create_task(forward_to_ssh())
|
ssh_task = asyncio.create_task(forward_to_ssh())
|
||||||
|
|
@ -63,18 +76,34 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("WebSocket 正常斷線,正在終止背景任務...")
|
||||||
|
if ws_task and not ws_task.done():
|
||||||
|
ws_task.cancel()
|
||||||
|
if ssh_task and not ssh_task.done():
|
||||||
|
ssh_task.cancel()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
|
err_str = str(e)
|
||||||
|
error_msg = f"\r\n\x1b[31mSSH Connection Error: {err_str}\x1b[0m\r\n"
|
||||||
try:
|
try:
|
||||||
|
# 先把錯誤印在終端機畫面上
|
||||||
await websocket.send_text(error_msg)
|
await websocket.send_text(error_msg)
|
||||||
|
|
||||||
|
# 🌟 關鍵修復:使用自訂斷線碼 4001,並附上錯誤原因 (WebSocket 規範 reason 最長 123 bytes)
|
||||||
|
safe_reason = err_str[:120] if err_str else "Authentication or Connection Failed"
|
||||||
|
await websocket.close(code=4001, reason=safe_reason)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
|
logger.error(f"❌ [Connection Error] 建立連線或執行過程中發生錯誤: {str(e)}")
|
||||||
traceback.print_exc()
|
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
|
||||||
finally:
|
finally:
|
||||||
|
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
||||||
|
if process:
|
||||||
|
process.close()
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
try:
|
try:
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
89
shared.py
89
shared.py
|
|
@ -11,12 +11,12 @@ 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
|
||||||
|
|
@ -27,44 +27,28 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
|
將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
|
||||||
"""
|
"""
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 🌟 階段 0:預處理 (修復終端機 200 字元自動折行問題)
|
# 🌟 階段 0:預處理 (清理雜訊,移除危險的自動黏合)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
cli_text = cli_text.replace('\r\n', '\n').replace('\r', '\n')
|
||||||
|
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
||||||
|
|
||||||
raw_lines = cli_text.split('\n')
|
raw_lines = cli_text.split('\n')
|
||||||
fixed_lines = []
|
fixed_lines = []
|
||||||
|
|
||||||
# 定義常見的 Root 指令關鍵字,避免把正常的指令誤判為斷行
|
|
||||||
root_keywords = (
|
|
||||||
'alias', 'ssh', 'hostname', 'logging', 'cable', 'ipdr',
|
|
||||||
'snmp-server', 'aaa', 'radius-server', 'privilege', 'cli', 'packetcable',
|
|
||||||
'system', 'network', 'clock', 'no'
|
|
||||||
)
|
|
||||||
|
|
||||||
for line in raw_lines:
|
for line in raw_lines:
|
||||||
line = line.replace('\r', '')
|
line = ansi_escape.sub('', line)
|
||||||
|
line = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]', '', line)
|
||||||
|
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 💡 判斷是否為被截斷的行:
|
# 💡 乾淨俐落:直接加入陣列,不再做危險的字串長度黏合判斷
|
||||||
# 1. 前一行長度非常長 (大於 190 字元,根據您的截圖,極限是 199)
|
|
||||||
# 2. 當前行「沒有縮排」(終端機自動折行會把字元推到最左邊第 0 格)
|
|
||||||
# 3. 當前行不是註解 '!'
|
|
||||||
if fixed_lines and len(fixed_lines[-1]) > 190 and not line.startswith(' ') and not line.startswith('!'):
|
|
||||||
# 取得當前行的第一個單字
|
|
||||||
first_word = line.strip().split()[0] if line.strip() else ""
|
|
||||||
|
|
||||||
# 確保它不是一個正常的 Root 指令
|
|
||||||
if first_word not in root_keywords:
|
|
||||||
# ✅ 觸發黏合邏輯:將這行直接接在上一行後面
|
|
||||||
fixed_lines[-1] = fixed_lines[-1] + line
|
|
||||||
continue
|
|
||||||
|
|
||||||
fixed_lines.append(line)
|
fixed_lines.append(line)
|
||||||
|
|
||||||
# 統一去除右側多餘空白,準備進入階段 1
|
|
||||||
lines = [line.rstrip() for line in fixed_lines if line.strip()]
|
lines = [line.rstrip() for line in fixed_lines if line.strip()]
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
|
# 🌟 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
def build_raw_tree(start_idx, current_indent):
|
def build_raw_tree(start_idx, current_indent):
|
||||||
nodes = []
|
nodes = []
|
||||||
|
|
@ -74,24 +58,20 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
indent = len(line) - len(line.lstrip())
|
indent = len(line) - len(line.lstrip())
|
||||||
|
|
||||||
# 🌟 嚴格判斷 1:如果遇到縮排退回 (包含縮排退回的 !),代表當前深度的區塊已結束
|
if indent < current_indent and stripped != '!':
|
||||||
if indent < current_indent:
|
|
||||||
return nodes, i
|
return nodes, i
|
||||||
|
|
||||||
# 🌟 嚴格判斷 2:處理同層級的 !
|
|
||||||
if stripped == '!':
|
if stripped == '!':
|
||||||
# 能走到這裡,代表 indent >= current_indent
|
|
||||||
# 這是對應當前層級的結束符號或分隔符,我們將其消耗掉並繼續,絕對不提早 return
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 探測是否有子節點
|
|
||||||
has_children = False
|
has_children = False
|
||||||
if i + 1 < len(lines):
|
next_i = i + 1
|
||||||
next_line = lines[i+1]
|
while next_i < len(lines) and lines[next_i].strip() == '!':
|
||||||
next_stripped = next_line.strip()
|
next_i += 1
|
||||||
# 只有當下一行不是 ! 且縮排大於當前行時,才判定為有子節點
|
|
||||||
if next_stripped != '!':
|
if next_i < len(lines):
|
||||||
|
next_line = lines[next_i]
|
||||||
next_indent = len(next_line) - len(next_line.lstrip())
|
next_indent = len(next_line) - len(next_line.lstrip())
|
||||||
if next_indent > indent:
|
if next_indent > indent:
|
||||||
has_children = True
|
has_children = True
|
||||||
|
|
@ -108,16 +88,14 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
raw_tree, _ = build_raw_tree(0, 0)
|
raw_tree, _ = build_raw_tree(0, 0)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 階段 2:邏輯群組化與字典合併
|
# 🌟 階段 2:邏輯群組化與字典合併 (保留嚴格型別防護)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
def deep_merge(dict1, dict2):
|
def deep_merge(dict1, dict2):
|
||||||
"""深度合併兩個字典 (解決同名資料夾與指令群組的融合)"""
|
|
||||||
for k, v in dict2.items():
|
for k, v in dict2.items():
|
||||||
if k in dict1:
|
if k in dict1:
|
||||||
if isinstance(dict1[k], dict) and isinstance(v, dict):
|
if isinstance(dict1[k], dict) and isinstance(v, dict):
|
||||||
deep_merge(dict1[k], v)
|
deep_merge(dict1[k], v)
|
||||||
else:
|
else:
|
||||||
# 發生碰撞時的安全機制
|
|
||||||
idx = 1
|
idx = 1
|
||||||
while f"{k} [{idx}]" in dict1:
|
while f"{k} [{idx}]" in dict1:
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
@ -126,7 +104,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
dict1[k] = v
|
dict1[k] = v
|
||||||
|
|
||||||
def nest_folder_key(key_string, value_dict, target_dict):
|
def nest_folder_key(key_string, value_dict, target_dict):
|
||||||
"""將 'cable mac-domain 13' 拆解為巢狀字典"""
|
|
||||||
parts = key_string.split()
|
parts = key_string.split()
|
||||||
current = target_dict
|
current = target_dict
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
|
|
@ -137,14 +114,20 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
if isinstance(current[part], dict) and isinstance(value_dict, dict):
|
if isinstance(current[part], dict) and isinstance(value_dict, dict):
|
||||||
deep_merge(current[part], value_dict)
|
deep_merge(current[part], value_dict)
|
||||||
else:
|
else:
|
||||||
current[f"{part} (資料夾)"] = value_dict
|
idx = 1
|
||||||
|
while f"{part} (衝突 {idx})" in current:
|
||||||
|
idx += 1
|
||||||
|
current[f"{part} (衝突 {idx})"] = value_dict
|
||||||
else:
|
else:
|
||||||
if part not in current or not isinstance(current[part], dict):
|
if part not in current or not isinstance(current[part], dict):
|
||||||
|
if part in current and not isinstance(current[part], dict):
|
||||||
|
old_val = current[part]
|
||||||
|
current[part] = {"[0]": old_val}
|
||||||
|
else:
|
||||||
current[part] = {}
|
current[part] = {}
|
||||||
current = current[part]
|
current = current[part]
|
||||||
|
|
||||||
def group_leaves(leaf_strings):
|
def group_leaves(leaf_strings):
|
||||||
"""將單行指令依據共同前綴進行遞迴群組化"""
|
|
||||||
grouped = {}
|
grouped = {}
|
||||||
first_word_map = defaultdict(list)
|
first_word_map = defaultdict(list)
|
||||||
|
|
||||||
|
|
@ -164,8 +147,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
group_dict = {}
|
group_dict = {}
|
||||||
if valid_remainders:
|
if valid_remainders:
|
||||||
sub_grouped = group_leaves(valid_remainders)
|
sub_grouped = group_leaves(valid_remainders)
|
||||||
|
|
||||||
# 判斷是否全為純陣列值 (例如 IP 列表)
|
|
||||||
is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
|
is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
|
||||||
|
|
||||||
if is_all_empty_vals:
|
if is_all_empty_vals:
|
||||||
|
|
@ -182,7 +163,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
else:
|
else:
|
||||||
group_dict[k] = v
|
group_dict[k] = v
|
||||||
|
|
||||||
# 補回完全沒有後續參數的指令
|
|
||||||
for _ in range(empty_count):
|
for _ in range(empty_count):
|
||||||
idx = 0
|
idx = 0
|
||||||
while f"[{idx}]" in group_dict:
|
while f"[{idx}]" in group_dict:
|
||||||
|
|
@ -195,12 +175,10 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
|
|
||||||
def fold_nodes(nodes):
|
def fold_nodes(nodes):
|
||||||
result = {}
|
result = {}
|
||||||
# 1. 處理資料夾 (Folders)
|
|
||||||
for node in [n for n in nodes if n["type"] == "folder"]:
|
for node in [n for n in nodes if n["type"] == "folder"]:
|
||||||
folded_children = fold_nodes(node["children"])
|
folded_children = fold_nodes(node["children"])
|
||||||
nest_folder_key(node["content"], folded_children, result)
|
nest_folder_key(node["content"], folded_children, result)
|
||||||
|
|
||||||
# 2. 處理單行指令 (Leaves)
|
|
||||||
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
|
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
|
||||||
if leaves:
|
if leaves:
|
||||||
leaf_dict = group_leaves(leaves)
|
leaf_dict = group_leaves(leaves)
|
||||||
|
|
@ -279,5 +257,10 @@ def save_settings(settings_data):
|
||||||
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(settings_data, f, indent=4, ensure_ascii=False)
|
json.dump(settings_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
# 全域非同步鎖:確保同一時間只有一人能執行設定寫入
|
# 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖 (安全動態獲取版)
|
||||||
cmts_config_lock = asyncio.Lock()
|
_cmts_config_locks = {}
|
||||||
|
|
||||||
|
def get_cmts_lock(host: str) -> asyncio.Lock:
|
||||||
|
if host not in _cmts_config_locks:
|
||||||
|
_cmts_config_locks[host] = asyncio.Lock()
|
||||||
|
return _cmts_config_locks[host]
|
||||||
154
static/api.js
154
static/api.js
|
|
@ -3,56 +3,66 @@
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. 鎖定管理 (Lock Management)
|
// 1. 鎖定管理 (Lock Management)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export async function apiAcquireLock(path, userId, username) {
|
export async function apiAcquireLock(path, userId, username, host) {
|
||||||
const response = await fetch('/api/v1/locks/acquire', {
|
const res = await fetch('/api/v1/locks/acquire', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path, user_id: userId, username })
|
body: JSON.stringify({ path, user_id: userId, username, host })
|
||||||
});
|
});
|
||||||
return { status: response.status, data: await response.json() };
|
const data = await res.json();
|
||||||
|
return { status: res.status, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiReleaseLock(path, userId) {
|
export async function apiReleaseLock(path, userId, host) {
|
||||||
return fetch('/api/v1/locks/release', {
|
return fetch('/api/v1/locks/release', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path, user_id: userId })
|
body: JSON.stringify({ path, user_id: userId, host })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiSendHeartbeat(path, userId) {
|
export async function apiSendHeartbeat(path, userId, host) {
|
||||||
return fetch('/api/v1/locks/heartbeat', {
|
return fetch('/api/v1/locks/heartbeat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path, user_id: userId })
|
body: JSON.stringify({ path, user_id: userId, host })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiGetLockStatus(host) {
|
||||||
|
// 帶入 host 參數,實現 IP 隔離查詢
|
||||||
|
const url = host ? `/api/v1/locks/status?host=${encodeURIComponent(host)}` : '/api/v1/locks/status';
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 2. 樹狀圖與選項快取 (Tree & Options)
|
// 2. 樹狀圖與選項快取 (Tree & Options)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export async function apiGetFullConfig(host, username, password, skipFilter = false) {
|
export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
|
||||||
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
// 🌟 將 config_type 加入 URL 參數中
|
||||||
|
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`;
|
||||||
|
|
||||||
if (skipFilter) url += '&skip_filter=true';
|
if (skipFilter) url += '&skip_filter=true';
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGetLeafOptions() {
|
export async function apiGetLeafOptions(host) {
|
||||||
const response = await fetch('/api/v1/cmts-leaf-options');
|
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}`);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiSyncLeafOptions(paths) {
|
export async function apiSyncLeafOptions(host, paths) {
|
||||||
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({ leaf_paths: paths })
|
body: JSON.stringify({ host, leaf_paths: paths })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiClearCache(paths) {
|
export async function apiClearCache(host, paths) {
|
||||||
const response = await fetch('/api/v1/clear_cache', {
|
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(paths)
|
body: JSON.stringify(paths)
|
||||||
|
|
@ -61,6 +71,22 @@ export async function apiClearCache(paths) {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiGetCmtsVersion(host, username, password) {
|
||||||
|
const url = `/api/v1/cmts-version?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetScanStatus(host) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.is_scanning;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 3. 指令生成與執行 (CLI Generation & Execution)
|
// 3. 指令生成與執行 (CLI Generation & Execution)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -85,16 +111,18 @@ export async function apiExecuteConfig(script, host, username, password) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 4. 系統設定與過濾器 (System Settings)
|
// 4. 系統設定與過濾器 (System Settings)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export async function apiGetTreeFilters() {
|
// 🌟 加上 configType 參數
|
||||||
const response = await fetch('/api/v1/settings/tree-filters');
|
export async function apiGetTreeFilters(configType = 'running') {
|
||||||
|
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiSaveTreeFilters(hiddenKeys) {
|
// 🌟 加上 configType 參數
|
||||||
const response = await fetch('/api/v1/settings/tree-filters', {
|
export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
|
||||||
|
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ hidden_keys: hiddenKeys })
|
body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
|
||||||
});
|
});
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
@ -120,39 +148,93 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 新增:動態抓取設備清單 API
|
||||||
|
export async function apiListCms(host, username, password) {
|
||||||
|
const url = `/api/v1/list-cms?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiListRpds(host, username, password) {
|
||||||
|
const url = `/api/v1/list-rpds?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
|
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export async function apiSyncLeafOptionsStream(paths, onProgress) {
|
export async function apiSyncLeafOptionsStream(host, paths, onProgress) {
|
||||||
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({ leaf_paths: paths })
|
body: JSON.stringify({ host, leaf_paths: paths })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
|
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
|
||||||
|
|
||||||
// 🌟 核心修改:讀取串流資料
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break; // 伺服器斷開連線 (任務完成)
|
if (done) break;
|
||||||
|
|
||||||
// 解碼二進位資料為文字
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
// 因為一次可能收到多行 JSON,我們用換行符號切開
|
|
||||||
const lines = chunk.split("\n").filter(line => line.trim() !== "");
|
const lines = chunk.split("\n").filter(line => line.trim() !== "");
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line);
|
const data = JSON.parse(line);
|
||||||
onProgress(data); // 將解析後的資料傳給 UI 介面
|
onProgress(data);
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
console.error("JSON 解析錯誤:", e, line);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiGetCmDiagnostics(host, username, password, mac) {
|
||||||
|
const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || "伺服器發生錯誤");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetLogLevels() {
|
||||||
|
const response = await fetch('/api/v1/settings/logs');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiSetLogLevel(module, level) {
|
||||||
|
const response = await fetch('/api/v1/settings/logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ module, level })
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 7. 授權與驗證 API (Auth)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiVerifyGodMode(password) {
|
||||||
|
const response = await fetch('/api/v1/auth/god-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || "驗證失敗");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 📌 釘選防護 API (Pinning)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiToggleBackupPin(backupId) {
|
||||||
|
const response = await fetch(`/api/v1/backups/${backupId}/toggle-pin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
2075
static/app.js
2075
static/app.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -18,34 +18,45 @@ export async function loadMacDomainList() {
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
if (!connInfo) return;
|
if (!connInfo) return;
|
||||||
|
|
||||||
const mdSelect = document.getElementById('cfgMacDomain');
|
const mdInput = document.getElementById('cfgMacDomain');
|
||||||
|
const mdDatalist = document.getElementById('mac-domain-list');
|
||||||
const statusSpan = document.getElementById('fetchStatus');
|
const statusSpan = document.getElementById('fetchStatus');
|
||||||
|
|
||||||
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
|
// 載入前:鎖定輸入框並更改提示
|
||||||
|
mdInput.disabled = true;
|
||||||
|
mdInput.value = '';
|
||||||
|
mdInput.placeholder = '⏳ 查詢設備中...';
|
||||||
|
if (mdDatalist) mdDatalist.innerHTML = '';
|
||||||
|
|
||||||
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
|
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
|
||||||
statusSpan.style.color = "#f39c12";
|
statusSpan.style.color = "#f39c12";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
|
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
|
||||||
if (result.status === 'success' && result.data.length > 0) {
|
if (result.status === 'success' && result.data.length > 0) {
|
||||||
mdSelect.innerHTML = '';
|
// 載入成功:將資料塞入 datalist
|
||||||
|
if (mdDatalist) {
|
||||||
result.data.forEach(md => {
|
result.data.forEach(md => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = md;
|
option.value = md;
|
||||||
option.textContent = md;
|
mdDatalist.appendChild(option);
|
||||||
mdSelect.appendChild(option);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
|
||||||
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
|
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
|
||||||
statusSpan.style.color = "#27ae60";
|
statusSpan.style.color = "#27ae60";
|
||||||
} else {
|
} else {
|
||||||
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>';
|
mdInput.placeholder = '❌ 找不到資料';
|
||||||
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
|
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
|
||||||
statusSpan.style.color = "#e74c3c";
|
statusSpan.style.color = "#e74c3c";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>';
|
mdInput.placeholder = '❌ 連線錯誤';
|
||||||
statusSpan.textContent = "連線失敗:" + error;
|
statusSpan.textContent = "連線失敗:" + error;
|
||||||
statusSpan.style.color = "#e74c3c";
|
statusSpan.style.color = "#e74c3c";
|
||||||
|
} finally {
|
||||||
|
// 載入結束:解鎖輸入框
|
||||||
|
mdInput.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,6 +274,15 @@ export async function executeBondingConfig() {
|
||||||
const outputEl = document.getElementById('modalOutput');
|
const outputEl = document.getElementById('modalOutput');
|
||||||
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`;
|
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`;
|
||||||
|
|
||||||
|
// 鎖定關閉按鈕 (Execution Lock)
|
||||||
|
const closeBtn = document.getElementById('btn-modal-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.disabled = true;
|
||||||
|
closeBtn.style.opacity = '0.5';
|
||||||
|
closeBtn.style.cursor = 'not-allowed';
|
||||||
|
closeBtn.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
|
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
|
|
@ -275,5 +295,13 @@ export async function executeBondingConfig() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outputEl.style.color = "#e74c3c";
|
outputEl.style.color = "#e74c3c";
|
||||||
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
|
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
|
||||||
|
} finally {
|
||||||
|
// 恢復關閉按鈕
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
closeBtn.style.opacity = '1';
|
||||||
|
closeBtn.style.cursor = 'pointer';
|
||||||
|
closeBtn.style.pointerEvents = 'auto';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 622 B |
244
static/style.css
244
static/style.css
|
|
@ -1,58 +1,166 @@
|
||||||
/* --- static/style.css --- */
|
/* --- static/style.css --- */
|
||||||
|
|
||||||
/* 1. 基礎滿版佈局 */
|
/* =========================================
|
||||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; box-sizing: border-box; background-color: #f5f7fa; height: 100vh; display: flex; flex-direction: column; }
|
全域按鈕色彩系統 (Modern Enterprise Vibe)
|
||||||
h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 15px; }
|
========================================= */
|
||||||
|
:root {
|
||||||
|
/* 基礎設定 */
|
||||||
|
--btn-radius: 6px;
|
||||||
|
--btn-transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
/* 色彩定義 */
|
||||||
|
--color-emerald: #059669; /* 連線 */
|
||||||
|
--color-emerald-hover: #047857;
|
||||||
|
|
||||||
|
--color-slate: #64748B; /* 斷開/中性/取消 */
|
||||||
|
--color-slate-hover: #475569;
|
||||||
|
|
||||||
|
--color-blue: #2563EB; /* 載入/查詢 */
|
||||||
|
--color-blue-hover: #1D4ED8;
|
||||||
|
|
||||||
|
--color-indigo: #4F46E5; /* 儲存/套用 */
|
||||||
|
--color-indigo-hover: #4338CA;
|
||||||
|
|
||||||
|
--color-violet: #7C3AED; /* 掃描/自動化 */
|
||||||
|
--color-violet-hover: #6D28D9;
|
||||||
|
|
||||||
|
--color-rose: #DC2626; /* 清除/刪除 */
|
||||||
|
--color-rose-hover: #B91C1C;
|
||||||
|
|
||||||
|
--color-disabled-bg: #E2E8F0;
|
||||||
|
--color-disabled-text: #94A3B8;
|
||||||
|
--color-busy-bg: #F59E0B; /* 忙碌中(橘黃) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基礎按鈕共用樣式 */
|
||||||
|
.btn-modern {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--btn-radius);
|
||||||
|
color: #FFFFFF;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--btn-transition);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 禁用狀態 (所有按鈕共用) */
|
||||||
|
.btn-modern:disabled {
|
||||||
|
background-color: var(--color-disabled-bg) !important;
|
||||||
|
color: var(--color-disabled-text) !important;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 各別語意 Class */
|
||||||
|
.btn-connect { background-color: var(--color-emerald); }
|
||||||
|
.btn-connect:hover:not(:disabled) { background-color: var(--color-emerald-hover); }
|
||||||
|
|
||||||
|
.btn-disconnect { background-color: var(--color-slate); }
|
||||||
|
.btn-disconnect:hover:not(:disabled) { background-color: var(--color-slate-hover); }
|
||||||
|
|
||||||
|
.btn-load { background-color: var(--color-blue); }
|
||||||
|
.btn-load:hover:not(:disabled) { background-color: var(--color-blue-hover); }
|
||||||
|
|
||||||
|
.btn-save { background-color: var(--color-indigo); }
|
||||||
|
.btn-save:hover:not(:disabled) { background-color: var(--color-indigo-hover); }
|
||||||
|
|
||||||
|
.btn-scan { background-color: var(--color-violet); }
|
||||||
|
.btn-scan:hover:not(:disabled) { background-color: var(--color-violet-hover); }
|
||||||
|
|
||||||
|
.btn-clear { background-color: var(--color-rose); }
|
||||||
|
.btn-clear:hover:not(:disabled) { background-color: var(--color-rose-hover); }
|
||||||
|
|
||||||
|
/* 特殊忙碌狀態 (給掃描或載入時使用) */
|
||||||
|
.btn-modern.is-busy:disabled {
|
||||||
|
background-color: var(--color-busy-bg) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. 基礎滿版佈局 (全域 80% 縮放感) */
|
||||||
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 15px 20px; box-sizing: border-box; background-color: #f5f7fa; height: 100vh; display: flex; flex-direction: column; font-size: 14px; }
|
||||||
|
h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-size: 24px; }
|
||||||
|
|
||||||
/* 2. 全域設備設定區塊 */
|
/* 2. 全域設備設定區塊 */
|
||||||
.global-settings { background-color: #e8ecef; padding: 12px 20px; border-radius: 6px; border: 1px solid #d1d8dd; margin-bottom: 15px; display: flex; gap: 10px; align-items: center; flex-shrink: 0; flex-wrap: wrap; }
|
.global-settings { background-color: #e8ecef; padding: 8px 15px; border-radius: 6px; border: 1px solid #d1d8dd; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-shrink: 0; flex-wrap: wrap; font-size: 13px; }
|
||||||
|
|
||||||
/* 3. 頁籤樣式 */
|
/* 3. 頁籤樣式 (🌟 修復 Hover 狀態,確保字體清晰) */
|
||||||
.tabs { display: flex; margin-bottom: 15px; border-bottom: 2px solid #ddd; flex-shrink: 0; }
|
.tabs { display: flex; margin-bottom: 12px; border-bottom: 2px solid #ddd; flex-shrink: 0; }
|
||||||
.tab-btn { padding: 10px 20px; cursor: pointer; background: none; border: none; font-size: 16px; font-weight: bold; color: #7f8c8d; }
|
.tab-btn { padding: 8px 18px; cursor: pointer; background: transparent; border: none; font-size: 15px; font-weight: bold; color: #7f8c8d; transition: all 0.2s ease; border-radius: 6px 6px 0 0; }
|
||||||
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; }
|
.tab-btn:hover { color: #2c3e50; background-color: #e2e8f0; } /* 滑鼠移入時:深色字+淺灰底 */
|
||||||
|
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; background-color: transparent; }
|
||||||
|
|
||||||
/* 4. 內容區塊 */
|
/* 4. 內容區塊 */
|
||||||
.tab-content { display: none; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; }
|
.tab-content { display: none; background: white; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; box-sizing: border-box; max-width: 100%; overflow: hidden; }
|
||||||
.tab-content.active { display: flex; flex-direction: column; }
|
.tab-content.active { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
/* 5. 控制列樣式 */
|
/* 5. 控制列樣式 (微縮元件) */
|
||||||
.control-group { margin-bottom: 15px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
|
.control-group { margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
|
||||||
select, input[type="text"], input[type="password"] { padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
select, input[type="text"], input[type="password"] { padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
|
||||||
button { padding: 8px 16px; background-color: #2980b9; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; transition: background-color 0.2s; }
|
button { padding: 6px 14px; background-color: #2980b9; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 13px; transition: background-color 0.2s; }
|
||||||
button:hover { background-color: #3498db; }
|
button:hover { background-color: #3498db; }
|
||||||
|
|
||||||
/* 6. 終端機與輸出區塊 */
|
/* 6. 終端機與輸出區塊 */
|
||||||
#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; }
|
#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; min-height: 0; overflow: hidden; position: relative; }
|
||||||
.xterm { padding: 10px; }
|
.xterm { padding: 10px; height: 100%; box-sizing: border-box; }
|
||||||
|
|
||||||
/* 7. 專業級表單排版 (全版面 + 靠左對齊) */
|
/* 7. 專業級表單排版 (🌟 縮小間距,解決空白過多) */
|
||||||
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 20px; }
|
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 12px; }
|
||||||
.task-form { display: none; background: #ffffff; padding: 25px; border-radius: 8px; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px rgba(0,0,0,0.02); }
|
.task-form { display: none; background: #ffffff; padding: 18px 20px; border-radius: 8px; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px rgba(0,0,0,0.02); }
|
||||||
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
|
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
|
||||||
|
|
||||||
/* 網格系統:調整為更緊湊的高空間利用率排版 */
|
/* 網格系統:高空間利用率 */
|
||||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 15px; margin-bottom: 20px; justify-content: start; }
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
|
||||||
.form-row { display: flex; flex-direction: column; gap: 6px; }
|
.form-row { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.form-row label { font-size: 13px; font-weight: bold; color: #34495e; }
|
.form-row label { font-size: 12px; font-weight: bold; color: #34495e; }
|
||||||
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px; }
|
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
|
||||||
|
.form-row select { padding-right: 30px; text-overflow: ellipsis; } /* 🌟 確保下拉箭頭有足夠空間,不會遮擋文字 */
|
||||||
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }
|
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }
|
||||||
|
|
||||||
/* 表單底部操作區 */
|
/* 表單底部操作區 */
|
||||||
.form-actions { display: flex; justify-content: flex-start; align-items: center; gap: 20px; padding-top: 15px; border-top: 1px solid #ecf0f1; }
|
.form-actions { display: flex; justify-content: flex-start; align-items: center; gap: 15px; padding-top: 12px; border-top: 1px solid #ecf0f1; }
|
||||||
.form-actions button { padding: 10px 25px; font-size: 15px; min-width: 150px; }
|
.form-actions button { padding: 8px 20px; font-size: 14px; min-width: 120px; }
|
||||||
|
|
||||||
/* 8. 彈出式輸出視窗 (Modal) 樣式 */
|
/* 8. 彈出式輸出視窗 (Modal) 樣式 */
|
||||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; backdrop-filter: blur(3px); }
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); } /* 🌟 新增這行支援 Safari */
|
||||||
.modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; }
|
.modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; }
|
||||||
.modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; }
|
.modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; }
|
||||||
.modal-overlay.active .modal-container { transform: translateY(0); }
|
.modal-overlay.active .modal-container { transform: translateY(0); }
|
||||||
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
|
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
|
||||||
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
|
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
|
||||||
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
|
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
|
||||||
.modal-close:hover { color: #e74c3c; }
|
.modal-body {
|
||||||
.modal-body { flex-grow: 1; padding: 20px; overflow: auto; background-color: #1e1e1e; }
|
flex-grow: 1;
|
||||||
.readonly-terminal { margin: 0; color: #e0e0e0; font-family: 'Courier New', Courier, monospace; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
|
padding: 0 !important; /* 🌟 關鍵:移除原本 20px 的留白 */
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* 將捲軸交給內層的 terminal 處理 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 徹底移除終端機的邊框,並接管滿版空間 */
|
||||||
|
.readonly-terminal {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 15px 20px !important; /* 保留適當的文字呼吸空間,不讓字完全貼死邊緣 */
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: transparent !important; /* 🌟 關鍵:背景透明,融入 Modal */
|
||||||
|
border: none !important; /* 🌟 關鍵:拔除所有細邊框 */
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto; /* 垂直捲動 */
|
||||||
|
overflow-x: auto; /* 水平捲動 */
|
||||||
|
white-space: pre; /* 強制不折行以支援水平滑動,保護版面 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* 樹狀圖節點的容器樣式 */
|
/* 樹狀圖節點的容器樣式 */
|
||||||
.tree-node-header {
|
.tree-node-header {
|
||||||
|
|
@ -60,6 +168,7 @@ button:hover { background-color: #3498db; }
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
-webkit-user-select: none; /* 🌟 新增這行支援 Safari */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: #2c3e50; /* 保持原本的深色文字 */
|
color: #2c3e50; /* 保持原本的深色文字 */
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
|
|
@ -155,4 +264,79 @@ button:hover { background-color: #3498db; }
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 針對 SweetAlert2 彈窗內的終端機強制去框 */
|
||||||
|
.swal2-html-container pre {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 15px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
🚨 密碼輸入錯誤震動動畫 (Shake Effect)
|
||||||
|
========================================= */
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake-animation {
|
||||||
|
animation: shake 0.4s ease-in-out;
|
||||||
|
border-color: #e74c3c !important; /* 震動時外框變紅 */
|
||||||
|
background-color: #fdedec !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================
|
||||||
|
🌟 效能優化:大型 DOM 樹渲染隔離
|
||||||
|
========================================= */
|
||||||
|
.tree-folder-content {
|
||||||
|
/* 讓不在畫面內的子節點跳過渲染計算,極大提升效能 */
|
||||||
|
content-visibility: auto;
|
||||||
|
/* 給予一個預設高度,避免捲軸在捲動時瘋狂跳動 */
|
||||||
|
contain-intrinsic-size: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-children-container {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
🎙️ 系統實時日誌終端機 - 滾動條隔離優化 (Scrollbar Isolation - 修正版)
|
||||||
|
============================================================================ */
|
||||||
|
#log-terminal-container .xterm {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen {
|
||||||
|
/* 🌟 關鍵:限制文字區最大寬度,扣除右側垂直滾動條的寬度 */
|
||||||
|
max-width: calc(100% - 18px) !important;
|
||||||
|
|
||||||
|
/* 🌟 關鍵:只允許水平滾動,強制關閉垂直滾動,消除多餘的藍灰色軌道 */
|
||||||
|
overflow-x: auto !important;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 讓水平滾動條呈現精美的扁平化現代風格 */
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }
|
||||||
|
|
@ -9,18 +9,42 @@ export let isConnected = false;
|
||||||
|
|
||||||
// 3. 終端機文字上色 (內部使用,不需要 export)
|
// 3. 終端機文字上色 (內部使用,不需要 export)
|
||||||
function colorizeTerminalStream(text) {
|
function colorizeTerminalStream(text) {
|
||||||
if (text.includes('\x1b[')) return text;
|
|
||||||
let res = text;
|
let res = text;
|
||||||
res = res.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g, '\x1b[1;34m$1\x1b[0m');
|
|
||||||
res = res.replace(/\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b/g, '\x1b[1;35m$1\x1b[0m');
|
// 1. IP 地址:改為「柔和冰藍色」 (移除 1; 粗體)
|
||||||
res = res.replace(/\b(online|w-online|p-online|init\([^)]+\)|up|active|success|yes)\b/gi, '\x1b[1;32m$1\x1b[0m');
|
res = res.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g, '\x1b[38;5;111m$1\x1b[0m');
|
||||||
res = res.replace(/\b(offline|down|admin-down|error|fail|failed|shutdown|reject|invalid|no)\b/gi, '\x1b[1;31m$1\x1b[0m');
|
|
||||||
res = res.replace(/\b(Cable|GigabitEthernet|TenGigabitEthernet|Upstream|Downstream|Mac-domain|bonding-group|rpd)\b/gi, '\x1b[33m$1\x1b[0m');
|
// 2. MAC 地址:改為「柔和薰衣草紫」 (移除 1; 粗體)
|
||||||
|
res = res.replace(/\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b/g, '\x1b[38;5;140m$1\x1b[0m');
|
||||||
|
|
||||||
|
// 3. 正常狀態:維持「柔和薄荷綠」
|
||||||
|
res = res.replace(/\b([a-z]-online|online|init\([^)]+\)|up|active|success|yes)\b/gi, '\x1b[38;5;114m$1\x1b[0m');
|
||||||
|
|
||||||
|
// 4. 異常狀態:維持「柔和珊瑚紅」
|
||||||
|
res = res.replace(/\b(offline|down|admin-down|error|fail|failed|shutdown|reject|invalid|no)\b/gi, '\x1b[38;5;204m$1\x1b[0m');
|
||||||
|
|
||||||
|
// 5. 介面名稱 (Cable/RPD等):改為「柔和奶油黃」 (原本是 \x1b[33m)
|
||||||
|
res = res.replace(/\b(Cable|GigabitEthernet|TenGigabitEthernet|Upstream|Downstream|Mac-domain|bonding-group|rpd)\b/gi, '\x1b[38;5;179m$1\x1b[0m');
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
@ -41,6 +65,11 @@ export function connectWebSocket(resetCallback) {
|
||||||
|
|
||||||
if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數
|
if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數
|
||||||
|
|
||||||
|
// 🌟 [修復] 切換設備時,徹底清空終端機舊有畫面與緩衝區
|
||||||
|
if (term) {
|
||||||
|
term.clear();
|
||||||
|
}
|
||||||
|
|
||||||
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
|
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
||||||
|
|
@ -55,17 +84,36 @@ export function connectWebSocket(resetCallback) {
|
||||||
btnLoadTask.style.backgroundColor = '#c0392b';
|
btnLoadTask.style.backgroundColor = '#c0392b';
|
||||||
btnLoadTask.style.cursor = 'pointer';
|
btnLoadTask.style.cursor = 'pointer';
|
||||||
}
|
}
|
||||||
document.getElementById('wsStatus').textContent = `狀態:已連線`;
|
// 🌟 UX 升級:剛連上 WS 時,顯示「驗證中...」而不是直接顯示「已連線」
|
||||||
document.getElementById('wsStatus').style.color = '#27ae60';
|
document.getElementById('wsStatus').textContent = `狀態:連線與驗證中...`;
|
||||||
|
document.getElementById('wsStatus').style.color = '#f39c12'; // 橘色
|
||||||
document.getElementById('btnConnect').style.display = 'none';
|
document.getElementById('btnConnect').style.display = 'none';
|
||||||
document.getElementById('btnDisconnect').style.display = 'inline-block';
|
document.getElementById('btnDisconnect').style.display = 'inline-block';
|
||||||
document.getElementById('cmtsHost').disabled = true;
|
document.getElementById('cmtsHost').disabled = true;
|
||||||
document.getElementById('cmtsUser').disabled = true;
|
document.getElementById('cmtsUser').disabled = true;
|
||||||
document.getElementById('cmtsPass').disabled = true;
|
document.getElementById('cmtsPass').disabled = true;
|
||||||
term.focus();
|
term.focus();
|
||||||
|
|
||||||
|
// 🌟 原有:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
|
||||||
|
const queryTaskSelect = document.getElementById('queryTask');
|
||||||
|
if (queryTaskSelect) {
|
||||||
|
queryTaskSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌟 新增:連線成功後,強制觸發配置任務切換,藉此啟動「正確 IP」的 SSE 監聽
|
||||||
|
const configTaskSelect = document.getElementById('configTask');
|
||||||
|
if (configTaskSelect) {
|
||||||
|
configTaskSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
// 🌟 UX 升級:只要收到設備傳來的第一個字元,就代表 SSH 驗證成功,正式轉為綠色「已連線」
|
||||||
|
const statusEl = document.getElementById('wsStatus');
|
||||||
|
if (statusEl.textContent.includes('驗證中')) {
|
||||||
|
statusEl.textContent = `狀態:已連線`;
|
||||||
|
statusEl.style.color = '#27ae60'; // 綠色
|
||||||
|
}
|
||||||
term.write(colorizeTerminalStream(event.data));
|
term.write(colorizeTerminalStream(event.data));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,6 +134,17 @@ export function connectWebSocket(resetCallback) {
|
||||||
document.getElementById('cmtsPass').disabled = false;
|
document.getElementById('cmtsPass').disabled = false;
|
||||||
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
|
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
|
||||||
|
|
||||||
|
// 🌟 關鍵修復:攔截後端傳來的 4001 錯誤碼,彈出明確的警告視窗
|
||||||
|
if (event.code === 4001) {
|
||||||
|
alert(`❌ 連線失敗!\n請檢查目標設備 IP、帳號與密碼是否正確。\n\n系統訊息: ${event.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌟 新增:斷線時,強制觸發配置任務切換,藉此「關閉」SSE 監聽
|
||||||
|
const configTaskSelect = document.getElementById('configTask');
|
||||||
|
if (configTaskSelect) {
|
||||||
|
configTaskSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
|
||||||
if (resetCallback) resetCallback();
|
if (resetCallback) resetCallback();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,135 @@
|
||||||
// --- static/tree-ui.js ---
|
// --- static/tree-ui.js ---
|
||||||
|
|
||||||
|
import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🌟 輔助函數:全部展開與全部收合
|
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
|
||||||
|
// ==========================================
|
||||||
|
export const folderDataCache = {};
|
||||||
|
export const filterFolderCache = {};
|
||||||
|
|
||||||
|
// 🧹 [修復 Leak] 徹底清空樹狀圖快取,釋放記憶體
|
||||||
|
export function clearTreeCache() {
|
||||||
|
for (let key in folderDataCache) delete folderDataCache[key];
|
||||||
|
for (let key in filterFolderCache) delete filterFolderCache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
|
||||||
|
window.lazyLoadFolder = function(elementId, isFilter = false) {
|
||||||
|
const detailsEl = document.getElementById(`details-${elementId}`);
|
||||||
|
// 如果已經載入過,就不重複渲染
|
||||||
|
if (!detailsEl || detailsEl.dataset.loaded === 'true') return;
|
||||||
|
|
||||||
|
// 從 dataset 讀取當初存下來的屬性
|
||||||
|
const currentPath = detailsEl.dataset.path;
|
||||||
|
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
|
||||||
|
const mode = detailsEl.dataset.mode;
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
|
||||||
|
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
|
||||||
|
|
||||||
|
if (contentDiv && cache) {
|
||||||
|
// 🌟 統一渲染作法:單點展開也套用非同步渲染保護
|
||||||
|
contentDiv.innerHTML = `<span style="color: #f39c12; font-size: 13px; font-weight: bold; margin-left: 5px;">⏳ 載入中...</span>`;
|
||||||
|
document.body.style.cursor = 'wait';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isFilter) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🌟 輔助函數:全部展開與全部收合 (支援延遲渲染)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
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;
|
||||||
details.open = true; // 展開自己
|
|
||||||
const subDetails = details.querySelectorAll('details');
|
const isFilter = details.id.includes('filter-');
|
||||||
subDetails.forEach(d => d.open = true); // 展開所有子項目
|
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
|
||||||
|
if (!cache) return;
|
||||||
|
|
||||||
|
// 🌟 讓滑鼠變成讀取狀態,給予使用者即時回饋
|
||||||
|
document.body.style.cursor = 'wait';
|
||||||
|
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
|
||||||
|
|
||||||
|
if (contentDiv) {
|
||||||
|
contentDiv.innerHTML = `<span style="color: #f39c12; font-weight: bold; font-size: 13px;">⏳ 正在極速展開中,請稍候...</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 使用 setTimeout 讓出一個 Frame,讓瀏覽器能渲染出「讀取中」的文字與滑鼠狀態
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentPath = details.dataset.path;
|
||||||
|
const isCommandGroup = details.dataset.isGroup === 'true';
|
||||||
|
const mode = details.dataset.mode;
|
||||||
|
|
||||||
|
// 🚀 核心魔法:在記憶體中一次性遞迴生成所有子節點的 HTML 字串 (傳入 forceExpand = true)
|
||||||
|
let fullHtml = '';
|
||||||
|
if (isFilter) {
|
||||||
|
fullHtml = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode, true);
|
||||||
|
} else {
|
||||||
|
fullHtml = buildTree(cache, currentPath, isCommandGroup, mode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 終極效能:只執行 1 次 DOM 寫入!
|
||||||
|
if (contentDiv) contentDiv.innerHTML = fullHtml;
|
||||||
|
|
||||||
|
details.dataset.loaded = 'true';
|
||||||
|
details.open = true;
|
||||||
|
|
||||||
|
const iconClosed = details.querySelector('.icon-closed');
|
||||||
|
const iconOpen = details.querySelector('.icon-open');
|
||||||
|
if (iconClosed) iconClosed.style.display = 'none';
|
||||||
|
if (iconOpen) iconOpen.style.display = 'inline-block';
|
||||||
|
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
}, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collapseAll(elementId) {
|
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]');
|
||||||
details.open = false; // 收合自己
|
openSubDetails.forEach(d => {
|
||||||
|
d.open = false;
|
||||||
|
const iconClosed = d.querySelector('.icon-closed');
|
||||||
|
const iconOpen = d.querySelector('.icon-open');
|
||||||
|
if (iconClosed) iconClosed.style.display = 'inline-block';
|
||||||
|
if (iconOpen) iconOpen.style.display = 'none';
|
||||||
|
});
|
||||||
|
details.open = false;
|
||||||
|
|
||||||
|
const mainIconClosed = details.querySelector('.icon-closed');
|
||||||
|
const mainIconOpen = details.querySelector('.icon-open');
|
||||||
|
if (mainIconClosed) mainIconClosed.style.display = 'inline-block';
|
||||||
|
if (mainIconOpen) mainIconOpen.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🌟 核心函數:生成完整設備配置樹狀圖
|
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export function buildTree(node, path = '', isParentCommandGroup = false) {
|
// 🌟 修改函數宣告,加入 renderAll 參數
|
||||||
let html = '';
|
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false, renderAll = false) {
|
||||||
if (!node || typeof node !== 'object') return html;
|
const htmlParts = [];
|
||||||
|
if (!node || typeof node !== 'object') return htmlParts.join('');
|
||||||
|
|
||||||
|
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||||||
|
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
||||||
|
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
||||||
|
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||||||
|
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(node)) {
|
for (const [key, value] of Object.entries(node)) {
|
||||||
const currentPath = path ? `${path}::${key}` : key;
|
const currentPath = path ? `${path}::${key}` : key;
|
||||||
|
|
@ -35,20 +138,14 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||||||
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
||||||
const elementId = `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>' : '';
|
||||||
|
|
||||||
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>`;
|
folderDataCache[elementId] = value;
|
||||||
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}')"
|
||||||
|
|
@ -64,9 +161,47 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
html += `
|
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
||||||
|
let isLocked = false;
|
||||||
|
let lockTitle = "鎖定並編輯此群組";
|
||||||
|
let lockText = "✏️";
|
||||||
|
let lockOpacity = "0.3";
|
||||||
|
let lockPointer = "auto";
|
||||||
|
|
||||||
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
if (hostLocks[currentPath]) {
|
||||||
|
const lockInfo = hostLocks[currentPath];
|
||||||
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
|
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
||||||
|
if (currentPath.startsWith(lockedPath + "::")) {
|
||||||
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
|
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 核心魔法:如果 forceExpand 為 true,直接在記憶體中遞迴生成子節點 HTML
|
||||||
|
const isOpenAttr = forceExpand ? 'open' : '';
|
||||||
|
const isLoadedAttr = (forceExpand || renderAll) ? 'true' : 'false';
|
||||||
|
const displayClosed = forceExpand ? 'none' : 'inline-block';
|
||||||
|
const displayOpen = forceExpand ? 'inline-block' : 'none';
|
||||||
|
|
||||||
|
let innerContent = '<!-- 🌟 延遲渲染 -->';
|
||||||
|
if (forceExpand || renderAll) {
|
||||||
|
// 🌟 遞迴呼叫時,把 renderAll 傳遞下去
|
||||||
|
innerContent = buildTree(value, currentPath, isCommandGroup, mode, forceExpand, renderAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlParts.push(`
|
||||||
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
||||||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
|
||||||
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
||||||
<summary class="tree-folder-header"
|
<summary class="tree-folder-header"
|
||||||
title="${cliCommand}"
|
title="${cliCommand}"
|
||||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
|
|
@ -74,17 +209,25 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
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}
|
||||||
|
|
||||||
<div style="margin-left: auto; display: flex; align-items: center;">
|
<div style="margin-left: auto; display: flex; align-items: center;">
|
||||||
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
|
<span class="debug-preview-btn admin-only"
|
||||||
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
|
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
|
||||||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
|
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
|
||||||
✏️
|
title="[開發者] 預覽此節點下所有指令"
|
||||||
|
onmouseover="this.style.transform='scale(1.2)'"
|
||||||
|
onmouseout="this.style.transform='scale(1)'">
|
||||||
|
🔍
|
||||||
|
</span>
|
||||||
|
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
|
||||||
|
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
|
||||||
|
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
|
||||||
|
${lockText}
|
||||||
</span>
|
</span>
|
||||||
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
||||||
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
||||||
|
|
@ -93,27 +236,49 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
</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;">
|
||||||
${buildTree(value, currentPath, isCommandGroup)}
|
${innerContent}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`);
|
||||||
} else {
|
} else {
|
||||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||||
const isNoCommand = (key === 'no');
|
const isNoCommand = (key === 'no');
|
||||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
|
||||||
if (isNoCommand) {
|
if (isNoCommand) {
|
||||||
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
|
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
|
||||||
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
|
||||||
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
|
||||||
|
|
||||||
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
|
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
|
||||||
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
|
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
|
||||||
|
|
||||||
|
const currentHost = document.getElementById('cmtsHost')?.value.trim();
|
||||||
|
let isLocked = false;
|
||||||
|
let lockTitle = "鎖定並編輯此項目";
|
||||||
|
let lockText = "✏️";
|
||||||
|
let lockOpacity = "0.3";
|
||||||
|
let lockPointer = "auto";
|
||||||
|
|
||||||
|
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
|
||||||
|
const hostLocks = window.globalActiveLocks[currentHost];
|
||||||
|
if (hostLocks[currentPath]) {
|
||||||
|
const lockInfo = hostLocks[currentPath];
|
||||||
|
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
|
||||||
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
|
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
|
||||||
|
if (currentPath.startsWith(lockedPath + "::")) {
|
||||||
|
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
|
||||||
|
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let displayContent = '';
|
let displayContent = '';
|
||||||
|
|
||||||
if (isVirtualIndex) {
|
if (isVirtualIndex) {
|
||||||
|
|
@ -142,7 +307,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<div class="tree-leaf-node"
|
<div class="tree-leaf-node"
|
||||||
title="${cliCommand}"
|
title="${cliCommand}"
|
||||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
|
|
@ -151,10 +316,18 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
${displayContent}
|
${displayContent}
|
||||||
|
|
||||||
<div style="margin-left: auto; display: flex; align-items: center;">
|
<div style="margin-left: auto; display: flex; align-items: center;">
|
||||||
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
|
<span class="debug-preview-btn admin-only"
|
||||||
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
|
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
|
||||||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
|
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
|
||||||
✏️
|
title="[開發者] 預覽此節點下所有指令"
|
||||||
|
onmouseover="this.style.transform='scale(1.2)'"
|
||||||
|
onmouseout="this.style.transform='scale(1)'">
|
||||||
|
🔍
|
||||||
|
</span>
|
||||||
|
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
|
||||||
|
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
|
||||||
|
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
|
||||||
|
${lockText}
|
||||||
</span>
|
</span>
|
||||||
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
||||||
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
||||||
|
|
@ -162,19 +335,29 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html;
|
return htmlParts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 🌟 核心函數:生成系統設定的過濾樹狀圖
|
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false) {
|
// 🌟 新增 forceExpand 參數,預設為 false
|
||||||
|
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running', forceExpand = false) {
|
||||||
if (typeof data !== 'object' || data === null) return '';
|
if (typeof data !== 'object' || data === null) return '';
|
||||||
|
|
||||||
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
|
const htmlParts = [];
|
||||||
|
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
|
||||||
|
|
||||||
|
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||||||
|
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||||||
|
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
|
||||||
|
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
|
||||||
|
|
||||||
for (const key in data) {
|
for (const key in data) {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
|
|
@ -183,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;">`;
|
||||||
|
|
@ -196,15 +373,14 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||||||
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
||||||
const elementId = `filter-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 };
|
||||||
|
|
||||||
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}')"
|
||||||
|
|
@ -220,23 +396,36 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
html += `
|
// 🚀 核心魔法:如果 forceExpand 為 true,直接在記憶體中遞迴生成子節點 HTML
|
||||||
<details id="details-${elementId}" class="tree-folder" ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
|
const isOpenAttr = forceExpand ? 'open' : '';
|
||||||
|
const isLoadedAttr = forceExpand ? 'true' : 'false';
|
||||||
|
const displayClosed = forceExpand ? 'none' : 'inline-block';
|
||||||
|
const displayOpen = forceExpand ? 'inline-block' : 'none';
|
||||||
|
|
||||||
|
let innerContent = '<!-- 🌟 延遲渲染 -->';
|
||||||
|
if (forceExpand) {
|
||||||
|
innerContent = buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup, mode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlParts.push(`
|
||||||
|
<details id="details-${elementId}" class="tree-folder"
|
||||||
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
|
||||||
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
||||||
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
|
<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 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;">
|
||||||
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
|
${innerContent}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`);
|
||||||
} else {
|
} else {
|
||||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||||
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
||||||
|
|
@ -258,7 +447,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<div class="tree-leaf-node"
|
<div class="tree-leaf-node"
|
||||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
onmouseout="this.style.backgroundColor='transparent'"
|
onmouseout="this.style.backgroundColor='transparent'"
|
||||||
|
|
@ -271,9 +460,32 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
||||||
${valueHtml}
|
${valueHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += '</div>';
|
htmlParts.push('</div>');
|
||||||
return html;
|
return htmlParts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🌟 輔助函數:強制預先渲染資料夾 HTML (供編輯模式使用)
|
||||||
|
// ==========================================
|
||||||
|
window.forceRenderFolderHTML = function(elementId) {
|
||||||
|
const detailsEl = document.getElementById(`details-${elementId}`);
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
const cache = folderDataCache[elementId];
|
||||||
|
|
||||||
|
// 🌟 關鍵修復:移除 detailsEl.dataset.loaded !== 'true' 的限制!
|
||||||
|
// 因為如果使用者先手動展開了父資料夾,再點擊編輯,
|
||||||
|
// 原本的邏輯會因為 loaded === true 而拒絕往下遞迴渲染子資料夾,
|
||||||
|
// 導致子資料夾內的項目沒有被轉成輸入框。
|
||||||
|
if (detailsEl && contentDiv && cache) {
|
||||||
|
const currentPath = detailsEl.dataset.path;
|
||||||
|
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
|
||||||
|
const mode = detailsEl.dataset.mode;
|
||||||
|
|
||||||
|
// 重新渲染,傳入 renderAll = true (生成 HTML 但不展開)
|
||||||
|
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode, false, true);
|
||||||
|
detailsEl.dataset.loaded = 'true';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"hidden_keys": [
|
|
||||||
"ssh"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,615 @@
|
||||||
|
================================================================================
|
||||||
|
TARGET SOURCE CODE EXPORT (SPECIFIC)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
FILE: index.html
|
||||||
|
================================================================================
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Harmonic CMTS Manager</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/my_icon.png">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
|
||||||
|
<!-- 引入獨立的 CSS 樣式表 -->
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- 1. 標題加上 id="app-title" -->
|
||||||
|
<h1 id="app-title" style="cursor: default;">🎙️ Harmonic CMTS Admin</h1>
|
||||||
|
|
||||||
|
<div class="global-settings">
|
||||||
|
<label><strong>🌐 目標設備:</strong></label>
|
||||||
|
<!-- 🌟 修改後 (徹底淨化) -->
|
||||||
|
<input type="text" id="cmtsHost" placeholder="IP 地址" style="width: 130px;">
|
||||||
|
<input type="text" id="cmtsUser" placeholder="帳號" style="width: 100px;">
|
||||||
|
<input type="password" id="cmtsPass" placeholder="密碼" style="width: 100px;">
|
||||||
|
|
||||||
|
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
|
||||||
|
<button onclick="disconnectWebSocket()" id="btnDisconnect" class="btn-modern btn-disconnect" style="display: none;">斷開連線</button>
|
||||||
|
<span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('config-tab', this)">🛠️ CMTS 設備配置</button>
|
||||||
|
<!-- 💡 新增:系統設定頁籤 -->
|
||||||
|
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
|
||||||
|
<!-- 💡 新增:備份與還原頁籤 -->
|
||||||
|
<button class="tab-btn" onclick="switchTab('backup-tab', this); loadBackupHistory();">💾 設備備份與還原</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 1: True SSH Terminal -->
|
||||||
|
<div id="cli-tab" class="tab-content active">
|
||||||
|
<div class="control-group">
|
||||||
|
<label><strong>快捷指令:</strong></label>
|
||||||
|
<select id="fixedCmd" aria-label="選擇快捷指令" >
|
||||||
|
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
|
||||||
|
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
|
||||||
|
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
|
||||||
|
|
||||||
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
|
<button onclick="downloadTerminal()" class="btn-modern btn-slate">💾 下載紀錄</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="terminal-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 2: CMTS 狀態查詢 -->
|
||||||
|
<div id="query-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
|
||||||
|
<div class="manager-container">
|
||||||
|
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
||||||
|
<label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
|
||||||
|
<select id="queryTask" onchange="switchQueryTask()" aria-label="選擇查詢任務" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
|
||||||
|
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
|
||||||
|
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
|
||||||
|
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單 1:Cable 狀態綜合查詢 -->
|
||||||
|
<div id="form-cm-query" class="task-form active">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>目標設備 (Cable Modem MAC)</label>
|
||||||
|
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||||||
|
<datalist id="cm-mac-list"></datalist>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>查詢動作 (Action)</label>
|
||||||
|
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')" aria-label="選擇 Cable Modem 查詢動作">
|
||||||
|
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
|
||||||
|
<option value="base">基本狀態 (show cable modem)</option>
|
||||||
|
<option value="cpe">CPE 資訊 (cpe)</option>
|
||||||
|
<option value="cpe_dhcp">CPE DHCP (cpe dhcp)</option>
|
||||||
|
<option value="cpe_ipv6">CPE IPv6 (cpe ipv6)</option>
|
||||||
|
<option value="bonding">綁定摘要 (bonding)</option>
|
||||||
|
<option value="bonding_ds">下行綁定 (bonding downstream)</option>
|
||||||
|
<option value="bonding_us">上行綁定 (bonding upstream)</option>
|
||||||
|
<option value="phy">實體層狀態 (phy)</option>
|
||||||
|
<option value="verbose">詳細資訊 (verbose)</option>
|
||||||
|
<option value="ofdm_profile">OFDM Profile (ofdm-profile)</option>
|
||||||
|
<option value="ofdma_profile">OFDMA Profile (ofdma-profile)</option>
|
||||||
|
<option value="dhcp_verbose">DHCP 詳細資訊 (dhcp verbose)</option>
|
||||||
|
<option value="service_flow">Service Flow (service-flow)</option>
|
||||||
|
<option value="service_flow_verbose">Service Flow 詳細 (service-flow verbose)</option>
|
||||||
|
<option value="qos">QoS 資訊 (qos)</option>
|
||||||
|
<option value="uptime">運行時間 (uptime)</option>
|
||||||
|
<option value="ugs">UGS 資訊 (ugs)</option>
|
||||||
|
<option value="cm_status">CM 狀態 (cm-status)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="全域狀態查詢 (不分特定目標)">
|
||||||
|
<option value="partial_mode">Partial Mode (partial-mode)</option>
|
||||||
|
<option value="hop">Cable Hop (show cable hop)</option>
|
||||||
|
<option value="flap_list">Flap List (show cable flap-list)</option>
|
||||||
|
<option value="flap_sum">Flap Summary (show cable flap-sum)</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button onclick="executeQuery('Cm')" class="btn-modern btn-load">執行查詢</button>
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
||||||
|
<label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單 2:RPD 狀態綜合查詢 -->
|
||||||
|
<div id="form-rpd-query" class="task-form">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>目標設備 (RPD VC:VS)</label>
|
||||||
|
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
|
||||||
|
<datalist id="rpd-vcvs-list"></datalist>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>查詢動作 (Action)</label>
|
||||||
|
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')" aria-label="選擇 RPD 查詢動作">
|
||||||
|
<option value="rpd_base">基本狀態 (show cable rpd)</option>
|
||||||
|
<option value="rpd_verbose">詳細資訊 (verbose)</option>
|
||||||
|
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
|
||||||
|
<option value="rpd_ptp_verbose">PTP 詳細資訊 (ptp verbose)</option>
|
||||||
|
<option value="rpd_counters_map">Counters Map (counters map)</option>
|
||||||
|
<option value="rpd_capabilities">能力資訊 (capabilities)</option>
|
||||||
|
<option value="rpd_video_counters">Video Counters (video-channel counters)</option>
|
||||||
|
<option value="rpd_env_temp">環境溫度 (environment temperature)</option>
|
||||||
|
<option value="rpd_env_volt">環境電壓 (environment voltage)</option>
|
||||||
|
<option value="rpd_session">Session (session)</option>
|
||||||
|
<option value="rpd_reset_history">重啟歷史 (reset-history)</option>
|
||||||
|
<option value="rpd_port_transceiver">光模組資訊 (port-transceiver)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button onclick="executeQuery('Rpd')" class="btn-modern btn-load">執行查詢</button>
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
|
||||||
|
<label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單 3:CM 一鍵診斷中心 -->
|
||||||
|
<div id="form-cm-diagnostics" class="task-form">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
|
||||||
|
|
||||||
|
<!-- 搜尋區塊 -->
|
||||||
|
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
|
||||||
|
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC:</label>
|
||||||
|
<input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
|
||||||
|
<datalist id="diag-cm-mac-list"></datalist>
|
||||||
|
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
|
||||||
|
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 結果顯示區塊 (預設隱藏) -->
|
||||||
|
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
|
||||||
|
|
||||||
|
<!-- 上半部:基本資訊與 PHY 狀態 -->
|
||||||
|
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||||||
|
<!-- 基本資訊卡片 -->
|
||||||
|
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||||||
|
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
|
||||||
|
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
|
||||||
|
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PHY 狀態卡片 -->
|
||||||
|
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||||||
|
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
|
||||||
|
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
|
||||||
|
<tr style="border-bottom: 1px solid #ecf0f1;">
|
||||||
|
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
|
||||||
|
<td id="diagResTx" style="font-weight: bold;">-</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
|
||||||
|
<td id="diagResRx" style="font-weight: bold;">-</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:上行通道標籤區塊 -->
|
||||||
|
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
|
||||||
|
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
|
||||||
|
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
|
<!-- 動態生成標籤 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下半部:Chart.js MER 圖表 -->
|
||||||
|
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
||||||
|
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
|
||||||
|
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
|
||||||
|
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
|
||||||
|
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<!-- 動態生成按鈕 -->
|
||||||
|
</div>
|
||||||
|
</h4>
|
||||||
|
<div style="position: relative; height: 280px; width: 100%;">
|
||||||
|
<canvas id="merChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 3: CMTS 設備配置 -->
|
||||||
|
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
|
||||||
|
<div class="manager-container">
|
||||||
|
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
||||||
|
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
|
||||||
|
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
|
||||||
|
<!-- 🌟 拆分為兩個獨立的選項 -->
|
||||||
|
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
|
||||||
|
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
|
||||||
|
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單 3:MAC Domain 配置精靈 -->
|
||||||
|
<div id="form-bonding-config" class="task-form active">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
|
||||||
|
|
||||||
|
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
|
||||||
|
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain:</label>
|
||||||
|
<input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
||||||
|
<datalist id="mac-domain-list"></datalist>
|
||||||
|
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
|
||||||
|
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="macDomainConfigArea" style="display: none; margin-top: 20px;">
|
||||||
|
<!-- Common Settings -->
|
||||||
|
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row"><label>IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
|
||||||
|
<div class="form-row"><label>Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
|
<div class="form-row"><label>CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
|
<div class="form-row"><label>CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
|
<div class="form-row"><label>DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
|
<div class="form-row"><label>DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
|
<div class="form-row"><label>US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [Basic] DS/US Channel Sets -->
|
||||||
|
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row"><label>Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
|
<div class="form-row"><label>DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
|
||||||
|
<div class="form-row"><label>DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
|
||||||
|
<div class="form-row"><label>US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
|
||||||
|
<div class="form-row"><label>DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
|
||||||
|
<div class="form-row"><label>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [Static] Downstream Bonding Groups -->
|
||||||
|
<div id="section_group_ds" style="margin-top: 30px;">
|
||||||
|
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
|
||||||
|
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||||||
|
<label>選擇要編輯的 DS Group:</label>
|
||||||
|
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
|
||||||
|
<input type="text" id="input_new_ds_group" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row"><label>Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
|
<div class="form-row"><label>Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
|
||||||
|
<div class="form-row"><label>OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
|
||||||
|
<div class="form-row"><label>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [Static] Upstream Bonding Groups -->
|
||||||
|
<div id="section_group_us" style="margin-top: 30px;">
|
||||||
|
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
|
||||||
|
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||||||
|
<label>選擇要編輯的 US Group:</label>
|
||||||
|
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
|
||||||
|
<input type="text" id="input_new_us_group" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row"><label>Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
|
<div class="form-row"><label>US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
|
||||||
|
<div class="form-row"><label>OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
|
||||||
|
<div class="form-row"><label>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CLI 預覽區塊 -->
|
||||||
|
<div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="btnRevert" onclick="revertFormChanges()" class="btn-modern btn-disconnect" style="margin-right: 15px;">🔄 復原重填</button>
|
||||||
|
<button onclick="generateMacDomainCLI()" class="btn-modern btn-scan">⚙️ 產生絕對路徑指令預覽</button>
|
||||||
|
<button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單 4:設備配置樹狀圖 (共用容器) -->
|
||||||
|
<div id="form-tree-config" class="task-form" style="display: none;">
|
||||||
|
<!-- 頂部:標題與按鈕區塊 (維持原樣) -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
|
||||||
|
<span id="tree-form-title">🌲 設備配置樹狀圖</span>
|
||||||
|
</h3>
|
||||||
|
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
|
||||||
|
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
|
||||||
|
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button id="btn-scan-options" onclick="scanMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">🚀 掃描缺失選項</button>
|
||||||
|
<button id="btn-clear-options" onclick="clearOptionsCache()" class="btn-modern btn-clear" style="display: none;">🗑️ 清除選項快取</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 左右分屏容器 (關鍵修改:align-items: stretch 讓左右強制等高) -->
|
||||||
|
<div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
|
||||||
|
|
||||||
|
<!-- 左側:樹狀圖 (保留 max-height: 600px,超過才出捲軸) -->
|
||||||
|
<div id="tree-container-running" class="tree-view-instance" style="flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
||||||
|
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tree-container-full" class="tree-view-instance" style="display: none; flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
||||||
|
<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🖱️ 拖曳分隔線 (拔除寫死的 height: 400px) -->
|
||||||
|
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; flex-shrink: 0; align-items: center; justify-content: center; z-index: 10;" title="左右拖曳調整寬度">
|
||||||
|
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右側:CLI 預覽與執行結果外框 -->
|
||||||
|
<div id="side-cli-preview" style="flex: 1; min-width: 300px; max-width: 50%; display: none; box-sizing: border-box;">
|
||||||
|
|
||||||
|
<!-- 🌟 內部黑底容器 (使用 flex column 與 height: 100% 完美填滿外框) -->
|
||||||
|
<div style="display: flex; flex-direction: column; height: 100%; background: #1e1e1e; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #34495e; box-sizing: border-box;">
|
||||||
|
|
||||||
|
<!-- 頂部標題列 -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
|
||||||
|
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px;">
|
||||||
|
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
|
||||||
|
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
|
||||||
|
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模式一:指令預覽框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||||||
|
<textarea id="side-cli-textarea" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; outline: none; box-sizing: border-box; white-space: pre; overflow-x: auto; overflow-y: auto; margin: 0; display: block; resize: none; min-height: 150px;"></textarea>
|
||||||
|
|
||||||
|
<!-- 模式二:執行結果框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||||||
|
<div id="side-execution-result" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; box-sizing: border-box; overflow-x: auto; overflow-y: auto; margin: 0; display: none; white-space: pre; min-height: 150px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 💡 Tab 4: 系統設定 (System Settings) -->
|
||||||
|
<div id="settings-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe; text-align: left;">
|
||||||
|
<div class="manager-container" style="display: block; max-width: 100%;">
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block;">
|
||||||
|
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
|
||||||
|
⚙️ 全域系統設定 (God Mode Filters)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; border-left: 4px solid #3498db; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
|
||||||
|
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
|
||||||
|
請勾選您希望在「完整設備配置樹狀圖」中 <b>隱藏</b> 的設定項目。<br>
|
||||||
|
<span style="color: #7f8c8d; font-size: 14px;">💡 點擊下方按鈕載入設備實時配置,您可以深入展開並勾選任何層級的節點進行過濾。</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
|
||||||
|
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
|
||||||
|
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
|
||||||
|
<option value="running">Running 配置過濾器</option>
|
||||||
|
<option value="full">Full 配置過濾器</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button onclick="loadRealConfigForFilters()" class="btn-modern btn-load">
|
||||||
|
📥 載入設備配置以設定過濾
|
||||||
|
</button>
|
||||||
|
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 樹狀 Checkbox 容器 -->
|
||||||
|
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
|
||||||
|
<button onclick="saveSystemSettings()" class="btn-modern btn-connect" style="padding: 10px 20px; font-size: 15px;">
|
||||||
|
💾 儲存並套用設定
|
||||||
|
</button>
|
||||||
|
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:伺服器日誌管控面板 -->
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
|
||||||
|
🎛️ 伺服器日誌管控 (Server Log Management)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
|
||||||
|
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
|
||||||
|
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,<b>無需重啟伺服器</b>。<br>
|
||||||
|
<span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b> 或 <b>ERROR</b>,僅在需要排查問題時開啟 <b>DEBUG</b>。</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 矩陣式下拉選單容器 -->
|
||||||
|
<div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
|
||||||
|
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 💡 Tab 5: 設備備份與還原 -->
|
||||||
|
<div id="backup-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
|
||||||
|
<div class="manager-container">
|
||||||
|
|
||||||
|
<!-- 上半部:建立新快照 -->
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
|
||||||
|
|
||||||
|
<!-- 💡 修正:移除負 margin,讓底線與內容邊界對齊 (如同系統設定頁籤) -->
|
||||||
|
<div style="display: flex; justify-content: flex-start; align-items: center; gap: 15px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px;">
|
||||||
|
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px;">
|
||||||
|
📸 建立新快照
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save" style="padding: 8px 20px; font-size: 14px; background-color: #2980b9; border: none; box-shadow: 0 2px 4px rgba(41, 128, 185, 0.3);">
|
||||||
|
🚀 立即執行備份
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none; font-weight: bold;">
|
||||||
|
⏳ 正在連線設備並抓取設定,請稍候...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單網格區 -->
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
|
快照名稱 <span style="color: #e74c3c;">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
|
配置類型
|
||||||
|
</label>
|
||||||
|
<select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
|
||||||
|
<option value="running">Running Config (運行中配置)</option>
|
||||||
|
<option value="full">Full Config (完整配置)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述區 -->
|
||||||
|
<div style="margin-bottom: 0;">
|
||||||
|
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
|
備份描述 <span style="color: #7f8c8d; font-weight: normal; font-size: 13px;">(選填)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="snapshotDescription" placeholder="請簡述此次備份的目的,例如:升級至 Unify FDD firmware,驗收通過" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下半部:歷史紀錄列表 -->
|
||||||
|
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
|
||||||
|
|
||||||
|
<!-- 💡 修正:移除負 margin,讓底線與內容邊界對齊 -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px; flex-wrap: wrap;">
|
||||||
|
|
||||||
|
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px; white-space: nowrap;">
|
||||||
|
📁 歷史備份紀錄
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<input type="text" id="filter-keyword" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
|
||||||
|
|
||||||
|
<select id="filter-type" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
|
||||||
|
<option value="">所有類型</option>
|
||||||
|
<option value="running">running</option>
|
||||||
|
<option value="full">full</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
|
||||||
|
<input type="date" id="filter-date-start" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||||||
|
<span style="color: #7f8c8d; font-size: 13px;">至</span>
|
||||||
|
<input type="date" id="filter-date-end" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
|
||||||
|
🔄 重新整理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格區塊 -->
|
||||||
|
<table style="width: 100%; border-collapse: collapse; text-align: left;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 25%;">快照名稱</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 25%;">描述</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="backup-history-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 獨立的彈出式輸出視窗 (Modal) -->
|
||||||
|
<div id="outputModal" class="modal-overlay">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
|
||||||
|
<span>📄 執行結果</span>
|
||||||
|
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
|
||||||
|
</div>
|
||||||
|
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
|
||||||
|
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:God Mode 授權視窗 (Modal) -->
|
||||||
|
<div id="godModeModal" class="modal-overlay">
|
||||||
|
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
|
||||||
|
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
|
||||||
|
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
|
||||||
|
<div class="modal-title" style="justify-content: center; width: 100%;">
|
||||||
|
<span>🔐 系統進階授權</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
|
||||||
|
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
|
||||||
|
請輸入維護者密碼以解鎖隱藏功能
|
||||||
|
</p>
|
||||||
|
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
|
||||||
|
|
||||||
|
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 15px; justify-content: center;">
|
||||||
|
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
|
||||||
|
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 引入獨立的 JavaScript 邏輯 -->
|
||||||
|
<script type="module" src="/static/app.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Loading…
Reference in New Issue