Compare commits

...

15 Commits

Author SHA1 Message Date
swpa d0d80b26ac chore: 將所有須一的程式碼寫入單一文字檔, 方便提供給Gemini AI分析。 2026-05-31 10:25:28 +08:00
swpa e9b07ddadc chore: 調整Monica Code設定 2026-05-30 23:27:33 +08:00
swpa fb1c091be9 fix(backup): 設備終端機分頁機制(Pagination)問題處理
- 根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁

refactor(backup): 將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
- 階段 0: 預處理 (清理雜訊,移除危險的自動黏合)
- 階段 2: 邏輯群組化與字典合併 (保留嚴格型別防護)

refactor(ui): 獲取整台 CMTS 的完整配置 (串流進度回報版)
- 前端串流接收器 (Streaming UI)

fix(ui): 修復 SSH 終端機無底洞溢出與頁籤底部左右捲軸錯位問題
- 於 .xterm 新增 height: 100% 與 box-sizing: border-box,確保終端機畫布完美貼合黑底外框,並將捲動權限成功交還給終端機內部。
2026-05-30 00:48:53 +08:00
swpa cf4b6f2e3b refactor: 將 Tree Editor 升級為 Streaming 架構 2026-05-29 16:21:43 +08:00
swpa 61241c796e feat: 前端串流接收器 (Streaming UI)
feat: 後端 SSH 交易寫入管道 (Transactional SSH Pipeline)

refactor: 終端機緩衝區 (Terminal Buffer) 吞吐時機

feat: Streaming、Fail-safe 以及全域併發鎖
2026-05-29 15:16:46 +08:00
swpa 9a3850031c feat(backup): 優化備份模組 UI 佈局與搜尋過濾防呆機制
- style: 重構「歷史備份紀錄」版面,移除 Flex 衝突,實作滿版分隔線與按鈕高度對齊。
- refactor: 將日期選擇器回歸原生 <input type="date">,支援作業系統語系顯示。
- fix(search): 升級 applyBackupFilters 邏輯,加入 Null-Safety (|| '') 與 .trim() 防禦性編程,避免空值導致畫面崩潰。
- feat(search): 擴充搜尋範圍,現在可同時精準比對「快照名稱」與「備份描述」。
2026-05-28 22:24:57 +08:00
swpa f8285f7ff7 fix: 對 CMTS 設定階層實施強化排查機制 2026-05-28 13:28:39 +08:00
swpa a868d82bd5 fix: 1.清除 ANSI 控制碼。 2.清除殘留的 --More-- 文字 2026-05-28 11:56:45 +08:00
swpa e00642553f fix: 將 startLockStatusPolling 改成特務模式(State Diffing), 解決樹狀圖瀏覽延遲。 2026-05-28 10:51:04 +08:00
swpa 77821fc620 feat: Smart Diff Recovery & Preview 2026-05-28 10:24:17 +08:00
swpa eb8c3af98e chore: Adjusting the layout design 2026-05-27 11:33:01 +08:00
swpa f2de682b9b chore: Remove dynamically generated temporary JSON files. 2026-05-26 18:40:19 +08:00
swpa cfc4060d2b feat: Core Backup Engine & Async Stability 2026-05-26 18:30:10 +08:00
swpa 9de6896363 fix: modify the prefix of APIRouter 2026-05-25 16:53:40 +08:00
swpa d11f70b898 feat: 實作 Phase 1 & 2 備份資料庫 Schema 與 API 路由 2026-05-25 16:40:55 +08:00
22 changed files with 8606 additions and 224289 deletions

View File

@ -8,8 +8,8 @@
### 📂 目錄與檔案結構
- **進入點**: `main.py`
- **路由管理**: API 路由統一放置於 `routers/` 目錄。
- **前端介面**: `index.html` 與 `static/` 目錄。
- **路由管理**: 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`: 放置共用函式 (如兩階段解析法)。
@ -20,6 +20,8 @@
## 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` 的生命週期防呆機制。
@ -36,6 +38,11 @@
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`。
### 🎨 前端規範
9. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。
10. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`。
11. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時必須透過 SSE 即時回報進度。
12. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy保留策略/定期清理機制),不可只寫入不刪除。
13. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。
14. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()` 與 `?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。

View File

@ -8,8 +8,8 @@
### 📂 目錄與檔案結構
- **進入點**: `main.py`
- **路由管理**: API 路由統一放置於 `routers/` 目錄。
- **前端介面**: `index.html``static/` 目錄。
- **路由管理**: 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`: 放置共用函式 (如兩階段解析法)。
@ -20,6 +20,8 @@
## 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` 的生命週期防呆機制。
@ -36,6 +38,11 @@
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`
### 🎨 前端規範
9. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`
10. **DOM 神聖不可侵犯**: 在前端 JS 中,嚴禁為了視覺美化刪除 `leaf-container`, `data-path`, `data-original` 等錨點。隱藏請用 `display: none`
11. **設備操作原子性 (Atomicity)**:任何涉及「讀取後寫入」或「備份後寫入」的設備操作,必須確保嚴格的先後順序(使用 `await`),嚴禁使用 `BackgroundTasks` 導致 Race Condition。若過程耗時必須透過 SSE 即時回報進度。
12. **資料庫防膨脹原則**:實作任何會自動產生大量資料的功能(如自動備份、日誌),必須同時實作 Retention Policy保留策略/定期清理機制),不可只寫入不刪除。
13. **安全還原原則 (Safe Restore)**: 嚴禁直接將整份備份檔盲目寫入設備。任何還原操作必須遵循「三階段流程」:產生差異 (Diff) ➡️ 前端預覽 (Preview) ➡️ 授權執行 (Commit)。
14. **前端防禦性編程 (Defensive Programming)**: 處理 API 回傳資料、DOM 元素取值或陣列過濾時,必須嚴格防範 Null/Undefined 情況(例如使用 `(item.description || '').toLowerCase()``?.` 運算子)。確保前端 UI 絕對不會因為單一欄位資料缺失或舊版快取而導致整個畫面或功能崩潰。

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"editor.inlineSuggest.enabled": false,
"json.schemaDownload.enable": false,
"monica-code.showInlineTip": false,
"monica-code.telemetryEnabled": false,
"monica-code.enableTabAutocomplete": false
}

View File

@ -9,20 +9,29 @@
### Phase 1: PostgreSQL 高可用性架構升級 (Completed)
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動捕捉錯誤並退回使用 JSON 檔案讀寫,防止 FastAPI Crash
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
- [x] 調整 `init_db.py` 為非同步啟動腳本。
### Phase 2: 設備配置備份與快照機制 (Completed)
- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`)。
- [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。
- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。
---
## 🚧 目前開發階段 (Current Phase)
### Phase 2: 設備配置備份與快照機制 (Configuration Backup & Snapshots)
- [ ] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (核心欄位需包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`)。
- [ ] **後端 API 實作**:新增「手動建立快照 (Manual Snapshot)」與「取得歷史快照列表」的 RESTful API 路由。
- [ ] **自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。
### Phase 3: 智慧差異還原與歷史預覽 (Smart Diff Recovery & Preview)
- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)。
- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。
- [ ] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 ReadableStream 即時渲染後端傳來的 SSH 逐行執行 Log。
- [ ] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**
- 實作 `/api/v1/backups/{id}/restore` API改為 Streaming Response (串流回應)。
- 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止並回傳錯誤 Chunk。
---
## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
- **[未來計畫] Phase 3: 歷史預覽 (History Preview)**:前端 UI 支援點擊歷史快照,將 `parsed_tree` 載入 `tree-ui.js` 並強制標示為「唯讀模式 (Read-only)」。
- **[未來計畫] Phase 4: 智慧還原 (Smart Recovery)**:實作 Diff-based Rollback 演算法。比對歷史備份與當前樹狀結構,自動產生反向 CLI 指令 (`no [指令]`),並在推送到設備前提供 Dry-Run 預覽確認視窗。
- **[未來計畫] 自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。

6804
all_code.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -338,3 +338,93 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
except Exception as e:
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:
"""連線至設備並抓取完整的純文字設定檔"""
conn = None
try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
async def read_until_quiet(timeout=2.0):
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()
except asyncio.TimeoutError:
break
return output
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0)
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
if config_type == "full":
# 1. 先進入 config 模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
cmd = "show full-configuration | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
# 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n")
await process.stdin.drain()
else:
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
cmd = "show running-config | nomore"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
# 最終退出設備
process.stdin.write("exit\n")
await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
# 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [
line for line in lines
if not line.strip().startswith(cmd)
and not line.strip().startswith("admin@")
]
return "\n".join(clean_lines).strip()
except Exception as e:
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
finally:
if conn:
conn.close()
def parse_config_to_tree(raw_cli: str) -> dict:
"""將純文字設定檔轉換為簡單的階層式 JSON 樹狀圖 (Phase 3 會用到)"""
# 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化
tree = {}
# 暫時回傳空字典,確保 Phase 1 & 2 能順利走通
# 真正的樹狀解析邏輯我們可以在 Phase 3 完善
return {"_raw_length": len(raw_cli), "status": "pending_parser"}

View File

@ -1,6 +1,7 @@
import asyncpg
import json
import logging
import uuid
from typing import Dict, List, Optional, Any
# ==========================================
@ -241,3 +242,151 @@ async def get_tree_filters(config_type: str) -> Optional[List[str]]:
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' 撈取全部)"""
pool = await get_pool()
if not pool:
return None
try:
async with pool.acquire() as conn:
if config_type == "all":
# 🟢 SELECT 加入 description
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
FROM config_backups
WHERE host = $1
ORDER BY timestamp DESC;
"""
records = await conn.fetch(query, host)
else:
# 🟢 SELECT 加入 description
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
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"]
}
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

View File

@ -1,3 +0,0 @@
{
"hidden_keys": []
}

View File

@ -1,3 +0,0 @@
{
"hidden_keys": []
}

View File

@ -33,9 +33,11 @@
<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('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 -->
@ -160,7 +162,7 @@
<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>
<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>
@ -251,78 +253,67 @@
</div>
<!-- 表單 4設備配置樹狀圖 (共用容器) -->
<!-- 🌟 修正 1將 ID 改為 form-tree-config -->
<div id="form-tree-config" class="task-form" style="display: none;">
<h3 style="display: flex; align-items: center; margin-top: 0; margin-bottom: 10px; font-size: 16px; color: #2c3e50;">
<!-- 🌟 修正 2加上 span 與 ID用來動態切換文字 -->
<span id="tree-form-title">🌳 設備配置樹狀圖</span>
<!-- 🌟 掃描與清除按鈕群組 -->
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none; margin-left: 20px;">
🔍 掃描局部缺失選項
</button>
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none; margin-left: 10px;">
🗑️ 清除局部選項快取
</button>
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none; margin-left: 10px;">
🌍 掃描全域缺失選項
</button>
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none; margin-left: 10px;">
🔥 清除全域快取
</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;">
<!-- 頂部:標題與按鈕區塊 (維持原樣) -->
<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>
</h3>
</div>
<div style="display: flex; gap: 10px;">
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描局部缺失</button>
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">清除局部快取</button>
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
</div>
</div>
<!-- 🌟 左右分屏容器 -->
<div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;">
<!-- 🌟 左右分屏容器 (關鍵修改align-items: stretch 讓左右強制等高) -->
<div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
<!-- 左側:樹狀圖 (雙容器實體隔離) -->
<div id="tree-container-running" class="tree-view-instance" 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;">
<!-- 左側:樹狀圖 (保留 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; 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;">
<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>
<!-- 🖱️ 拖曳分隔線 (預設隱藏) -->
<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="左右拖曳調整寬度">
<!-- 🖱️ 拖曳分隔線 (拔除寫死的 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 預覽與執行結果 (預設隱藏,顯示時佔 50%) -->
<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;">
<!-- 右側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: 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>
<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-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">&times;</span>
</div>
</div>
<!-- 模式一:指令預覽框 (可編輯) -->
<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>
<!-- 模式一:指令預覽框 (🌟 拔除 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>
<!-- 模式二:執行結果框 (唯讀,升級字體與行高) -->
<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>
<!-- 模式二:執行結果框 (🌟 拔除 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>
@ -353,7 +344,7 @@
<option value="full">Full 配置過濾器</option>
</select>
<button onclick="loadRealConfigForFilters()" style="background-color: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
<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>
@ -364,7 +355,7 @@
</div>
<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>
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
@ -373,6 +364,110 @@
</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" onclick="closeModal(event)">
<div class="modal-container" onclick="event.stopPropagation()">

View File

@ -61,6 +61,28 @@ async def init_database():
);
""")
# 4. 建立設備配置備份表 config_backups (Phase 1 新增)
logger.info("🛠️ 正在建立 config_backups 資料表與索引...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS config_backups (
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),
is_auto 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.close()
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")

View File

@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
import database
# 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options
from routers import query, config, terminal, lock, leaf_options, backup
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -27,6 +27,7 @@ app.include_router(query.router, prefix="/api/v1")
app.include_router(config.router, prefix="/api/v1")
app.include_router(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router)

95
merge_code.py Normal file
View File

@ -0,0 +1,95 @@
import os
# ==========================================
# 設定區
# ==========================================
# 輸出的合併檔案名稱
OUTPUT_FILE = "all_code.txt"
# ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄)
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", # Python 編譯檔
".exe", ".dll", ".so", ".dylib", # 執行檔/函式庫
".zip", ".tar", ".gz", ".rar", # 壓縮檔
".pdf", ".doc", ".docx", # 文件檔
".sqlite3", ".db" # 資料庫
}
# ⚠️ 排除的特定檔案名稱 (例如腳本自己、隱藏檔等)
EXCLUDE_FILES = {
"merge_code.py", # 排除這支腳本自己
"all_code.txt", # 排除輸出的檔案
".DS_Store", # Mac 系統檔
".clineignore", # AI 輔助工具的忽略檔
".clinerules", # AI 輔助工具的規則檔
".gitignore", # Git 忽略檔
"requirements.txt" # 依賴清單 (通常不需要給 AI 看,除非你要問套件問題)
}
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_files():
# 取得當前腳本所在的目錄 (專案根目錄)
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\n")
outfile.write("=" * 80 + "\n\n")
# 走訪目錄
for dirpath, dirnames, filenames in os.walk(root_dir):
# 排除不需要的目錄 (原地修改 dirnames 列表os.walk 就不會進去)
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)
# 計算相對路徑 (例如: routers/config.py)
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}")
if __name__ == "__main__":
merge_files()

319
routers/backup.py Normal file
View File

@ -0,0 +1,319 @@
import logging
import asyncssh
import asyncio
import re
import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, List
# 引入 DB 函數
from database import (
insert_config_backup,
get_config_backup_list,
get_config_backup_detail,
delete_config_backup
)
from cmts_scraper import fetch_raw_config
from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖
logger = logging.getLogger(__name__)
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("/snapshot", summary="手動建立設備快照")
async def create_snapshot(req: SnapshotRequest):
try:
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
parsed_tree = parse_cli_to_tree(raw_cli)
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:
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}
return {
"status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id,
"host": req.host
}
except Exception as e:
logger.error(f"❌ 建立快照失敗: {e}")
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}
@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": "無法從設備取得當前配置,請檢查連線狀態"}
current_tree = parse_cli_to_tree(raw_current)
diff_commands = 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():
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
if cmts_config_lock.locked():
yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n"
return
async with cmts_config_lock:
try:
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
conn = await asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
async def read_until_quiet(timeout=1.0):
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()
except asyncio.TimeoutError:
break
return output
# 2. 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5)
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
# 3. 逐行寫入並進行 Fail-safe 檢查
for cmd in commands:
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
out = await read_until_quiet(timeout=0.2)
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
# 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback)
if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out:
# 1. 先通知前端正在撤銷
yield json.dumps({
"status": "progress",
"message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..."
}) + "\n"
# 2. 對設備下達 abort 指令
process.stdin.write("abort\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式
# 3. 回報最終錯誤並關閉連線
yield json.dumps({
"status": "error",
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
"output": clean_out
}) + "\n"
conn.close()
return
yield json.dumps({
"status": "progress",
"message": f"執行: {cmd}",
"output": clean_out
}) + "\n"
# 稍微讓出控制權,確保串流順暢
await asyncio.sleep(0.05)
# 4. 提交變更 (Commit)
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write("commit\n")
await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0)
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
conn.close()
yield json.dumps({
"status": "success",
"message": "✅ 所有差異還原指令已成功送出並 Commit",
"output": clean_commit
}) + "\n"
except Exception as e:
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")

View File

@ -3,6 +3,8 @@ import re
import json
import os
import database
import asyncio
import asyncssh
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List
@ -10,6 +12,7 @@ from netmiko import ConnectHandler
# 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict
from fastapi.responses import StreamingResponse
router = APIRouter()
@ -82,44 +85,82 @@ class GenerateCliRequest(BaseModel):
@router.post("/cmts-config")
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('!')]
net_connect = ConnectHandler(**device)
# 1. 手動進入設定模式
net_connect.send_command_timing("config")
async def config_streamer():
# 🛡️ 繼承原本的非同步鎖,確保同一時間只有一個寫入任務執行
async with cmts_config_lock:
try:
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
# 2. 批次送出設定指令
output = net_connect.send_config_set(
commands,
read_timeout=60,
cmd_verify=False,
enter_config_mode=False,
exit_config_mode=False
)
# 使用 asyncssh 建立非同步連線
async with asyncssh.connect(
req.host,
username=req.username,
password=req.password,
known_hosts=None
) as conn:
async with conn.create_process() as process:
# 3. 手動退出設定模式
net_connect.send_command_timing("exit")
net_connect.disconnect()
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
async def read_until_quiet(timeout=0.5):
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)
except asyncio.TimeoutError:
break
return out_data
# 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
# 1. 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(0.5) # 清空歡迎訊息
# 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)
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower()
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output:
return {
if "% invalid" in lower_output or "% incomplete" in lower_output or "error" in lower_output or "aborted" in lower_output:
yield json.dumps({
"status": "error",
"message": "CMTS 拒絕了設定 (Commit Aborted)",
"detail": output
}
"message": f"❌ 設備拒絕指令: {cmd}",
"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)
yield json.dumps({
"status": "success",
"message": "✅ 所有配置已成功寫入並 Commit",
"output": final_output.strip()
}) + "\n"
return {"status": "success", "data": output}
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")
async def generate_cli(req: GenerateCliRequest):
@ -291,6 +332,77 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
except Exception as e:
return {"status": "error", "message": str(e)}
@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():
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)
await asyncio.to_thread(net_connect.disconnect)
# 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()
base_tree = parse_cli_to_tree(clean_output)
perfect_tree = deep_split_tree(base_tree)
# ==========================================
# 🌟 深度過濾攔截器 (維持原樣)
# ==========================================
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"
# 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
# ==========================================
# 🌟 系統設定 API (System Settings)
# ==========================================

View File

@ -28,44 +28,28 @@ def parse_cli_to_tree(cli_text: str) -> dict:
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')
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:
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():
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)
# 統一去除右側多餘空白,準備進入階段 1
lines = [line.rstrip() for line in fixed_lines if line.strip()]
# ==========================================
# 階段 1建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
# 🌟 階段 1建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
# ==========================================
def build_raw_tree(start_idx, current_indent):
nodes = []
@ -75,24 +59,20 @@ def parse_cli_to_tree(cli_text: str) -> dict:
stripped = line.strip()
indent = len(line) - len(line.lstrip())
# 🌟 嚴格判斷 1如果遇到縮排退回 (包含縮排退回的 !),代表當前深度的區塊已結束
if indent < current_indent:
if indent < current_indent and stripped != '!':
return nodes, i
# 🌟 嚴格判斷 2處理同層級的 !
if stripped == '!':
# 能走到這裡,代表 indent >= current_indent
# 這是對應當前層級的結束符號或分隔符,我們將其消耗掉並繼續,絕對不提早 return
i += 1
continue
# 探測是否有子節點
has_children = False
if i + 1 < len(lines):
next_line = lines[i+1]
next_stripped = next_line.strip()
# 只有當下一行不是 ! 且縮排大於當前行時,才判定為有子節點
if next_stripped != '!':
next_i = i + 1
while next_i < len(lines) and lines[next_i].strip() == '!':
next_i += 1
if next_i < len(lines):
next_line = lines[next_i]
next_indent = len(next_line) - len(next_line.lstrip())
if next_indent > indent:
has_children = True
@ -109,16 +89,14 @@ def parse_cli_to_tree(cli_text: str) -> dict:
raw_tree, _ = build_raw_tree(0, 0)
# ==========================================
# 階段 2邏輯群組化與字典合併
# 🌟 階段 2邏輯群組化與字典合併 (保留嚴格型別防護)
# ==========================================
def deep_merge(dict1, dict2):
"""深度合併兩個字典 (解決同名資料夾與指令群組的融合)"""
for k, v in dict2.items():
if k in dict1:
if isinstance(dict1[k], dict) and isinstance(v, dict):
deep_merge(dict1[k], v)
else:
# 發生碰撞時的安全機制
idx = 1
while f"{k} [{idx}]" in dict1:
idx += 1
@ -127,7 +105,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
dict1[k] = v
def nest_folder_key(key_string, value_dict, target_dict):
"""'cable mac-domain 13' 拆解為巢狀字典"""
parts = key_string.split()
current = target_dict
for i, part in enumerate(parts):
@ -138,14 +115,20 @@ def parse_cli_to_tree(cli_text: str) -> dict:
if isinstance(current[part], dict) and isinstance(value_dict, dict):
deep_merge(current[part], value_dict)
else:
current[f"{part} (資料夾)"] = value_dict
idx = 1
while f"{part} (衝突 {idx})" in current:
idx += 1
current[f"{part} (衝突 {idx})"] = value_dict
else:
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 = current[part]
def group_leaves(leaf_strings):
"""將單行指令依據共同前綴進行遞迴群組化"""
grouped = {}
first_word_map = defaultdict(list)
@ -165,8 +148,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
group_dict = {}
if 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())
if is_all_empty_vals:
@ -183,7 +164,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
else:
group_dict[k] = v
# 補回完全沒有後續參數的指令
for _ in range(empty_count):
idx = 0
while f"[{idx}]" in group_dict:
@ -196,12 +176,10 @@ def parse_cli_to_tree(cli_text: str) -> dict:
def fold_nodes(nodes):
result = {}
# 1. 處理資料夾 (Folders)
for node in [n for n in nodes if n["type"] == "folder"]:
folded_children = fold_nodes(node["children"])
nest_folder_key(node["content"], folded_children, result)
# 2. 處理單行指令 (Leaves)
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
if leaves:
leaf_dict = group_leaves(leaves)

View File

@ -102,6 +102,8 @@ window.addEventListener('beforeunload', () => {
}
});
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」
let activeLockState = new Set();
let lockPollingTimer = null;
function startLockStatusPolling() {
@ -114,24 +116,46 @@ function startLockStatusPolling() {
try {
const result = await apiGetLockStatus(connInfo.host);
if (result.status === 'success') {
const activeLocks = result.data;
const currentLocks = result.data;
const newLockState = new Set();
document.querySelectorAll('.edit-btn').forEach(btn => {
const path = btn.getAttribute('data-path');
if (activeLocks[path] && activeLocks[path].user_id !== SESSION_USER_ID) {
// 🎯 任務 1處理「新增鎖定」或「持續鎖定」的節點
for (const [path, lockInfo] of Object.entries(currentLocks)) {
if (lockInfo.user_id !== SESSION_USER_ID) {
newLockState.add(path);
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
targetBtns.forEach(btn => {
if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
btn.title = `🔒 此區塊正由 [${activeLocks[path].username}] 編輯中`;
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
btn.innerText = "🔒";
} else {
}
});
}
}
// 🔓 任務 2處理「解除鎖定」的節點
activeLockState.forEach(oldPath => {
if (!newLockState.has(oldPath)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
targetBtns.forEach(btn => {
if (btn.innerText === "🔒") {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
});
}
});
// 💾 任務 3更新特務的記憶
activeLockState = newLockState;
}
} catch (error) {
console.warn("獲取鎖定狀態失敗:", error);
@ -322,8 +346,12 @@ function resetAllManagerData() {
document.getElementById('queryTargetCm').value = '';
document.getElementById('queryTargetRpd').value = '';
}
// 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
if (typeof loadBackupHistory === 'function') {
loadBackupHistory();
}
}
// ============================================================================
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
@ -359,28 +387,29 @@ async function executeQuery(mode) {
}
}
// 載入完整設備配置並生成樹狀圖,預設為 running
// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀)
async function fetchFullConfig(configType = 'running') {
const loadingMsg = document.getElementById('loading-message');
// 🌟 動態抓取當前模式的專屬容器
const treeContainer = document.getElementById(`tree-container-${configType}`);
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
if (loadingMsg) loadingMsg.style.display = 'inline-block';
if (loadingMsg) {
loadingMsg.style.display = 'inline-block';
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
}
if (treeContainer) treeContainer.innerHTML = '';
let needAutoScan = false; // 🌟 標記是否需要自動掃描
let needAutoScan = false;
let progressInterval = null;
try {
// --- 1. 版本守門員 ---
// --- 1. 版本守門員 (維持原樣) ---
try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
// 🌟 修正 1讀取選項快取時必須加上 config_type 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
@ -389,12 +418,8 @@ async function fetchFullConfig(configType = 'running') {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
if (doClear) {
const allKeys = Object.keys(optData);
// 🌟 修正 2清空快取時必須傳遞 configType 告訴後端清哪一個
await apiClearCache(connInfo.host, allKeys, configType);
alert("✅ 舊版快取已清空!系統載入配置後,將自動為您重新掃描選項。");
needAutoScan = true; // 🌟 觸發自動掃描旗標
needAutoScan = true;
}
}
}
@ -402,53 +427,109 @@ async function fetchFullConfig(configType = 'running') {
console.warn("版本檢查失敗,略過", vErr);
}
// --- 2. 載入完整配置 ---
const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass, false, configType);
// --- 2. 載入完整配置 (ReadableStream 串流接收) ---
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
// 🌟 新增:將原始資料存入全域變數,供全域掃描使用
window.currentTreeData = result.data;
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
// 🌟 新增防呆攔截:檢查使用者是否在等待期間切換了下拉選單
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalTreeData = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
}
}, 500);
} else {
// 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
// 1. 強制填滿 100% 並給予成功提示
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感
}
// 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
await new Promise(resolve => setTimeout(resolve, 600));
// 3. 恢復原本的顏色
if (loadingMsg) loadingMsg.style.color = "";
}
// 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
if (loadingMsg) loadingMsg.innerHTML = data.message;
}
}
else if (data.status === 'success') {
finalTreeData = data.data;
}
else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
window.currentTreeData = finalTreeData;
// --- 3. 防呆攔截與畫面渲染 ---
const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
if (currentSelectedTask !== expectedTask) {
console.warn(`[防呆攔截] 正在載入 ${configType},但使用者已切換至 ${currentSelectedTask},捨棄本次結果以防畫面錯亂。`);
return; // 🌟 發現模式不符,直接中斷,不要把舊樹狀圖貼上去!
}
if (result.status === 'success') {
if (treeContainer) {
// 🌟 傳入 configType讓 ID 加上前綴
treeContainer.innerHTML = buildTree(result.data, '', false, configType);
if (currentSelectedTask !== expectedTask) return;
// 🌟 修正:樹狀圖一畫完,立刻隱藏載入訊息,讓使用者感覺「秒開」
if (treeContainer) {
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
if (loadingMsg) loadingMsg.style.display = 'none';
// 🌟 3. 檢查後端是否已經有人在掃描
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
if (isSomeoneScanning) {
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000);
} else {
enableGlobalScanButton();
// 如果剛清空快取,自動啟動掃描
if (needAutoScan) {
setTimeout(() => {
// 🌟 修正 3全域掃描也必須知道現在要掃哪一棵樹
scanGlobalMissingOptions(configType);
}, 500);
setTimeout(() => scanGlobalMissingOptions(configType), 500);
}
}
}
} else {
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
}
} catch (error) {
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally {
if (loadingMsg) loadingMsg.style.display = 'none';
if (progressInterval) clearInterval(progressInterval);
if (loadingMsg) {
loadingMsg.style.display = 'none';
loadingMsg.style.color = ""; // 確保重置顏色
}
}
}
@ -795,6 +876,428 @@ async function saveSystemSettings() {
}
}
// ============================================================================
// 🌟 新增:設備備份與還原 (Backup & Restore)
// ============================================================================
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = [];
// 1. 建立新快照
async function createSnapshot() {
// 🛡️ 防線一:必須先連線才能備份
if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
}
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!");
// 🟢 抓取描述輸入框的值
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot');
const msg = document.getElementById('backup-status-msg');
btn.disabled = true;
msg.style.display = 'inline-block';
try {
const response = await fetch('/api/v1/backups/snapshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass,
snapshot_name: snapshotName,
description: snapshotDescription, // 🟢 將描述傳給後端
config_type: configType
})
});
const result = await response.json();
if (result.status === 'success') {
alert(`${result.message}`);
document.getElementById('snapshotName').value = ''; // 清空名稱輸入框
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框
}
loadBackupHistory(); // 重新載入歷史紀錄
} else {
alert(`❌ 備份失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
} finally {
btn.disabled = false;
msg.style.display = 'none';
}
}
// 2. 載入歷史紀錄列表 (維持 IP 綁定,避免後端報錯)
async function loadBackupHistory() {
// 🌟 補回取得當前畫面上設定的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">⏳ 正在載入歷史紀錄...</td></tr>';
try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=all`);
const result = await response.json();
if (result.status === 'success') {
allBackupsData = result.data;
applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染
} else {
// 加入防呆:如果後端回傳 422 (detail),也能正確印出錯誤而不是 undefined
const errMsg = result.message || (result.detail ? JSON.stringify(result.detail) : '未知錯誤');
tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 載入失敗: ${errMsg}</td></tr>`;
}
} catch (error) {
tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 連線失敗: ${error.message}</td></tr>`;
}
}
// 💡 前端多維度過濾器邏輯
window.applyBackupFilters = function() {
const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return;
// 取得所有過濾條件 (加上 trim() 避免不小心多敲了空白鍵)
const keyword = (document.getElementById('filter-keyword')?.value || '').trim().toLowerCase();
const type = document.getElementById('filter-type')?.value || '';
const dateStart = document.getElementById('filter-date-start')?.value;
const dateEnd = document.getElementById('filter-date-end')?.value;
// 進行陣列過濾
const filteredData = allBackupsData.filter(item => {
// 💡 終極防呆版:確保變數存在,並同時搜尋名稱與描述
const safeName = (item.snapshot_name || '').toLowerCase();
const safeDesc = (item.description || '').toLowerCase();
const matchKeyword = safeName.includes(keyword) || safeDesc.includes(keyword);
const matchType = type === '' || item.config_type === type;
let matchDate = true;
const itemDate = new Date(item.timestamp);
if (dateStart) {
const start = new Date(dateStart);
start.setHours(0, 0, 0, 0);
if (itemDate < start) matchDate = false;
}
if (dateEnd) {
const end = new Date(dateEnd);
end.setHours(23, 59, 59, 999);
if (itemDate > end) matchDate = false;
}
return matchKeyword && matchType && matchDate;
});
// 渲染過濾後的結果
if (filteredData.length > 0) {
tbody.innerHTML = filteredData.map(item => `
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
<!-- 🟢 新增描述欄位加上防撐破設計與 title 提示 -->
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
</td>
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
<td style="padding: 12px 10px; text-align: right;">
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁 檢視</button>
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態"> 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑 刪除</button>
</td>
</tr>
`).join('');
} else {
// 🟢 修正沒有資料時colspan 改為 5
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
}
};
// 3. 檢視特定快照內容
async function viewBackup(backupId) {
openModal("載入快照中...");
const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">⏳ 正在向資料庫撈取快照資料,請稍候...</span>`;
try {
const response = await fetch(`/api/v1/backups/${backupId}`);
const result = await response.json();
if (result.status === 'success') {
// 確保這行真的有加進去,且檔案有按下 Ctrl+S 儲存!
const data = result.data;
// 🟤 將描述加入彈出視窗的標頭中
const descText = data.description ? data.description : '無';
const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【備份描述】: ${descText}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`;
// 如果後端有回傳 raw_cli 就優先顯示,否則將 parsed_tree 轉為字串顯示
const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2);
outputEl.style.color = "#e0e0e0";
outputEl.textContent = infoHeader + content;
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 讀取失敗: ${result.message}`;
}
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線失敗: ${error.message}`;
}
}
// 4. 刪除快照
async function deleteBackup(backupId) {
if (!confirm("⚠️ 確定要永久刪除這筆備份紀錄嗎?此動作無法復原!")) return;
try {
const response = await fetch(`/api/v1/backups/${backupId}`, { method: 'DELETE' });
const result = await response.json();
if (result.status === 'success') {
loadBackupHistory(); // 刪除成功後重新整理列表
} else {
alert(`❌ 刪除失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
}
}
// 5. 請求設備差異分析 (安全還原第一步)
// 🌟 接收第三個參數 backupHost
async function restoreBackup(snapshotId, snapshotName, backupHost) {
// 🛡️ 防線一:必須先連線
if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行還原任務");
}
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
// 🛡️ 防線二:嚴格阻斷跨設備還原
if (backupHost && backupHost !== connInfo.host) {
alert(
`🚨 【跨設備還原阻斷】🚨\n\n` +
`這份快照屬於設備:${backupHost}\n` +
`您目前連線的設備:${connInfo.host}\n\n` +
`⚠️ 系統目前禁止跨設備還原,以防止嚴重的網路衝突。請先連線至正確的設備再執行此操作!`
);
return; // 直接中斷,不進行後續 API 呼叫
}
const modal = document.getElementById('outputModal');
const modalOutput = document.getElementById('modalOutput');
const modalTargetInfo = document.getElementById('modalTargetInfo');
// 1. 移除進度條,改為純文字百分比動態顯示
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
modalOutput.innerHTML = `
<div id="diff-progress-text" style="color: #3498db; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置並與快照進行深度比對... (0%)</div>
`;
modal.classList.add('active');
// 啟動漸進式模擬進度邏輯 (最高停在 95%)
let progress = 0;
const progressInterval = setInterval(() => {
progress += (95 - progress) * 0.08;
const text = document.getElementById('diff-progress-text');
if (text) {
text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (${Math.round(progress)}%)`;
}
}, 400);
try {
const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass })
});
const result = await response.json();
// API 回傳後,清除計時器並將進度填滿 100%
clearInterval(progressInterval);
const text = document.getElementById('diff-progress-text');
if (text) {
text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (100%) - 分析完成!`;
}
// 稍微延遲 0.5 秒讓使用者看清楚 100%,再渲染結果
setTimeout(() => {
if (result.status === 'success') {
const commands = result.data.commands;
let diffHtml = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單:</div>`;
if (commands.length === 0) {
diffHtml += `<div style="color: #27ae60; font-weight: bold; padding: 10px; background: rgba(39, 174, 96, 0.1); border-radius: 5px;">✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!</div>`;
} else {
const safeCommands = JSON.stringify(commands).replace(/"/g, '&quot;');
// 2. 更改按鈕顏色:使用亮藍色 (#2980b9) 並加上陰影與邊框,凸顯立體感
modalTargetInfo.innerHTML = `
正在分析快照差異: ${snapshotName}
<span style="display: inline-flex; gap: 8px; margin-left: 35px; vertical-align: middle;">
<button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button>
<button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button>
</span>
<button id="btn-confirm-restore" onclick="executeSmartRestore('${snapshotId}', '${snapshotName}', ${safeCommands})" class="btn-modern btn-save" style="position: absolute; right: 45px; top: 12px; background-color: #e74c3c; padding: 6px 12px; font-size: 13px; border-radius: 4px; color: white; border: none; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;"> 確認無誤立即寫入設備並 Commit</button>
`;
diffHtml += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`;
commands.forEach(cmd => {
if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`;
else diffHtml += `<span style="color: #2ecc71;">+ ${cmd}</span>\n`;
});
diffHtml += `</pre>`;
diffHtml += `<pre id="view-cli" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: none; color: #ecf0f1;">`;
commands.forEach(cmd => { diffHtml += `${cmd}\n`; });
diffHtml += `<span style="color: #f39c12;">commit</span>\n`;
diffHtml += `</pre>`;
}
modalOutput.innerHTML = diffHtml;
} else {
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗:\n${result.message}</span>`;
}
}, 500);
} catch (error) {
clearInterval(progressInterval);
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`;
}
}
// 6. 真正執行差異還原 (串接後端 Streaming API)
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎?`)) return;
// 1. 優雅地將按鈕反灰 (Disabled)
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore'];
btnIds.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
btn.style.pointerEvents = 'none';
if (id === 'btn-confirm-restore') {
btn.innerText = '⏳ 正在寫入設備中...';
btn.style.backgroundColor = '#7f8c8d';
}
}
});
const connInfo = getGlobalConnectionInfo();
const modalOutput = document.getElementById('modalOutput');
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
// 移除 min-height 和 overflow-y讓它完全撐滿父容器
modalOutput.innerHTML = `
<div id="restore-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; box-sizing: border-box;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] 正在透過 SSH 寫入還原指令請勿關閉視窗...</div>
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
</div>
`;
const logContainer = document.getElementById('restore-log-container');
try {
// 3. 發送 Fetch 請求 (不使用 await response.json())
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
});
// 4. 處理 ReadableStream (串流接收 NDJSON)
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 將新收到的位元組解碼並加入緩衝區
buffer += decoder.decode(value, { stream: true });
// 用換行符號切割出完整的 JSON 字串
let lines = buffer.split('\n');
// 把最後一行 (可能還沒傳完的半截字串) 留到下一回合處理
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
// 根據狀態渲染不同顏色的 Log
if (data.status === 'progress') {
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
} else if (data.status === 'success') {
// 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
let cleanOutput = data.output || '無';
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
logContainer.innerHTML += `
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 15px;">[${timeStr}] ${data.message}</div>
<div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳<br><span style="color: #bdc3c7;">${cleanOutput}</span></div>
`;
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
} else if (data.status === 'error') {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
if (data.output) {
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
}
// 發生錯誤時,恢復按鈕讓使用者可以重試或關閉
restoreButtonsState(btnIds);
}
// 自動捲動到最底部
logContainer.scrollTop = logContainer.scrollHeight;
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
} catch (error) {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。<br>${error.message}</div>`;
restoreButtonsState(btnIds);
}
};
// 輔助函數:恢復按鈕狀態 (請放在 executeSmartRestore 下方)
function restoreButtonsState(btnIds) {
btnIds.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
btn.style.pointerEvents = 'auto';
if (id === 'btn-confirm-restore') {
btn.innerText = '⚠️ 確認無誤,重新嘗試寫入';
btn.style.backgroundColor = '#e74c3c';
}
}
});
}
// ============================================================================
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
@ -868,8 +1371,8 @@ function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
} else {
btn.classList.remove('is-busy');
// 恢復原本的文字
if (id === 'btn-scan-visible') btn.innerText = "🔍 掃描局部缺失選項";
if (id === 'btn-scan-global') btn.innerText = "🌍 掃描全域缺失選項";
if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失";
if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失";
}
}
});
@ -943,7 +1446,7 @@ window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; // 🌟 新增綁定
window.switchFilterMode = switchFilterMode;
// --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll;
@ -961,3 +1464,11 @@ window.executeSideCLI = executeSideCLI;
window.toggleChildCheckboxes = toggleChildCheckboxes;
window.updateParentCheckboxState = updateParentCheckboxState;
window.closeModal = closeModal;
// --- 備份與還原 ---
window.createSnapshot = createSnapshot;
window.loadBackupHistory = loadBackupHistory;
window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域

View File

@ -3,7 +3,7 @@
import {
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
apiSyncLeafOptionsStream // 🌟 新增這一個
apiSyncLeafOptionsStream
} from './api.js';
import { getGlobalConnectionInfo } from './utils.js';
@ -24,19 +24,17 @@ function escapeHTML(str) {
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {};
// 💡 追蹤當前編輯狀態
export let currentEditPath = null;
export let currentEditElementId = null;
export let currentEditIsSuccess = false;
export let currentEditType = null;
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑
export let currentEditDiffs = [];
export async function releaseAllLocks() {
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (keysToRelease.length === 0) return;
const releasePromises = keysToRelease.map(key => {
// 🌟 解析出 host 與 path
const [host, path] = key.split('@@');
clearInterval(ACTIVE_HEARTBEATS[key]);
delete ACTIVE_HEARTBEATS[key];
@ -49,7 +47,6 @@ export async function releaseAllLocks() {
location.reload();
}
// 🌟 加入 host 與 lockKey 參數
async function sendHeartbeat(path, host, intervalId, lockKey) {
try {
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
@ -74,7 +71,7 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
export async function startEditFolder(path, elementId) {
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : ''; // 🌟 取得設備 IP
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
@ -89,7 +86,6 @@ export async function startEditFolder(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
// 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) editBtn.style.display = 'none';
@ -102,21 +98,17 @@ export async function startEditFolder(path, elementId) {
const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) {
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯
return;
}
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {};
// 🌟 修改 1傳入 host 給 apiGetLeafOptions
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
const pathsToSync = [];
// ==========================================
// 🌟 效能急救:分批渲染 (Chunking) 避免卡死主執行緒
// ==========================================
const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50; // 每次處理 50 個,確保畫面不結凍
const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
@ -159,12 +151,10 @@ export async function startEditFolder(path, elementId) {
container.innerHTML = inputHtml;
});
// 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
await new Promise(resolve => setTimeout(resolve, 0));
}
// 🌟 傳入 host 給 apiSyncLeafOptions
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
}
} catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message);
@ -199,19 +189,16 @@ export async function startEditLeaf(path, elementId) {
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try {
// 🌟 修改 1傳入 host
let optData = await apiGetLeafOptions(host, currentMode);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey];
if (!leafCache) {
// 🌟 修改 2傳入 host
apiSyncLeafOptions(host, [cacheKey], currentMode);
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
// 🌟 修改 3傳入 host
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
@ -301,7 +288,6 @@ export async function cancelEditLeaf(path, elementId) {
// 3. CLI 生成與預覽 (CLI Generation)
// ==========================================
function getInterfacesWithAdminState() {
// 🌟 限制只從當前顯示的樹狀圖中抓取狀態
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (!activeContainer) return [];
@ -343,7 +329,7 @@ export async function previewFolderCLI(path, elementId) {
});
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
currentEditDiffs = diffs;
try {
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
@ -375,7 +361,7 @@ export async function previewLeafCLI(path, elementId) {
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
currentEditDiffs = diffs;
try {
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
@ -390,7 +376,6 @@ export async function previewLeafCLI(path, elementId) {
}
function showCliPreviewModal(cliScript) {
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview');
@ -407,12 +392,10 @@ function showCliPreviewModal(cliScript) {
const textarea = document.getElementById('side-cli-textarea');
textarea.value = cliScript;
// 👇 確保正常模式可以編輯,並明確恢復深色主題
textarea.readOnly = false;
textarea.style.backgroundColor = '#1e1e1e'; // 恢復成標準的深色背景 (您可以依喜好微調顏色碼)
textarea.style.color = '#ecf0f1'; // 確保字體是清晰的淺灰色
textarea.style.backgroundColor = '#1e1e1e';
textarea.style.color = '#ecf0f1';
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
rightPane.style.width = 'calc(50% - 11px)';
resizer.style.display = 'flex';
@ -428,7 +411,6 @@ function showCliPreviewModal(cliScript) {
// 4. 側邊欄與執行邏輯
// ==========================================
export async function hideSideCLI() {
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%');
document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none';
@ -499,15 +481,79 @@ async function executeGeneratedCLI(script) {
const outputEl = document.getElementById('side-execution-result');
// 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格)
outputEl.innerHTML = `
<div id="edit-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; margin: 0; box-sizing: border-box;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] 正在透過 SSH 寫入指令請勿關閉視窗...</div>
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
</div>
`;
const logContainer = document.getElementById('edit-log-container');
// 🌟 修正 1: 捲動目標必須是真正有捲軸的容器
const scrollTarget = document.getElementById('side-execution-result');
try {
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass);
const response = await fetch('/api/v1/cmts-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: script,
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass
})
});
if (result.status === 'success') {
currentEditIsSuccess = true;
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let isSuccess = false;
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
if (data.status === 'progress') {
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
} else if (data.status === 'success') {
isSuccess = true;
logContainer.innerHTML += `
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 14px;">[${timeStr}] ${data.message}</div>
`;
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在同步快取...';
document.getElementById('side-pane-title').style.color = '#f39c12';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...</span>`;
} else if (data.status === 'error') {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
if (data.output) {
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
}
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
}
// 自動捲動到最底部
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
// 4. 串流結束後,執行快取同步
if (isSuccess) {
currentEditIsSuccess = true;
const pathsToSync = currentEditDiffs.map(d => {
let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
@ -517,39 +563,67 @@ async function executeGeneratedCLI(script) {
const uniquePaths = [...new Set(pathsToSync)];
try {
// 🌟 修改:取得當前模式,並將 connInfo.host 與 currentMode 傳給 apiSyncLeafOptionsStream
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
let isSyncDone = false;
await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
if (data.event === 'done') {
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成';
document.getElementById('side-pane-title').style.color = '#2ecc71';
// 🌟 修正 2: 先啟動 SSE 監聽 (避免錯過 done 事件)
const streamPromise = apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
const titleEl = document.getElementById('side-pane-title');
if (data.event === 'done' || data.status === 'done') {
isSyncDone = true;
if (titleEl) {
titleEl.innerHTML = '✅ 寫入成功!快取同步完成';
titleEl.style.color = '#2ecc71';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
} else if (data.event === 'error') {
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
document.getElementById('side-pane-title').style.color = '#e74c3c';
logContainer.innerHTML += `<br><span style="color: #2ecc71; font-weight: bold;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
} else if (data.event === 'error' || data.status === 'error') {
isSyncDone = true;
if (titleEl) {
titleEl.innerHTML = '⚠️ 寫入成功 (但同步快取失敗)';
titleEl.style.color = '#e74c3c';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message}</span>`;
logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, currentMode);
// 🌟 修正 3: 再觸發後端同步任務
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);
// 🌟 修正 4: 加入 8 秒防呆機制,如果後端默默做完沒通知,自動放行
setTimeout(() => {
if (!isSyncDone) {
const titleEl = document.getElementById('side-pane-title');
if (titleEl) {
titleEl.innerHTML = '✅ 寫入成功!(快取同步已於背景完成)';
titleEl.style.color = '#2ecc71';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
logContainer.innerHTML += `<br><span style="color: #f39c12; font-weight: bold;">[系統] 快取同步已於背景執行,您可以關閉此面板。</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, 8000);
await streamPromise;
} catch (syncErr) {
console.error("同步設定失敗:", syncErr);
document.getElementById('btn-side-close').style.display = 'inline-block';
}
} else {
currentEditIsSuccess = false;
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
const rawLog = result.detail || result.message || '';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${highlightTerminalOutput(rawLog)}</span>`;
}
} catch (error) {
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
outputEl.innerHTML = `<span style="color: #ff6b6b; font-weight: bold;">連線或伺服器錯誤: ${error}</span>`;
logContainer.innerHTML += `<div style="color: #ff6b6b; font-weight: bold; margin-top: 10px;">連線或伺服器錯誤: ${error}</div>`;
}
}
@ -558,7 +632,6 @@ async function executeGeneratedCLI(script) {
// ==========================================
function initResizer() {
const resizer = document.getElementById('drag-resizer');
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const rightPane = document.getElementById('side-cli-preview');
const container = document.getElementById('split-container');
@ -581,7 +654,6 @@ function initResizer() {
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
const rightPercentage = 100 - leftPercentage;
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
});
@ -639,11 +711,9 @@ function transformNoToActive(elementId, newCommand) {
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
// ============================================================================
export async function previewNodeCLI(btnElement, rootPath) {
// 1. 定位當前點擊的容器
let container = btnElement.closest('details');
if (container) {
const elementId = container.id.replace('details-', '');
// 確保延遲渲染的內容已經載入 (Lazy Load)
if (container.dataset.loaded !== 'true') {
await window.lazyLoadFolder(elementId, false);
}
@ -651,7 +721,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
const targetNode = container || btnElement.closest('.tree-leaf-node');
if (!targetNode) return;
// 2. 收集所有子節點資料
const leafNodes = targetNode.querySelectorAll('.leaf-container');
const interfaceGroups = {};
@ -662,7 +731,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
const parts = path.split('::');
const paramName = parts.pop();
// 移除虛擬陣列索引 [0], [1] 等,還原真實介面路徑
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
if (!interfaceGroups[interfacePath]) {
@ -671,7 +739,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
interfaceGroups[interfacePath].push({ param: paramName, val: val });
});
// 補強邏輯:處理使用者直接點擊葉節點的情況
if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
const singleNode = targetNode.querySelector('.leaf-container');
if (singleNode) {
@ -688,22 +755,18 @@ export async function previewNodeCLI(btnElement, rootPath) {
return alert("此節點下沒有任何數值可供預覽!");
}
// 3. 在前端組裝平坦化 CLI (模擬真實設備指令)
let cliLines = [];
cliLines.push("! --- Read-Only Configuration Preview ---");
for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
cliLines.push(`! Node: ${interfacePath || 'Global'}`);
params.forEach(({param, val}) => {
if (/^\[\d+\]$/.test(param)) {
// 陣列元素本身就是值
if (val) {
cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
}
} else if (param === 'no') {
// 處理 no 指令
cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
} else {
// 一般鍵值對 (若 val 為空,代表是單純的 flag 指令)
let line = interfacePath ? `${interfacePath} ${param}` : param;
if (val) line += ` ${val}`;
cliLines.push(line);
@ -713,11 +776,9 @@ export async function previewNodeCLI(btnElement, rootPath) {
}
const finalScript = cliLines.join('\n');
// 4. 顯示唯讀預覽面板
showReadOnlyCliPreviewModal(finalScript, rootPath);
}
// 專為 God-Mode 設計的唯讀面板 UI
function showReadOnlyCliPreviewModal(cliScript, rootPath) {
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer');
@ -726,7 +787,6 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) {
document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
document.getElementById('side-pane-title').style.color = '#3498db';
// 隱藏執行按鈕,只保留關閉
document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-cancel').style.display = 'none';
const closeBtn = document.getElementById('btn-side-close');
@ -736,8 +796,8 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) {
const textarea = document.getElementById('side-cli-textarea');
textarea.style.display = 'block';
textarea.value = cliScript;
textarea.readOnly = true; // 強制唯讀
textarea.style.backgroundColor = '#2c3e50'; // 改變背景色提示唯讀狀態
textarea.readOnly = true;
textarea.style.backgroundColor = '#2c3e50';
document.getElementById('side-execution-result').style.display = 'none';

View File

@ -96,7 +96,7 @@ h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-si
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; background-color: transparent; }
/* 4. 內容區塊 */
.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; }
.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; }
/* 5. 控制列樣式 (微縮元件) */
@ -106,8 +106,8 @@ button { padding: 6px 14px; background-color: #2980b9; color: white; border: non
button:hover { background-color: #3498db; }
/* 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; }
.xterm { padding: 10px; }
#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; height: 100%; box-sizing: border-box; }
/* 7. 專業級表單排版 (🌟 縮小間距,解決空白過多) */
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 12px; }
@ -134,8 +134,34 @@ button:hover { background-color: #3498db; }
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
.modal-close:hover { color: #e74c3c; }
.modal-body { flex-grow: 1; padding: 20px; overflow: auto; background-color: #1e1e1e; }
.readonly-terminal { margin: 0; color: #e0e0e0; font-family: 'Courier New', Courier, monospace; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
.modal-body {
flex-grow: 1;
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 {
@ -238,4 +264,12 @@ button:hover { background-color: #3498db; }
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; } }

View File

@ -1,3 +0,0 @@
{
"hidden_keys": []
}