feat(backup): 優化備份模組 UI 佈局與搜尋過濾防呆機制

- style: 重構「歷史備份紀錄」版面,移除 Flex 衝突,實作滿版分隔線與按鈕高度對齊。
- refactor: 將日期選擇器回歸原生 <input type="date">,支援作業系統語系顯示。
- fix(search): 升級 applyBackupFilters 邏輯,加入 Null-Safety (|| '') 與 .trim() 防禦性編程,避免空值導致畫面崩潰。
- feat(search): 擴充搜尋範圍,現在可同時精準比對「快照名稱」與「備份描述」。
This commit is contained in:
swpa 2026-05-28 22:24:57 +08:00
parent f8285f7ff7
commit 9a3850031c
5 changed files with 414 additions and 140 deletions

View File

@ -360,25 +360,53 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
break break
return output return output
# 根據 config_type 決定指令 (這裡以 running 為例,您可依設備指令集調整) # 🌟 關鍵修改:根據 config_type 切換模式與指令
cmd = "show running-config" if config_type == "running" else f"show config {config_type}" if config_type == "full":
# 1. 先進入 config 模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
process.stdin.write(f"{cmd}\n") # 2. 下達 full-configuration 指令
await process.stdin.drain() cmd = "show full-configuration"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
raw_output = await read_until_quiet(timeout=3.0) # 3. 抓完後退回上一層 (保持良好習慣)
process.stdin.write("exit\n")
await process.stdin.drain()
else:
# running-config 在一般模式即可下達
cmd = "show running-config"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
# 最終退出設備
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo) # 簡單清理頭尾的雜訊 (例如指令本身的 echo)
# 1. 清除 ANSI 控制碼 (如顏色、游標移動) 與退格鍵 \x08 # 🌟 關鍵修正 1強化 ANSI 正規表達式,加入對 '?' 的支援 (精準捕捉 \x1b[?7h)
cleaned_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]|\x08', '', raw_output) cleaned_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', raw_output)
# 2. 清除殘留的 --More-- 文字
cleaned_output = re.sub(r'--More--', '', cleaned_output)
lines = raw_output.splitlines() # 2. 清除失去 \x1b 殘留的字面分頁符號 (精準捕捉 [7m--More--[27m[8D[K)
clean_lines = [line for line in lines if not line.startswith(cmd) and not line.startswith("admin@")] cleaned_output = re.sub(r'\[7m\s*--More--\s*\[27m\[\d+D\[K', '', cleaned_output)
# 3. 清除純文字的 --More-- (防呆)
cleaned_output = re.sub(r'\s*--More--\s*', '', cleaned_output)
# 關鍵修正:這裡改用 cleaned_output 來切行!
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() return "\n".join(clean_lines).strip()
except Exception as e: except Exception as e:

View File

@ -253,6 +253,7 @@ async def insert_config_backup(
raw_cli: str, raw_cli: str,
parsed_tree: dict, parsed_tree: dict,
snapshot_name: Optional[str] = None, snapshot_name: Optional[str] = None,
description: str = "", # 🟢 新增描述參數 (預設為空字串)
is_auto: bool = False is_auto: bool = False
) -> Optional[str]: ) -> Optional[str]:
"""新增一筆設備配置備份,回傳產生的 Backup ID""" """新增一筆設備配置備份,回傳產生的 Backup ID"""
@ -262,10 +263,11 @@ async def insert_config_backup(
backup_id = str(uuid.uuid4()) backup_id = str(uuid.uuid4())
# 🟢 SQL 語句加入 description 與對應的 $5
query = """ query = """
INSERT INTO config_backups INSERT INTO config_backups
(id, host, config_type, snapshot_name, is_auto, raw_cli, parsed_tree) (id, host, config_type, snapshot_name, description, is_auto, raw_cli, parsed_tree)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
""" """
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
@ -275,6 +277,7 @@ async def insert_config_backup(
host, host,
config_type, config_type,
snapshot_name, snapshot_name,
description, # 🟢 傳入描述參數
is_auto, is_auto,
raw_cli, raw_cli,
json.dumps(parsed_tree) json.dumps(parsed_tree)
@ -288,21 +291,32 @@ async def insert_config_backup(
return None return None
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]: async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
"""取得歷史快照列表 (輕量級,不含完整設定檔內容)""" """取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
pool = await get_pool() pool = await get_pool()
if not pool: if not pool:
return None return None
query = """
SELECT id, host, config_type, timestamp, snapshot_name, is_auto
FROM config_backups
WHERE host = $1 AND config_type = $2
ORDER BY timestamp DESC;
"""
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
records = await conn.fetch(query, host, config_type) if config_type == "all":
# 將 asyncpg 的 Record 轉為 dict並將 datetime 轉為字串方便 JSON 序列化 # 🟢 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 [ return [
{ {
"id": str(r["id"]), "id": str(r["id"]),
@ -310,6 +324,7 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
"config_type": r["config_type"], "config_type": r["config_type"],
"timestamp": r["timestamp"].isoformat(), "timestamp": r["timestamp"].isoformat(),
"snapshot_name": r["snapshot_name"], "snapshot_name": r["snapshot_name"],
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
"is_auto": r["is_auto"] "is_auto": r["is_auto"]
} }
for r in records for r in records
@ -327,8 +342,9 @@ async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
if not pool: if not pool:
return None return None
# 🟢 SELECT 加入 description
query = """ query = """
SELECT id, host, timestamp, snapshot_name, is_auto, parsed_tree SELECT id, host, timestamp, snapshot_name, description, is_auto, parsed_tree
FROM config_backups FROM config_backups
WHERE id = $1; WHERE id = $1;
""" """
@ -344,6 +360,7 @@ async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
"host": record["host"], "host": record["host"],
"timestamp": record["timestamp"].isoformat(), "timestamp": record["timestamp"].isoformat(),
"snapshot_name": record["snapshot_name"], "snapshot_name": record["snapshot_name"],
"description": record["description"], # 🟢 將資料庫的描述放入回傳字典
"is_auto": record["is_auto"], "is_auto": record["is_auto"],
"parsed_tree": parsed_tree "parsed_tree": parsed_tree
} }

View File

@ -383,47 +383,97 @@
<div class="manager-container"> <div class="manager-container">
<!-- 上半部:建立新快照 --> <!-- 上半部:建立新快照 -->
<div class="control-group" style="background: #ffffff; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;"> <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;">
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; margin-bottom: 10px;">📸 建立新快照</h3>
<div style="display: flex; align-items: center; gap: 15px;"> <!-- 💡 修正:移除負 margin讓底線與內容邊界對齊 (如同系統設定頁籤) -->
<input type="text" id="snapshotName" placeholder="請輸入快照名稱 (例如: 例行備份)" style="width: 300px; padding: 8px; border: 1px solid #bdc3c7; border-radius: 4px;"> <div style="display: flex; justify-content: flex-start; align-items: center; gap: 15px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px;">
<select id="backupConfigType" style="padding: 8px; border: 1px solid #bdc3c7; border-radius: 4px;"> <h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px;">
<option value="running">Running Config</option> 📸 建立新快照
</select> </h3>
<button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save">🚀 立即備份</button>
<span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none;">⏳ 正在連線設備並抓取設定,請稍候...</span> <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> </div>
<!-- 下半部:歷史紀錄列表 --> <!-- 下半部:歷史紀錄列表 -->
<div class="control-group" style="background: #ffffff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;"> <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;">
<!-- 🎯 A 方案佈局:左右對齊 --> <!-- 💡 修正:移除負 margin讓底線與內容邊界對齊 -->
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 15px; width: 100%;"> <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;">
🗂️ 歷史備份紀錄 <h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px; white-space: nowrap;">
📁 歷史備份紀錄
</h3> </h3>
<!-- 🎨 沿用全域馬卡龍色系:使用 btn-modern 與 btn-slate (或 btn-scan) --> <div style="display: flex; gap: 10px; align-items: center;">
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="padding: 6px 12px; font-size: 13px;"> <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()">
🔄 重新整理
</button> <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> </div>
<!-- 表格區塊 -->
<table style="width: 100%; border-collapse: collapse; text-align: left;"> <table style="width: 100%; border-collapse: collapse; text-align: left;">
<!-- ... 下方的 table 保持不變 ... -->
<thead> <thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;"> <tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
<th style="padding: 10px; color: #34495e;">時間</th> <th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
<th style="padding: 10px; color: #34495e;">快照名稱</th> <th style="padding: 10px; color: #34495e; width: 25%;">快照名稱</th>
<th style="padding: 10px; color: #34495e;">類型</th> <th style="padding: 10px; color: #34495e; width: 25%;">描述</th>
<th style="padding: 10px; color: #34495e; text-align: right;">操作</th> <th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="backup-history-tbody"> <tbody id="backup-history-tbody">
<tr> <tr>
<td colspan="4" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td> <td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -32,6 +32,7 @@ class SnapshotRequest(BaseModel):
username: str username: str
password: str password: str
snapshot_name: str snapshot_name: str
description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述
config_type: str = "running" config_type: str = "running"
class RestoreRequest(BaseModel): class RestoreRequest(BaseModel):
@ -48,6 +49,10 @@ class ExecuteRestoreRequest(BaseModel):
# ========================================== # ==========================================
# 🛠️ 核心演算法:深度差異比對 (Deep Diff) # 🛠️ 核心演算法:深度差異比對 (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: def build_add_commands(node: dict, current_path: str) -> list:
"""遞迴展開所有需要新增的絕對路徑指令""" """遞迴展開所有需要新增的絕對路徑指令"""
commands = [] commands = []
@ -56,11 +61,13 @@ def build_add_commands(node: dict, current_path: str) -> list:
return commands return commands
for key, val in node.items(): for key, val in node.items():
# 🌟 新增這行:忽略系統中介資料
if key.startswith('_'): if key.startswith('_'):
continue continue
path = f"{current_path} {key}".strip() # 🌟 使用乾淨的 key 來組合路徑
real_key = clean_virtual_key(key)
path = f"{current_path} {real_key}".strip()
if isinstance(val, dict): if isinstance(val, dict):
commands.extend(build_add_commands(val, path)) commands.extend(build_add_commands(val, path))
else: else:
@ -72,23 +79,21 @@ def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path
"""比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單""" """比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
commands = [] commands = []
# 1. 找出需要刪除或覆蓋的 (存在於 current)
for key, curr_val in current_node.items(): for key, curr_val in current_node.items():
# 🌟 新增這行:忽略系統中介資料
if key.startswith('_'): if key.startswith('_'):
continue continue
path = f"{current_path} {key}".strip() # 🌟 使用乾淨的 key
real_key = clean_virtual_key(key)
path = f"{current_path} {real_key}".strip()
if key not in snapshot_node: if key not in snapshot_node:
# 快照裡沒有,代表這是後來新增的殘留設定 -> 刪除
commands.append(f"no {path}") commands.append(f"no {path}")
else: else:
snap_val = snapshot_node[key] snap_val = snapshot_node[key]
if isinstance(curr_val, dict) and isinstance(snap_val, dict): if isinstance(curr_val, dict) and isinstance(snap_val, dict):
commands.extend(generate_diff_commands(curr_val, snap_val, path)) commands.extend(generate_diff_commands(curr_val, snap_val, path))
elif isinstance(curr_val, dict) or isinstance(snap_val, dict): elif isinstance(curr_val, dict) or isinstance(snap_val, dict):
# 型態不一致,先刪除舊的再新增新的
commands.append(f"no {path}") commands.append(f"no {path}")
if isinstance(snap_val, dict): if isinstance(snap_val, dict):
commands.extend(build_add_commands(snap_val, path)) commands.extend(build_add_commands(snap_val, path))
@ -96,17 +101,17 @@ def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path
val_str = f" {snap_val}" if snap_val else "" val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}") commands.append(f"{path}{val_str}")
elif curr_val != snap_val: elif curr_val != snap_val:
# 兩邊都是最終值,但內容不同 -> 直接覆寫
val_str = f" {snap_val}" if snap_val else "" val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}") commands.append(f"{path}{val_str}")
# 2. 找出需要新增的 (存在於 snapshot但 current 沒有)
for key, snap_val in snapshot_node.items(): for key, snap_val in snapshot_node.items():
# 🌟 新增這行:忽略系統中介資料
if key.startswith('_'): if key.startswith('_'):
continue continue
path = f"{current_path} {key}".strip() # 🌟 使用乾淨的 key
real_key = clean_virtual_key(key)
path = f"{current_path} {real_key}".strip()
if key not in current_node: if key not in current_node:
if isinstance(snap_val, dict): if isinstance(snap_val, dict):
commands.extend(build_add_commands(snap_val, path)) commands.extend(build_add_commands(snap_val, path))
@ -152,6 +157,7 @@ async def create_snapshot(req: SnapshotRequest):
raw_cli=raw_cli, raw_cli=raw_cli,
parsed_tree=parsed_tree, parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name, snapshot_name=req.snapshot_name,
description=req.description, # 🟢 將描述傳遞給資料庫函數
is_auto=False is_auto=False
) )
@ -180,7 +186,12 @@ async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
if not snapshot_tree: if not snapshot_tree:
return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"} return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
raw_current = await fetch_raw_config(req.host, req.username, req.password, "running") # 🌟 關鍵修正:動態取得該快照的 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: if not raw_current:
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"} return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}

View File

@ -346,8 +346,12 @@ function resetAllManagerData() {
document.getElementById('queryTargetCm').value = ''; document.getElementById('queryTargetCm').value = '';
document.getElementById('queryTargetRpd').value = ''; document.getElementById('queryTargetRpd').value = '';
}
// 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
if (typeof loadBackupHistory === 'function') {
loadBackupHistory();
}
}
// ============================================================================ // ============================================================================
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config) // 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
@ -823,14 +827,24 @@ async function saveSystemSettings() {
// 🌟 新增:設備備份與還原 (Backup & Restore) // 🌟 新增:設備備份與還原 (Backup & Restore)
// ============================================================================ // ============================================================================
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = [];
// 1. 建立新快照 // 1. 建立新快照
async function createSnapshot() { async function createSnapshot() {
// 🛡️ 防線一:必須先連線才能備份
if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
}
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先在畫面上方輸入設備連線資訊!"); if (!connInfo || !connInfo.host) return;
const snapshotName = document.getElementById('snapshotName').value.trim(); const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!"); if (!snapshotName) return alert("請輸入快照名稱!");
// 🟢 抓取描述輸入框的值
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
const configType = document.getElementById('backupConfigType').value; const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot'); const btn = document.getElementById('btnCreateSnapshot');
const msg = document.getElementById('backup-status-msg'); const msg = document.getElementById('backup-status-msg');
@ -847,6 +861,7 @@ async function createSnapshot() {
username: connInfo.user, username: connInfo.user,
password: connInfo.pass, password: connInfo.pass,
snapshot_name: snapshotName, snapshot_name: snapshotName,
description: snapshotDescription, // 🟢 將描述傳給後端
config_type: configType config_type: configType
}) })
}); });
@ -854,7 +869,10 @@ async function createSnapshot() {
if (result.status === 'success') { if (result.status === 'success') {
alert(`${result.message}`); alert(`${result.message}`);
document.getElementById('snapshotName').value = ''; // 清空輸入框 document.getElementById('snapshotName').value = ''; // 清空名稱輸入框
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框
}
loadBackupHistory(); // 重新載入歷史紀錄 loadBackupHistory(); // 重新載入歷史紀錄
} else { } else {
alert(`❌ 備份失敗: ${result.message}`); alert(`❌ 備份失敗: ${result.message}`);
@ -867,44 +885,95 @@ async function createSnapshot() {
} }
} }
// 2. 載入歷史紀錄列表 // 2. 載入歷史紀錄列表 (維持 IP 綁定,避免後端報錯)
async function loadBackupHistory() { async function loadBackupHistory() {
// 🌟 補回取得當前畫面上設定的 IP
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; if (!connInfo || !connInfo.host) return;
const tbody = document.getElementById('backup-history-tbody'); const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="padding: 20px; text-align: center; color: #7f8c8d;">⏳ 正在載入歷史紀錄...</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">⏳ 正在載入歷史紀錄...</td></tr>';
try { try {
const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=running`); // 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=all`);
const result = await response.json(); const result = await response.json();
if (result.status === 'success' && result.data.length > 0) { if (result.status === 'success') {
tbody.innerHTML = result.data.map(item => ` allBackupsData = result.data;
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'"> applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染
<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>
<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}')" 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 { } else {
tbody.innerHTML = '<tr><td colspan="4" style="padding: 20px; text-align: center; color: #7f8c8d;">尚無任何備份紀錄。</td></tr>'; // 加入防呆:如果後端回傳 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) { } catch (error) {
tbody.innerHTML = `<tr><td colspan="4" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 載入失敗: ${error.message}</td></tr>`; 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. 檢視特定快照內容 // 3. 檢視特定快照內容
async function viewBackup(backupId) { async function viewBackup(backupId) {
openModal("載入快照中..."); openModal("載入快照中...");
@ -916,8 +985,12 @@ async function viewBackup(backupId) {
const result = await response.json(); const result = await response.json();
if (result.status === 'success') { if (result.status === 'success') {
// 確保這行真的有加進去,且檔案有按下 Ctrl+S 儲存!
const data = result.data; const data = result.data;
const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`;
// 🟤 將描述加入彈出視窗的標頭中
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 轉為字串顯示 // 如果後端有回傳 raw_cli 就優先顯示,否則將 parsed_tree 轉為字串顯示
const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2); const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2);
@ -953,72 +1026,172 @@ async function deleteBackup(backupId) {
} }
// 5. 請求設備差異分析 (安全還原第一步) // 5. 請求設備差異分析 (安全還原第一步)
async function restoreBackup(snapshotId, snapshotName) { // 🌟 接收第三個參數 backupHost
const connInfo = getGlobalConnectionInfo(); async function restoreBackup(snapshotId, snapshotName, backupHost) {
if (!connInfo || !connInfo.host) { // 🛡️ 防線一:必須先連線
return alert("請先連線至 CMTS 設備!"); 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 呼叫
} }
// 開啟 Modal 顯示分析進度
const modal = document.getElementById('outputModal'); const modal = document.getElementById('outputModal');
const modalOutput = document.getElementById('modalOutput'); const modalOutput = document.getElementById('modalOutput');
const modalTargetInfo = document.getElementById('modalTargetInfo'); const modalTargetInfo = document.getElementById('modalTargetInfo');
// 1. 移除進度條,改為純文字百分比動態顯示
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`; modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
modalOutput.innerHTML = `<span style="color: #3498db;">🔍 正在抓取設備當前配置,並與快照進行深度比對,請稍候...</span>`; 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'); 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 { try {
// 呼叫後端全新的 Diff API (我們接下來要實作的)
const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, { const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass })
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass
})
}); });
const result = await response.json(); const result = await response.json();
if (result.status === 'success') { // API 回傳後,清除計時器並將進度填滿 100%
// 渲染差異預覽畫面 clearInterval(progressInterval);
let diffHtml = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單:</div>`; const text = document.getElementById('diff-progress-text');
if (text) {
if (result.data.commands.length === 0) { text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (100%) - 分析完成!`;
diffHtml += `<div style="color: #27ae60; font-weight: bold; padding: 10px; background: rgba(39, 174, 96, 0.1); border-radius: 5px;">✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!</div>`;
} else {
diffHtml += `<pre style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto;">`;
result.data.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 += `
<div style="margin-top: 20px; text-align: right;">
<button onclick="executeSmartRestore('${snapshotId}', '${snapshotName}')" class="btn-modern btn-save" style="background-color: #e74c3c; padding: 8px 16px;"> 確認無誤立即寫入設備並 Commit</button>
</div>
`;
}
modalOutput.innerHTML = diffHtml;
} else {
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗:\n${result.message}</span>`;
} }
// 稍微延遲 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) { } catch (error) {
clearInterval(progressInterval);
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`; modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`;
} }
} }
// 6. 真正執行差異還原 (掛載到 window 供按鈕呼叫) // 6. 真正執行差異還原 (串接後端 API)
window.executeSmartRestore = async function(snapshotId, snapshotName) { window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
// 這裡將會呼叫我們原本設計好的寫入 API if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎?`)) return;
alert("即將實作:將上方預覽的指令寫入設備!");
// 優雅地將按鈕反灰 (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');
// 在原本的內容上方插入提示,保留指令預覽畫面讓使用者安心
const loadingMsg = document.createElement('div');
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>`;
modalOutput.insertBefore(loadingMsg, modalOutput.firstChild);
try {
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 })
});
const result = await response.json();
if (result.status === 'success') {
modalOutput.innerHTML = `
<div style="color: #27ae60; font-weight: bold; margin-bottom: 10px; font-size: 16px;"> 還原成功設備已 Commit</div>
<div style="color: #ecf0f1; margin-bottom: 5px;">設備回傳結果</div>
<pre style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; color: #ecf0f1;">${result.output}</pre>
`;
document.getElementById('modalTargetInfo').innerHTML = `還原完成: ${snapshotName}`;
} else {
loadingMsg.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 還原失敗:<br>${result.message}</span>`;
// 失敗的話,把按鈕恢復原狀,允許重試
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 = '⚠️ 確認無誤,立即寫入設備並 Commit';
btn.style.backgroundColor = '#e74c3c';
}
}
});
}
} catch (error) {
loadingMsg.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:<br>${error.message}</span>`;
}
}; };
// ============================================================================ // ============================================================================
@ -1168,7 +1341,7 @@ window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache; window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache; window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI; window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; // 🌟 新增綁定 window.switchFilterMode = switchFilterMode;
// --- 樹狀圖展開與編輯操作 --- // --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll; window.expandAll = expandAll;
@ -1192,10 +1365,5 @@ window.createSnapshot = createSnapshot;
window.loadBackupHistory = loadBackupHistory; window.loadBackupHistory = loadBackupHistory;
window.viewBackup = viewBackup; window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup; window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup; // 🌟 新增綁定還原函數 window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
// 將備份還原相關函數綁定到全域,供 HTML onclick 使用
window.loadBackupHistory = loadBackupHistory;
window.createSnapshot = createSnapshot;
//window.restoreSnapshot = restoreSnapshot;
//window.deleteSnapshot = deleteSnapshot;