diff --git a/all_code.txt b/all_code.txt
index 89a83ac..58c1074 100644
--- a/all_code.txt
+++ b/all_code.txt
@@ -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
| 時間 |
- 快照名稱 |
- 描述 |
+ 保護 |
+ 快照名稱 |
+ 描述 |
類型 |
- 操作 |
+ 操作 |
@@ -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 = '| ⏳ 正在載入歷史紀錄... |
';
+ tbody.innerHTML = '| ⏳ 正在載入歷史紀錄... |
';
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 => `
-
- | ${new Date(item.timestamp).toLocaleString()} |
- ${item.snapshot_name} |
-
-
- ${item.description || '無'}
- |
- ${item.config_type} |
-
-
-
-
-
- |
-
- `).join('');
+ tbody.innerHTML = filteredData.map(item => {
+ // 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
+ const pinIcon = item.is_pinned
+ ? `📌`
+ : `📌`;
+
+ return `
+
+
+ | ${new Date(item.timestamp).toLocaleString()} |
+
+
+
+ ${pinIcon}
+ |
+
+
+
+ ${item.snapshot_name}
+ |
+
+
+
+ ${item.description || '無'}
+ |
+
+
+ ${item.config_type} |
+
+
+
+
+
+
+ |
+
+ `;
+ }).join('');
} else {
- // 🟢 修正:沒有資料時,colspan 改為 5
tbody.innerHTML = '| 找不到符合條件的備份紀錄。 |
';
}
};
@@ -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
diff --git a/database.py b/database.py
index f848aa1..fbcf38e 100644
--- a/database.py
+++ b/database.py
@@ -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}
\ No newline at end of file
diff --git a/index.html b/index.html
index 476ac23..5ff202c 100644
--- a/index.html
+++ b/index.html
@@ -539,10 +539,11 @@
| 時間 |
- 快照名稱 |
- 描述 |
+ 保護 |
+ 快照名稱 |
+ 描述 |
類型 |
- 操作 |
+ 操作 |
diff --git a/init_db.py b/init_db.py
index ca811f1..0abd4cf 100644
--- a/init_db.py
+++ b/init_db.py
@@ -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("✅ 資料庫初始化完成!所有資料表已準備就緒。")
diff --git a/routers/backup.py b/routers/backup.py
index 05ff9a7..cb44c47 100644
--- a/routers/backup.py
+++ b/routers/backup.py
@@ -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:
diff --git a/static/api.js b/static/api.js
index 4113405..925529b 100644
--- a/static/api.js
+++ b/static/api.js
@@ -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();
+}
\ No newline at end of file
diff --git a/static/app.js b/static/app.js
index eab8ada..196af11 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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 = '| ⏳ 正在載入歷史紀錄... |
';
+ tbody.innerHTML = '| ⏳ 正在載入歷史紀錄... |
';
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 => `
-
- | ${new Date(item.timestamp).toLocaleString()} |
- ${item.snapshot_name} |
-
-
- ${item.description || '無'}
- |
- ${item.config_type} |
-
-
-
-
-
- |
-
- `).join('');
+ tbody.innerHTML = filteredData.map(item => {
+ // 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
+ const pinIcon = item.is_pinned
+ ? `📌`
+ : `📌`;
+
+ return `
+
+
+ | ${new Date(item.timestamp).toLocaleString()} |
+
+
+
+ ${pinIcon}
+ |
+
+
+
+ ${item.snapshot_name}
+ |
+
+
+
+ ${item.description || '無'}
+ |
+
+
+ ${item.config_type} |
+
+
+
+
+
+
+ |
+
+ `;
+ }).join('');
} else {
- // 🟢 修正:沒有資料時,colspan 改為 5
tbody.innerHTML = '| 找不到符合條件的備份紀錄。 |
';
}
};
@@ -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) {
@@ -2218,4 +2268,5 @@ window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
-window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
\ No newline at end of file
+window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
+window.togglePin = togglePin;
\ No newline at end of file
diff --git a/static/style.css b/static/style.css
index 2339c3e..7cdce33 100644
--- a/static/style.css
+++ b/static/style.css
@@ -302,4 +302,6 @@ button:hover { background-color: #3498db; }
.filter-children-container {
content-visibility: auto;
contain-intrinsic-size: 0 24px;
-}
\ No newline at end of file
+}
+
+.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }
\ No newline at end of file