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