feat: 前端串流接收器 (Streaming UI)

feat: 後端 SSH 交易寫入管道 (Transactional SSH Pipeline)

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

feat: Streaming、Fail-safe 以及全域併發鎖
This commit is contained in:
swpa 2026-05-29 15:16:46 +08:00
parent 9a3850031c
commit 61241c796e
6 changed files with 219 additions and 94 deletions

View File

@ -8,8 +8,8 @@
### 📂 目錄與檔案結構 ### 📂 目錄與檔案結構
- **進入點**: `main.py` - **進入點**: `main.py`
- **路由管理**: API 路由統一放置於 `routers/` 目錄。 - **路由管理**: API 路由統一放置於 `routers/` 目錄。包含 backup.py, config.py, leaf_options.py, lock.py, query.py, terminal.py。
- **前端介面**: `index.html` 與 `static/` 目錄。 - **前端介面**: `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`: 負責底層爬蟲與資料處理。 - `cmts_scraper.py`: 負責底層爬蟲與資料處理。
- `shared.py`: 放置共用函式 (如兩階段解析法)。 - `shared.py`: 放置共用函式 (如兩階段解析法)。
@ -20,6 +20,8 @@
## 2. 核心架構與業務邏輯 (Architecture & Logic) ## 2. 核心架構與業務邏輯 (Architecture & Logic)
- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。 - **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。
- **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `BATCH_SIZE = 30` 搭配 `asyncio.sleep()` 進行非同步批次處理。 - **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `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 頻道分流,即時推播進度。 - **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。
- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。 - **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。
@ -36,6 +38,11 @@
6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。 6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。 7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。 8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。
9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`。
### 🎨 前端規範 ### 🎨 前端規範
9. **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` - **進入點**: `main.py`
- **路由管理**: API 路由統一放置於 `routers/` 目錄。 - **路由管理**: API 路由統一放置於 `routers/` 目錄。包含 backup.py, config.py, leaf_options.py, lock.py, query.py, terminal.py。
- **前端介面**: `index.html``static/` 目錄。 - **前端介面**: `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`: 負責底層爬蟲與資料處理。 - `cmts_scraper.py`: 負責底層爬蟲與資料處理。
- `shared.py`: 放置共用函式 (如兩階段解析法)。 - `shared.py`: 放置共用函式 (如兩階段解析法)。
@ -20,6 +20,8 @@
## 2. 核心架構與業務邏輯 (Architecture & Logic) ## 2. 核心架構與業務邏輯 (Architecture & Logic)
- **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。 - **兩階段解析法**: 依賴「縮排」與「`!`」劃分區塊,並動態降維成深層巢狀結構,使用 `deep_merge` 確保資料不遺失。
- **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `BATCH_SIZE = 30` 搭配 `asyncio.sleep()` 進行非同步批次處理。 - **動態探測**: 透過發送 `[指令] ?` 動態學習資料結構,並採用 `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 頻道分流,即時推播進度。 - **併發與狀態廣播**: 支援「父子繼層攔截」的路徑階層鎖,並利用 `asyncio.Queue` 實作 SSE 頻道分流,即時推播進度。
- **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。 - **配置轉譯器**: 自動補齊父層級路徑,處理 `no [指令]` 刪除邏輯,並具備 `admin-state` 的生命週期防呆機制。
@ -36,6 +38,11 @@
6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。 6. **嚴守「雙軌並行」**: 必須在任何 API 請求明確傳遞並驗證 `config_type` ('running' 或 'full')。絕對禁止 `running` 污染 `full` 快取。
7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。 7. **無痛切換 (Feature Toggle)**: 必須保留 `USE_DB` 開關,若 DB 連線異常,必須能自動退回使用 JSON 檔案讀寫。
8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。 8. **無聲錯誤是原罪**: 所有設備互動模組必須使用 `try-except`,並回傳標準 JSON `{"status": "error", "message": "..."}`。嚴禁 FastAPI 直接拋出 500。
9. **CLI 執行策略 (Absolute Path Strategy)**: 針對設備寫入或還原設定時,一律採用「絕對路徑」指令,無需模擬傳統 Cisco IOS 的模式切換。寫入腳本的結尾或區塊末端,務必加上 `commit`
### 🎨 前端規範 ### 🎨 前端規範
9. **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

@ -9,20 +9,29 @@
### Phase 1: PostgreSQL 高可用性架構升級 (Completed) ### Phase 1: PostgreSQL 高可用性架構升級 (Completed)
- [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。 - [x] 成功導入 `asyncpg`,建立 `database.py` 管理非同步資料庫連線池。
- [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。 - [x] 建立 `cmts_options`, `device_status`, `system_filters` 資料表,嚴格遵守 `config_type` 雙軌隔離的主鍵設計。
- [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動捕捉錯誤並退回使用 JSON 檔案讀寫,防止 FastAPI Crash - [x] 實踐完整的「**Zero-Downtime Fallback 機制**」:資料庫連線異常時,自動退回使用 JSON 檔案讀寫。
- [x] 調整 `init_db.py` 為非同步啟動腳本。 - [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) ## 🚧 目前開發階段 (Current Phase)
### Phase 2: 設備配置備份與快照機制 (Configuration Backup & Snapshots) ### Phase 3: 智慧差異還原與歷史預覽 (Smart Diff Recovery & Preview)
- [ ] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (核心欄位需包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`)。 - [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。
- [ ] **後端 API 實作**:新增「手動建立快照 (Manual Snapshot)」與「取得歷史快照列表」的 RESTful API 路由。 - [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)。
- [ ] **自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。 - [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) ## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
- **[未來計畫] Phase 3: 歷史預覽 (History Preview)**:前端 UI 支援點擊歷史快照,將 `parsed_tree` 載入 `tree-ui.js` 並強制標示為「唯讀模式 (Read-only)」。 - **[未來計畫] 自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。
- **[未來計畫] Phase 4: 智慧還原 (Smart Recovery)**:實作 Diff-based Rollback 演算法。比對歷史備份與當前樹狀結構,自動產生反向 CLI 指令 (`no [指令]`),並在推送到設備前提供 Dry-Run 預覽確認視窗。

View File

@ -360,7 +360,10 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
break break
return output return output
# 🌟 關鍵修改:根據 config_type 切換模式與指令 # 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
await read_until_quiet(timeout=1.0)
# 關鍵修改:根據 config_type 切換模式與指令
if config_type == "full": if config_type == "full":
# 1. 先進入 config 模式 # 1. 先進入 config 模式
process.stdin.write("config\n") process.stdin.write("config\n")
@ -397,10 +400,13 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
# 3. 清除純文字的 --More-- (防呆) # 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output) cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 🌟 新增 4清除 CableOS 終端機特有的 (END) 結尾標記
cleaned_output = re.sub(r'\s*\(END\)\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行! # 關鍵修正:這裡改用 cleaned_output 來切行!
lines = cleaned_output.splitlines() lines = cleaned_output.splitlines()
# 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元 # 🌟 關鍵修正 2加入 strip() 避免空白干擾,確保精準踢掉提示字元
clean_lines = [ clean_lines = [
line for line in lines line for line in lines

View File

@ -2,7 +2,9 @@ import logging
import asyncssh import asyncssh
import asyncio import asyncio
import re import re
import json
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
@ -15,7 +17,7 @@ from database import (
) )
from cmts_scraper import fetch_raw_config from cmts_scraper import fetch_raw_config
from shared import parse_cli_to_tree # <== 引入真正的解析器 from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -209,64 +211,109 @@ async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
logger.error(f"差異分析失敗: {str(e)}") logger.error(f"差異分析失敗: {str(e)}")
return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"} return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"}
@router.post("/{backup_id}/restore", summary="執行差異還原 (寫入指定的指令清單)")
@router.post("/{backup_id}/restore", summary="執行差異還原 (串流即時回報)")
async def execute_restore(backup_id: str, req: ExecuteRestoreRequest): async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
commands = req.commands commands = req.commands
if not commands: if not commands:
return {"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"} async def empty_success():
yield json.dumps({"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}) + "\n"
return StreamingResponse(empty_success(), media_type="application/x-ndjson")
output_log = [] async def restore_generator():
try: # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
conn = await asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) if cmts_config_lock.locked():
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') 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): async def read_until_quiet(timeout=1.0):
output = "" output = ""
while True: while True:
try: try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break if not chunk: break
output += chunk output += chunk
if "--More--" in chunk or "More" in chunk: if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ") process.stdin.write(" ")
await process.stdin.drain()
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 process.stdin.drain()
except asyncio.TimeoutError: await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式
break
return output # 3. 回報最終錯誤並關閉連線
yield json.dumps({
"status": "error",
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
"output": clean_out
}) + "\n"
conn.close()
return
# 進入設定模式 yield json.dumps({
process.stdin.write("config\n") "status": "progress",
await process.stdin.drain() "message": f"執行: {cmd}",
await read_until_quiet(timeout=1.5) "output": clean_out
}) + "\n"
# 稍微讓出控制權,確保串流順暢
await asyncio.sleep(0.05)
# 逐行寫入精準的差異指令 # 4. 提交變更 (Commit)
for cmd in commands: yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
process.stdin.write(f"{cmd}\n") process.stdin.write("commit\n")
await process.stdin.drain() await process.stdin.drain()
out = await read_until_quiet(timeout=0.2) commit_out = await read_until_quiet(timeout=6.0)
output_log.append(out) clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 提交變更 # 5. 退出並關閉連線
process.stdin.write("commit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
commit_out = await read_until_quiet(timeout=2.0) conn.close()
output_log.append(commit_out)
# 退出並關閉連線 yield json.dumps({
process.stdin.write("exit\n") "status": "success",
await process.stdin.drain() "message": "✅ 所有差異還原指令已成功送出並 Commit",
conn.close() "output": clean_commit
}) + "\n"
clean_log = re.sub(r'\x1b\[[0-9;]*[mGK]', '', "".join(output_log)) except Exception as e:
logger.error(f"還原執行失敗: {str(e)}")
yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n"
return { return StreamingResponse(restore_generator(), media_type="application/x-ndjson")
"status": "success",
"message": "差異還原指令已成功送出並 Commit",
"output": clean_log
}
except Exception as e:
logger.error(f"還原執行失敗: {str(e)}")
return {"status": "error", "message": f"SSH 執行失敗: {str(e)}"}

View File

@ -1129,11 +1129,11 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
} }
} }
// 6. 真正執行差異還原 (串接後端 API) // 6. 真正執行差異還原 (串接後端 Streaming API)
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) { window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎?`)) return; if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎?`)) return;
// 優雅地將按鈕反灰 (Disabled),而不是消失 // 1. 優雅地將按鈕反灰 (Disabled)
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore']; const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore'];
btnIds.forEach(id => { btnIds.forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
@ -1141,10 +1141,10 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
btn.disabled = true; btn.disabled = true;
btn.style.opacity = '0.5'; btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed'; btn.style.cursor = 'not-allowed';
btn.style.pointerEvents = 'none'; // 徹底阻絕點擊事件 btn.style.pointerEvents = 'none';
if (id === 'btn-confirm-restore') { if (id === 'btn-confirm-restore') {
btn.innerText = '⏳ 正在寫入設備中...'; btn.innerText = '⏳ 正在寫入設備中...';
btn.style.backgroundColor = '#7f8c8d'; // 變成灰色 btn.style.backgroundColor = '#7f8c8d';
} }
} }
}); });
@ -1152,48 +1152,97 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const modalOutput = document.getElementById('modalOutput'); const modalOutput = document.getElementById('modalOutput');
// 在原本的內容上方插入提示,保留指令預覽畫面讓使用者安心 // 2. 建立即時 Log 視窗 UI (模擬終端機風格)
const loadingMsg = document.createElement('div'); modalOutput.innerHTML = `
loadingMsg.innerHTML = `<div style="color: #f39c12; font-weight: bold; margin-bottom: 15px; padding: 10px; background: rgba(243, 156, 18, 0.1); border-radius: 5px;">⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...</div>`; <div style="color: #f39c12; font-weight: bold; margin-bottom: 10px; font-size: 15px;">
modalOutput.insertBefore(loadingMsg, modalOutput.firstChild); 正在透過 SSH 寫入指令至設備請勿關閉視窗...
</div>
<div id="restore-log-container" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; height: 350px; overflow-y: auto; color: #ecf0f1; border: 1px solid #333; font-size: 13px; line-height: 1.5;">
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
</div>
`;
const logContainer = document.getElementById('restore-log-container');
try { try {
// 3. 發送 Fetch 請求 (不使用 await response.json())
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, { const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands }) body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
}); });
const result = await response.json();
if (result.status === 'success') { // 4. 處理 ReadableStream (串流接收 NDJSON)
modalOutput.innerHTML = ` const reader = response.body.getReader();
<div style="color: #27ae60; font-weight: bold; margin-bottom: 10px; font-size: 16px;"> 還原成功設備已 Commit</div> const decoder = new TextDecoder("utf-8");
<div style="color: #ecf0f1; margin-bottom: 5px;">設備回傳結果</div> let buffer = "";
<pre style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; color: #ecf0f1;">${result.output}</pre>
`; while (true) {
document.getElementById('modalTargetInfo').innerHTML = `還原完成: ${snapshotName}`; const { done, value } = await reader.read();
} else { if (done) break;
loadingMsg.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 還原失敗:<br>${result.message}</span>`;
// 失敗的話,把按鈕恢復原狀,允許重試 // 將新收到的位元組解碼並加入緩衝區
btnIds.forEach(id => { buffer += decoder.decode(value, { stream: true });
const btn = document.getElementById(id);
if (btn) { // 用換行符號切割出完整的 JSON 字串
btn.disabled = false; let lines = buffer.split('\n');
btn.style.opacity = '1';
btn.style.cursor = 'pointer'; // 把最後一行 (可能還沒傳完的半截字串) 留到下一回合處理
btn.style.pointerEvents = 'auto'; buffer = lines.pop();
if (id === 'btn-confirm-restore') {
btn.innerText = '⚠️ 確認無誤,立即寫入設備並 Commit'; for (let line of lines) {
btn.style.backgroundColor = '#e74c3c'; 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') {
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;">${data.output || '無'}</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) { } catch (error) {
loadingMsg.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:<br>${error.message}</span>`; 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) // 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
// ============================================================================ // ============================================================================