feat: 手動備份 + 釘選防護 + 背景滾動淘汰

This commit is contained in:
swpa 2026-06-15 14:45:54 +08:00
parent 050936668d
commit d4721b163f
8 changed files with 519 additions and 103 deletions

View File

@ -297,7 +297,7 @@ async def insert_config_backup(
return None
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
"""取得歷史快照列表 (支援 config_type='all' 撈取全部,包含 is_pinned 狀態)"""
pool = await get_pool()
if not pool:
return None
@ -305,18 +305,18 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
try:
async with pool.acquire() as conn:
if config_type == "all":
# 🟢 SELECT 加入 description
# 🌟 SQL 查詢補上 is_pinned
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
FROM config_backups
WHERE host = $1
ORDER BY timestamp DESC;
"""
records = await conn.fetch(query, host)
else:
# 🟢 SELECT 加入 description
# 🌟 SQL 查詢補上 is_pinned
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
FROM config_backups
WHERE host = $1 AND config_type = $2
ORDER BY timestamp DESC;
@ -330,8 +330,9 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
"config_type": r["config_type"],
"timestamp": r["timestamp"].isoformat(),
"snapshot_name": r["snapshot_name"],
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
"is_auto": r["is_auto"]
"description": r["description"],
"is_auto": r["is_auto"],
"is_pinned": r["is_pinned"] # 🌟 放入回傳字典
}
for r in records
]
@ -397,6 +398,113 @@ async def delete_config_backup(backup_id: str) -> bool:
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
return False
# ============================================================================
# 🧹 備份滾動淘汰機制 (Retention Policy)
# ============================================================================
async def cleanup_old_backups(pool, host: str) -> None:
"""
執行手動備份的滾動淘汰機制 (Retention Policy)
- 每個設備 (host) 保留最新 20 筆未釘選 (is_pinned=FALSE) 的手動備份
- 釘選的備份 (is_pinned=True) 永遠不刪除
"""
if not pool:
logger.warning("⚠️ [Retention] 無法執行備份清理Database Pool 未初始化。")
return
# 使用 PostgreSQL CTE (Common Table Expression) 語法
# 先選出該設備最新 20 筆需要保留的 ID再將其餘未釘選的舊備份一次性刪除
query = """
WITH kept_backups AS (
SELECT id FROM config_backups
WHERE host = $1 AND is_pinned = FALSE
ORDER BY timestamp DESC
LIMIT 20
)
DELETE FROM config_backups
WHERE host = $1 AND is_pinned = FALSE
AND id NOT IN (SELECT id FROM kept_backups)
RETURNING id;
"""
try:
async with pool.acquire() as conn:
deleted_records = await conn.fetch(query, host)
total_deleted = len(deleted_records)
if total_deleted > 0:
logger.info(f"🧹 [Retention Policy] 設備 {host} 清理完畢:已自動淘汰 {total_deleted} 筆過期手動備份。")
else:
logger.debug(f" [Retention Policy] 設備 {host} 備份數量未達 20 筆上限,無需清理。")
except Exception as e:
logger.error(f"❌ [Retention Policy] 清理設備 {host} 過期備份時發生錯誤: {e}", exc_info=True)
# ============================================================================
# 📌 釘選防護與容量指標計算 (Pinning & Metrics)
# ============================================================================
async def toggle_config_backup_pin(backup_id: str) -> Optional[bool]:
"""
切換特定備份的釘選狀態 (True -> False, False -> True)
回傳更新後的 is_pinned 狀態,若失敗則回傳 None
"""
pool = await get_pool()
if not pool:
return None
# 使用 PostgreSQL 的 UPDATE ... RETURNING 語法,一步完成切換與讀取,保證原子性
query = """
UPDATE config_backups
SET is_pinned = NOT is_pinned
WHERE id = $1
RETURNING is_pinned;
"""
try:
async with pool.acquire() as conn:
new_state = await conn.fetchval(query, backup_id)
return new_state
except Exception as e:
logger.error(f"❌ DB Error (toggle_config_backup_pin): {e}")
return None
async def get_backup_metrics(pool, host: str) -> dict:
"""
計算特定設備的備份容量指標
- total: 總備份數 (含釘選與未釘選)
- pinned: 已釘選保護的數量
- unpinned: 未釘選的數量 (上限為 20 筆)
- remaining: 剩餘可用手動備份額度 (20 - unpinned)
"""
if not pool:
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
query = """
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE is_pinned = TRUE) as pinned,
COUNT(*) FILTER (WHERE is_pinned = FALSE) as unpinned
FROM config_backups
WHERE host = $1;
"""
try:
async with pool.acquire() as conn:
r = await conn.fetchrow(query, host)
total = r["total"] or 0
pinned = r["pinned"] or 0
unpinned = r["unpinned"] or 0
remaining = max(0, 20 - unpinned)
return {
"total": total,
"pinned": pinned,
"unpinned": unpinned,
"remaining": remaining
}
except Exception as e:
logger.error(f"❌ DB Error (get_backup_metrics): {e}")
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
================================================================================
FILE: shared.py
@ -1208,10 +1316,11 @@ FILE: index.html
<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: 8%; text-align: center;">保護</th> <!-- 🌟 新增:獨立的保護欄位 -->
<th style="padding: 10px; color: #34495e; width: 22%;">快照名稱</th>
<th style="padding: 10px; color: #34495e; width: 22%;">描述</th>
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 18%;">操作</th>
</tr>
</thead>
<tbody id="backup-history-tbody">
@ -1359,7 +1468,7 @@ async def init_database(force_reset: bool = False):
);
""")
# 4. 建立設備配置備份表 config_backups
# 4. 建立設備配置備份表 config_backups (手動備份 + 釘選防護版)
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS config_backups (
@ -1370,17 +1479,25 @@ async def init_database(force_reset: bool = False):
snapshot_name VARCHAR(255),
description TEXT DEFAULT '',
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- 🌟 新增:釘選防護欄位
raw_cli TEXT,
parsed_tree JSONB,
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
);
""")
# 建立基礎查詢索引
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
ON config_backups (host, config_type, timestamp DESC);
""")
# 🌟 新增:建立滾動淘汰專用索引
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_retention
ON config_backups (host, is_pinned, timestamp DESC);
""")
await conn.close()
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
@ -2228,6 +2345,16 @@ export async function apiVerifyGodMode(password) {
return response.json();
}
// ==========================================
// 📌 釘選防護 API (Pinning)
// ==========================================
export async function apiToggleBackupPin(backupId) {
const response = await fetch(`/api/v1/backups/${backupId}/toggle-pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
return response.json();
}
================================================================================
FILE: static/app.js
@ -2252,7 +2379,8 @@ import {
apiGetLogLevels, apiSetLogLevel,
apiVerifyGodMode,
apiGetLeafOptions, // 🌟 補上這個
apiSyncLeafOptions // 🌟 補上這個
apiSyncLeafOptions, // 🌟 補上這個
apiToggleBackupPin // 🌟 新增這行
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -3702,7 +3830,7 @@ async function saveSystemSettings() {
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = [];
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
// 🌟 修正版:手動建立設備快照 (解決容量看板型別解析 Bug)
async function createSnapshot() {
if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
@ -3721,10 +3849,10 @@ async function createSnapshot() {
btn.disabled = true;
msg.style.display = 'inline-block';
msg.style.color = '#f39c12'; // 🌟 確保橘色
msg.style.color = '#f39c12'; // 確保橘色
msg.innerHTML = '⏳ 準備連線至設備...';
let progressInterval = null; // 🌟 新增:進度條計時器
let progressInterval = null;
try {
const response = await fetch('/api/v1/backups/snapshot', {
@ -3746,7 +3874,7 @@ async function createSnapshot() {
const decoder = new TextDecoder("utf-8");
let buffer = "";
let isSuccess = false;
let finalMessage = "";
let finalData = null; // 🌟 修正:改為儲存完整的 JSON 物件,而非只有字串
while (true) {
const { done, value } = await reader.read();
@ -3761,7 +3889,6 @@ async function createSnapshot() {
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
@ -3790,7 +3917,7 @@ async function createSnapshot() {
}
} else if (data.status === 'success') {
isSuccess = true;
finalMessage = data.message;
finalData = data; // 🌟 修正:儲存完整的成功資料物件 (包含 metrics)
} else if (data.status === 'error') {
throw new Error(data.message);
}
@ -3802,7 +3929,23 @@ async function createSnapshot() {
}
if (isSuccess) {
alert(`✅ ${finalMessage}`);
// 🌟 修正:從 finalData 中安全提取 metrics並補齊所有預設值
const metrics = finalData?.metrics || { total: 0, pinned: 0, unpinned: 0, remaining: 20 };
const successMsg = finalData?.message || `快照 '${snapshotName}' 建立成功!`;
// 建立精美的容量看板 HTML 內容
const alertMsg =
`🎉 ${successMsg}\n\n` +
`📊 【當前設備備份容量看板】\n` +
`----------------------------------------\n` +
`🗄️ 總備份檔案數:${metrics.total} 筆\n` +
`📌 已釘選保護數:${metrics.pinned} 筆\n` +
`🔓 未釘選備份數:${metrics.unpinned} 筆\n` +
`🔋 剩餘可用空間:${metrics.remaining} 筆 (上限 20 筆)\n\n` +
`💡 提示:當可用空間為 0 時,新備份將會自動淘汰最舊的未釘選備份。您可以點擊 📌 圖示來保護重要備份!`;
alert(alertMsg);
document.getElementById('snapshotName').value = '';
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = '';
@ -3812,7 +3955,7 @@ async function createSnapshot() {
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
} finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
if (progressInterval) clearInterval(progressInterval);
btn.disabled = false;
msg.style.display = 'none';
}
@ -3828,7 +3971,7 @@ async function loadBackupHistory() {
if (!tbody) return;
// 🌟 修正:將原本的灰色改為橘色並加粗
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
@ -3886,29 +4029,48 @@ window.applyBackupFilters = function() {
// 渲染過濾後的結果
if (filteredData.length > 0) {
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
const displayAdmin = isGodMode ? 'inline-block' : 'none';
tbody.innerHTML = filteredData.map(item => `
tbody.innerHTML = filteredData.map(item => {
// 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
const pinIcon = item.is_pinned
? `<span onclick="togglePin('${item.id}')" style="cursor: pointer; font-size: 16px; display: inline-block; transform: scale(1.2); transition: transform 0.1s;" title="📌 已釘選保護(系統絕不自動淘汰),點擊取消保護">📌</span>`
: `<span onclick="togglePin('${item.id}')" class="unpinned-icon" style="cursor: pointer; font-size: 16px; opacity: 0.2; display: inline-block; transition: all 0.2s;" title="📍 點擊釘選保護,防止被滾動淘汰">📌</span>`;
return `
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
<!-- 1. 時間 -->
<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>
<!-- 🟢 描述欄位 -->
<!-- 2. 🌟 獨立的保護 (釘選) 欄位,採水平與垂直置中對齊 -->
<td style="padding: 12px 10px; text-align: center; vertical-align: middle;">
${pinIcon}
</td>
<!-- 3. 快照名稱 (純文字,完美對齊!) -->
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">
${item.snapshot_name}
</td>
<!-- 4. 描述 -->
<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>
<!-- 5. 類型 -->
<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>
<!-- 6. 操作 -->
<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>
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式,只有解鎖後才會出現 -->
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
</td>
</tr>
`).join('');
`;
}).join('');
} else {
// 🟢 修正沒有資料時colspan 改為 5
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
}
};
@ -3964,6 +4126,21 @@ async function deleteBackup(backupId) {
}
}
// 🌟 新增:切換釘選狀態
window.togglePin = async function(backupId) {
try {
const result = await apiToggleBackupPin(backupId);
if (result.status === 'success') {
// 重新載入歷史紀錄,即時更新 📌 狀態
await loadBackupHistory();
} else {
alert(`❌ 操作失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 連線失敗: ${error.message}`);
}
};
// 5. 請求設備差異分析 (安全還原第一步)
// 🌟 接收第三個參數 backupHost
async function restoreBackup(snapshotId, snapshotName, backupHost) {
@ -4453,6 +4630,7 @@ window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
window.togglePin = togglePin;
================================================================================
FILE: static/terminal.js
@ -4963,6 +5141,8 @@ button:hover { background-color: #3498db; }
contain-intrinsic-size: 0 24px;
}
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }
================================================================================
FILE: static/edit-mode.js
================================================================================
@ -6942,7 +7122,7 @@ import asyncssh
import asyncio
import re
import json
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, List
@ -6950,10 +7130,14 @@ from logger import get_logger
# 引入 DB 函數
from database import (
get_pool,
insert_config_backup,
get_config_backup_list,
get_config_backup_detail,
delete_config_backup
delete_config_backup,
cleanup_old_backups,
toggle_config_backup_pin, # 🌟 新增
get_backup_metrics # 🌟 新增
)
from cmts_scraper import fetch_raw_config
@ -7088,23 +7272,35 @@ async def delete_backup(backup_id: str):
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
return {"status": "success", "message": "快照已成功刪除"}
@router.post("/{backup_id}/toggle-pin", summary="切換快照的釘選防護狀態")
async def toggle_backup_pin(backup_id: str):
new_state = await toggle_config_backup_pin(backup_id)
if new_state is None:
raise HTTPException(status_code=500, detail="資料庫更新失敗")
status_str = "已啟用釘選保護,系統絕不自動淘汰。" if new_state else "已取消釘選,該備份將納入滾動淘汰範圍。"
return {
"status": "success",
"is_pinned": new_state,
"message": f"快照狀態更新成功!{status_str}"
}
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
async def create_snapshot(req: SnapshotRequest):
async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
async def backup_streamer():
try:
# 1. 廣播:正在連線
pool = await get_pool()
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
await asyncio.sleep(0.1)
# 2. 廣播:正在下載
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
# 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1)
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
# 4. 廣播:寫入資料庫
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
backup_id = await insert_config_backup(
host=req.host,
@ -7120,12 +7316,23 @@ async def create_snapshot(req: SnapshotRequest):
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
return
# 5. 廣播:成功
# 🌟 關鍵變更:在同一個串流中「立即執行」清理與指標計算,確保回傳最新數據
yield json.dumps({"status": "progress", "message": "🧹 正在執行備份滾動淘汰與容量計算..."}) + "\n"
if pool:
# 1. 立即執行清理
await cleanup_old_backups(pool, req.host)
# 2. 立即計算最新指標
metrics = await get_backup_metrics(pool, req.host)
else:
metrics = {"total": 1, "pinned": 0, "unpinned": 1, "remaining": 19}
# 🌟 3. 在成功訊息中,將 metrics 字典一併回傳給前端
yield json.dumps({
"status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id,
"host": req.host
"host": req.host,
"metrics": metrics # 🌟 包含容量指標
}) + "\n"
except Exception as e:
@ -7326,7 +7533,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
except asyncio.CancelledError:
pass
except Exception as e:
logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True)
logger.error(f"❌ [WS Forward Error] 讀取設備畫面時發生錯誤: {str(e)}")
async def forward_to_ssh():
try:
@ -7349,7 +7556,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
except asyncio.CancelledError:
pass
except Exception as e:
logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True)
logger.error(f"❌ [SSH Forward Error] 寫入指令到設備時發生錯誤: {str(e)}")
ws_task = asyncio.create_task(forward_to_ws())
ssh_task = asyncio.create_task(forward_to_ssh())
@ -7380,7 +7587,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
await websocket.close(code=4001, reason=safe_reason)
except:
pass
logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
logger.error(f"❌ [Connection Error] 建立連線或執行過程中發生錯誤: {str(e)}")
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak

View File

@ -251,7 +251,7 @@ async def insert_config_backup(
return None
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
"""取得歷史快照列表 (支援 config_type='all' 撈取全部,包含 is_pinned 狀態)"""
pool = await get_pool()
if not pool:
return None
@ -259,18 +259,18 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
try:
async with pool.acquire() as conn:
if config_type == "all":
# 🟢 SELECT 加入 description
# 🌟 SQL 查詢補上 is_pinned
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
FROM config_backups
WHERE host = $1
ORDER BY timestamp DESC;
"""
records = await conn.fetch(query, host)
else:
# 🟢 SELECT 加入 description
# 🌟 SQL 查詢補上 is_pinned
query = """
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
FROM config_backups
WHERE host = $1 AND config_type = $2
ORDER BY timestamp DESC;
@ -284,8 +284,9 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
"config_type": r["config_type"],
"timestamp": r["timestamp"].isoformat(),
"snapshot_name": r["snapshot_name"],
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
"is_auto": r["is_auto"]
"description": r["description"],
"is_auto": r["is_auto"],
"is_pinned": r["is_pinned"] # 🌟 放入回傳字典
}
for r in records
]
@ -350,3 +351,111 @@ async def delete_config_backup(backup_id: str) -> bool:
except Exception as e:
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
return False
# ============================================================================
# 🧹 備份滾動淘汰機制 (Retention Policy)
# ============================================================================
async def cleanup_old_backups(pool, host: str) -> None:
"""
執行手動備份的滾動淘汰機制 (Retention Policy)
- 每個設備 (host) 保留最新 20 筆未釘選 (is_pinned=FALSE) 的手動備份
- 釘選的備份 (is_pinned=True) 永遠不刪除
"""
if not pool:
logger.warning("⚠️ [Retention] 無法執行備份清理Database Pool 未初始化。")
return
# 使用 PostgreSQL CTE (Common Table Expression) 語法
# 先選出該設備最新 20 筆需要保留的 ID再將其餘未釘選的舊備份一次性刪除
query = """
WITH kept_backups AS (
SELECT id FROM config_backups
WHERE host = $1 AND is_pinned = FALSE
ORDER BY timestamp DESC
LIMIT 20
)
DELETE FROM config_backups
WHERE host = $1 AND is_pinned = FALSE
AND id NOT IN (SELECT id FROM kept_backups)
RETURNING id;
"""
try:
async with pool.acquire() as conn:
deleted_records = await conn.fetch(query, host)
total_deleted = len(deleted_records)
if total_deleted > 0:
logger.info(f"🧹 [Retention Policy] 設備 {host} 清理完畢:已自動淘汰 {total_deleted} 筆過期手動備份。")
else:
logger.debug(f" [Retention Policy] 設備 {host} 備份數量未達 20 筆上限,無需清理。")
except Exception as e:
logger.error(f"❌ [Retention Policy] 清理設備 {host} 過期備份時發生錯誤: {e}", exc_info=True)
# ============================================================================
# 📌 釘選防護與容量指標計算 (Pinning & Metrics)
# ============================================================================
async def toggle_config_backup_pin(backup_id: str) -> Optional[bool]:
"""
切換特定備份的釘選狀態 (True -> False, False -> True)
回傳更新後的 is_pinned 狀態若失敗則回傳 None
"""
pool = await get_pool()
if not pool:
return None
# 使用 PostgreSQL 的 UPDATE ... RETURNING 語法,一步完成切換與讀取,保證原子性
query = """
UPDATE config_backups
SET is_pinned = NOT is_pinned
WHERE id = $1
RETURNING is_pinned;
"""
try:
async with pool.acquire() as conn:
new_state = await conn.fetchval(query, backup_id)
return new_state
except Exception as e:
logger.error(f"❌ DB Error (toggle_config_backup_pin): {e}")
return None
async def get_backup_metrics(pool, host: str) -> dict:
"""
計算特定設備的備份容量指標
- total: 總備份數 (含釘選與未釘選)
- pinned: 已釘選保護的數量
- unpinned: 未釘選的數量 (上限為 20 )
- remaining: 剩餘可用手動備份額度 (20 - unpinned)
"""
if not pool:
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
query = """
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE is_pinned = TRUE) as pinned,
COUNT(*) FILTER (WHERE is_pinned = FALSE) as unpinned
FROM config_backups
WHERE host = $1;
"""
try:
async with pool.acquire() as conn:
r = await conn.fetchrow(query, host)
total = r["total"] or 0
pinned = r["pinned"] or 0
unpinned = r["unpinned"] or 0
remaining = max(0, 20 - unpinned)
return {
"total": total,
"pinned": pinned,
"unpinned": unpinned,
"remaining": remaining
}
except Exception as e:
logger.error(f"❌ DB Error (get_backup_metrics): {e}")
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}

View File

@ -539,10 +539,11 @@
<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: 8%; text-align: center;">保護</th> <!-- 🌟 新增:獨立的保護欄位 -->
<th style="padding: 10px; color: #34495e; width: 22%;">快照名稱</th>
<th style="padding: 10px; color: #34495e; width: 22%;">描述</th>
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 18%;">操作</th>
</tr>
</thead>
<tbody id="backup-history-tbody">

View File

@ -79,7 +79,7 @@ async def init_database(force_reset: bool = False):
);
""")
# 4. 建立設備配置備份表 config_backups
# 4. 建立設備配置備份表 config_backups (手動備份 + 釘選防護版)
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS config_backups (
@ -90,17 +90,25 @@ async def init_database(force_reset: bool = False):
snapshot_name VARCHAR(255),
description TEXT DEFAULT '',
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- 🌟 新增釘選防護欄位
raw_cli TEXT,
parsed_tree JSONB,
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
);
""")
# 建立基礎查詢索引
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
ON config_backups (host, config_type, timestamp DESC);
""")
# 🌟 新增:建立滾動淘汰專用索引
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_retention
ON config_backups (host, is_pinned, timestamp DESC);
""")
await conn.close()
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")

View File

@ -3,7 +3,7 @@ import asyncssh
import asyncio
import re
import json
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, List
@ -11,10 +11,14 @@ from logger import get_logger
# 引入 DB 函數
from database import (
get_pool,
insert_config_backup,
get_config_backup_list,
get_config_backup_detail,
delete_config_backup
delete_config_backup,
cleanup_old_backups,
toggle_config_backup_pin, # 🌟 新增
get_backup_metrics # 🌟 新增
)
from cmts_scraper import fetch_raw_config
@ -149,23 +153,35 @@ async def delete_backup(backup_id: str):
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
return {"status": "success", "message": "快照已成功刪除"}
@router.post("/{backup_id}/toggle-pin", summary="切換快照的釘選防護狀態")
async def toggle_backup_pin(backup_id: str):
new_state = await toggle_config_backup_pin(backup_id)
if new_state is None:
raise HTTPException(status_code=500, detail="資料庫更新失敗")
status_str = "已啟用釘選保護,系統絕不自動淘汰。" if new_state else "已取消釘選,該備份將納入滾動淘汰範圍。"
return {
"status": "success",
"is_pinned": new_state,
"message": f"快照狀態更新成功!{status_str}"
}
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
async def create_snapshot(req: SnapshotRequest):
async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
async def backup_streamer():
try:
# 1. 廣播:正在連線
pool = await get_pool()
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
await asyncio.sleep(0.1)
# 2. 廣播:正在下載
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
# 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1)
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
# 4. 廣播:寫入資料庫
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
backup_id = await insert_config_backup(
host=req.host,
@ -181,12 +197,23 @@ async def create_snapshot(req: SnapshotRequest):
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
return
# 5. 廣播:成功
# 🌟 關鍵變更:在同一個串流中「立即執行」清理與指標計算,確保回傳最新數據
yield json.dumps({"status": "progress", "message": "🧹 正在執行備份滾動淘汰與容量計算..."}) + "\n"
if pool:
# 1. 立即執行清理
await cleanup_old_backups(pool, req.host)
# 2. 立即計算最新指標
metrics = await get_backup_metrics(pool, req.host)
else:
metrics = {"total": 1, "pinned": 0, "unpinned": 1, "remaining": 19}
# 🌟 3. 在成功訊息中,將 metrics 字典一併回傳給前端
yield json.dumps({
"status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id,
"host": req.host
"host": req.host,
"metrics": metrics # 🌟 包含容量指標
}) + "\n"
except Exception as e:

View File

@ -227,3 +227,14 @@ export async function apiVerifyGodMode(password) {
}
return response.json();
}
// ==========================================
// 📌 釘選防護 API (Pinning)
// ==========================================
export async function apiToggleBackupPin(backupId) {
const response = await fetch(`/api/v1/backups/${backupId}/toggle-pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
return response.json();
}

View File

@ -18,7 +18,8 @@ import {
apiGetLogLevels, apiSetLogLevel,
apiVerifyGodMode,
apiGetLeafOptions, // 🌟 補上這個
apiSyncLeafOptions // 🌟 補上這個
apiSyncLeafOptions, // 🌟 補上這個
apiToggleBackupPin // 🌟 新增這行
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -1468,7 +1469,7 @@ async function saveSystemSettings() {
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = [];
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
// 🌟 修正版:手動建立設備快照 (解決容量看板型別解析 Bug)
async function createSnapshot() {
if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
@ -1487,10 +1488,10 @@ async function createSnapshot() {
btn.disabled = true;
msg.style.display = 'inline-block';
msg.style.color = '#f39c12'; // 🌟 確保橘色
msg.style.color = '#f39c12'; // 確保橘色
msg.innerHTML = '⏳ 準備連線至設備...';
let progressInterval = null; // 🌟 新增:進度條計時器
let progressInterval = null;
try {
const response = await fetch('/api/v1/backups/snapshot', {
@ -1512,7 +1513,7 @@ async function createSnapshot() {
const decoder = new TextDecoder("utf-8");
let buffer = "";
let isSuccess = false;
let finalMessage = "";
let finalData = null; // 🌟 修正:改為儲存完整的 JSON 物件,而非只有字串
while (true) {
const { done, value } = await reader.read();
@ -1527,7 +1528,6 @@ async function createSnapshot() {
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
@ -1556,7 +1556,7 @@ async function createSnapshot() {
}
} else if (data.status === 'success') {
isSuccess = true;
finalMessage = data.message;
finalData = data; // 🌟 修正:儲存完整的成功資料物件 (包含 metrics)
} else if (data.status === 'error') {
throw new Error(data.message);
}
@ -1568,7 +1568,23 @@ async function createSnapshot() {
}
if (isSuccess) {
alert(`${finalMessage}`);
// 🌟 修正:從 finalData 中安全提取 metrics並補齊所有預設值
const metrics = finalData?.metrics || { total: 0, pinned: 0, unpinned: 0, remaining: 20 };
const successMsg = finalData?.message || `快照 '${snapshotName}' 建立成功!`;
// 建立精美的容量看板 HTML 內容
const alertMsg =
`🎉 ${successMsg}\n\n` +
`📊 【當前設備備份容量看板】\n` +
`----------------------------------------\n` +
`🗄️ 總備份檔案數:${metrics.total}\n` +
`📌 已釘選保護數:${metrics.pinned}\n` +
`🔓 未釘選備份數:${metrics.unpinned}\n` +
`🔋 剩餘可用空間:${metrics.remaining} 筆 (上限 20 筆)\n\n` +
`💡 提示:當可用空間為 0 時,新備份將會自動淘汰最舊的未釘選備份。您可以點擊 📌 圖示來保護重要備份!`;
alert(alertMsg);
document.getElementById('snapshotName').value = '';
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = '';
@ -1578,7 +1594,7 @@ async function createSnapshot() {
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
} finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
if (progressInterval) clearInterval(progressInterval);
btn.disabled = false;
msg.style.display = 'none';
}
@ -1594,7 +1610,7 @@ async function loadBackupHistory() {
if (!tbody) return;
// 🌟 修正:將原本的灰色改為橘色並加粗
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
@ -1652,29 +1668,48 @@ window.applyBackupFilters = function() {
// 渲染過濾後的結果
if (filteredData.length > 0) {
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
const displayAdmin = isGodMode ? 'inline-block' : 'none';
tbody.innerHTML = filteredData.map(item => `
tbody.innerHTML = filteredData.map(item => {
// 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
const pinIcon = item.is_pinned
? `<span onclick="togglePin('${item.id}')" style="cursor: pointer; font-size: 16px; display: inline-block; transform: scale(1.2); transition: transform 0.1s;" title="📌 已釘選保護(系統絕不自動淘汰),點擊取消保護">📌</span>`
: `<span onclick="togglePin('${item.id}')" class="unpinned-icon" style="cursor: pointer; font-size: 16px; opacity: 0.2; display: inline-block; transition: all 0.2s;" title="📍 點擊釘選保護,防止被滾動淘汰">📌</span>`;
return `
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
<!-- 1. 時間 -->
<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>
<!-- 🟢 描述欄位 -->
<!-- 2. 🌟 獨立的保護 (釘選) 欄位採水平與垂直置中對齊 -->
<td style="padding: 12px 10px; text-align: center; vertical-align: middle;">
${pinIcon}
</td>
<!-- 3. 快照名稱 (純文字完美對齊) -->
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">
${item.snapshot_name}
</td>
<!-- 4. 描述 -->
<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>
<!-- 5. 類型 -->
<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>
<!-- 6. 操作 -->
<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>
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式只有解鎖後才會出現 -->
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態"> 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑 刪除</button>
</td>
</tr>
`).join('');
`;
}).join('');
} else {
// 🟢 修正沒有資料時colspan 改為 5
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
}
};
@ -1730,6 +1765,21 @@ async function deleteBackup(backupId) {
}
}
// 🌟 新增:切換釘選狀態
window.togglePin = async function(backupId) {
try {
const result = await apiToggleBackupPin(backupId);
if (result.status === 'success') {
// 重新載入歷史紀錄,即時更新 📌 狀態
await loadBackupHistory();
} else {
alert(`❌ 操作失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 連線失敗: ${error.message}`);
}
};
// 5. 請求設備差異分析 (安全還原第一步)
// 🌟 接收第三個參數 backupHost
async function restoreBackup(snapshotId, snapshotName, backupHost) {
@ -2219,3 +2269,4 @@ window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
window.togglePin = togglePin;

View File

@ -303,3 +303,5 @@ button:hover { background-color: #3498db; }
content-visibility: auto;
contain-intrinsic-size: 0 24px;
}
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }