feat: 手動備份 + 釘選防護 + 背景滾動淘汰
This commit is contained in:
parent
050936668d
commit
d4721b163f
311
all_code.txt
311
all_code.txt
|
|
@ -297,7 +297,7 @@ async def insert_config_backup(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
|
"""取得歷史快照列表 (支援 config_type='all' 撈取全部,包含 is_pinned 狀態)"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return None
|
return None
|
||||||
|
|
@ -305,18 +305,18 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if config_type == "all":
|
if config_type == "all":
|
||||||
# 🟢 SELECT 加入 description
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
query = """
|
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
|
FROM config_backups
|
||||||
WHERE host = $1
|
WHERE host = $1
|
||||||
ORDER BY timestamp DESC;
|
ORDER BY timestamp DESC;
|
||||||
"""
|
"""
|
||||||
records = await conn.fetch(query, host)
|
records = await conn.fetch(query, host)
|
||||||
else:
|
else:
|
||||||
# 🟢 SELECT 加入 description
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
query = """
|
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
|
FROM config_backups
|
||||||
WHERE host = $1 AND config_type = $2
|
WHERE host = $1 AND config_type = $2
|
||||||
ORDER BY timestamp DESC;
|
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"],
|
"config_type": r["config_type"],
|
||||||
"timestamp": r["timestamp"].isoformat(),
|
"timestamp": r["timestamp"].isoformat(),
|
||||||
"snapshot_name": r["snapshot_name"],
|
"snapshot_name": r["snapshot_name"],
|
||||||
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
|
"description": r["description"],
|
||||||
"is_auto": r["is_auto"]
|
"is_auto": r["is_auto"],
|
||||||
|
"is_pinned": r["is_pinned"] # 🌟 放入回傳字典
|
||||||
}
|
}
|
||||||
for r in records
|
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}")
|
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
|
||||||
return False
|
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
|
FILE: shared.py
|
||||||
|
|
@ -1208,10 +1316,11 @@ FILE: index.html
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
||||||
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
|
<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: 8%; text-align: center;">保護</th> <!-- 🌟 新增:獨立的保護欄位 -->
|
||||||
<th style="padding: 10px; color: #34495e; width: 25%;">描述</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; 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="backup-history-tbody">
|
<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 資料表與索引...")
|
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS config_backups (
|
CREATE TABLE IF NOT EXISTS config_backups (
|
||||||
|
|
@ -1370,17 +1479,25 @@ async def init_database(force_reset: bool = False):
|
||||||
snapshot_name VARCHAR(255),
|
snapshot_name VARCHAR(255),
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- 🌟 新增:釘選防護欄位
|
||||||
raw_cli TEXT,
|
raw_cli TEXT,
|
||||||
parsed_tree JSONB,
|
parsed_tree JSONB,
|
||||||
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 建立基礎查詢索引
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
||||||
ON config_backups (host, config_type, timestamp DESC);
|
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()
|
await conn.close()
|
||||||
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||||||
|
|
||||||
|
|
@ -2228,6 +2345,16 @@ export async function apiVerifyGodMode(password) {
|
||||||
return response.json();
|
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
|
FILE: static/app.js
|
||||||
|
|
@ -2252,7 +2379,8 @@ import {
|
||||||
apiGetLogLevels, apiSetLogLevel,
|
apiGetLogLevels, apiSetLogLevel,
|
||||||
apiVerifyGodMode,
|
apiVerifyGodMode,
|
||||||
apiGetLeafOptions, // 🌟 補上這個
|
apiGetLeafOptions, // 🌟 補上這個
|
||||||
apiSyncLeafOptions // 🌟 補上這個
|
apiSyncLeafOptions, // 🌟 補上這個
|
||||||
|
apiToggleBackupPin // 🌟 新增這行
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
|
|
||||||
// 3. 共用工具模組 (Utils)
|
// 3. 共用工具模組 (Utils)
|
||||||
|
|
@ -3702,7 +3830,7 @@ async function saveSystemSettings() {
|
||||||
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
||||||
let allBackupsData = [];
|
let allBackupsData = [];
|
||||||
|
|
||||||
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
|
// 🌟 修正版:手動建立設備快照 (解決容量看板型別解析 Bug)
|
||||||
async function createSnapshot() {
|
async function createSnapshot() {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
||||||
|
|
@ -3721,10 +3849,10 @@ async function createSnapshot() {
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
msg.style.display = 'inline-block';
|
msg.style.display = 'inline-block';
|
||||||
msg.style.color = '#f39c12'; // 🌟 確保橘色
|
msg.style.color = '#f39c12'; // 確保橘色
|
||||||
msg.innerHTML = '⏳ 準備連線至設備...';
|
msg.innerHTML = '⏳ 準備連線至設備...';
|
||||||
|
|
||||||
let progressInterval = null; // 🌟 新增:進度條計時器
|
let progressInterval = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backups/snapshot', {
|
const response = await fetch('/api/v1/backups/snapshot', {
|
||||||
|
|
@ -3746,7 +3874,7 @@ async function createSnapshot() {
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let isSuccess = false;
|
let isSuccess = false;
|
||||||
let finalMessage = "";
|
let finalData = null; // 🌟 修正:改為儲存完整的 JSON 物件,而非只有字串
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
|
|
@ -3761,7 +3889,6 @@ async function createSnapshot() {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line);
|
const data = JSON.parse(line);
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
// 🌟 導入百分比動畫邏輯
|
|
||||||
if (data.message.includes('正在下載')) {
|
if (data.message.includes('正在下載')) {
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
if (progressInterval) clearInterval(progressInterval);
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
|
|
@ -3790,7 +3917,7 @@ async function createSnapshot() {
|
||||||
}
|
}
|
||||||
} else if (data.status === 'success') {
|
} else if (data.status === 'success') {
|
||||||
isSuccess = true;
|
isSuccess = true;
|
||||||
finalMessage = data.message;
|
finalData = data; // 🌟 修正:儲存完整的成功資料物件 (包含 metrics)
|
||||||
} else if (data.status === 'error') {
|
} else if (data.status === 'error') {
|
||||||
throw new Error(data.message);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
@ -3802,7 +3929,23 @@ async function createSnapshot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSuccess) {
|
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 = '';
|
document.getElementById('snapshotName').value = '';
|
||||||
if (document.getElementById('snapshotDescription')) {
|
if (document.getElementById('snapshotDescription')) {
|
||||||
document.getElementById('snapshotDescription').value = '';
|
document.getElementById('snapshotDescription').value = '';
|
||||||
|
|
@ -3812,7 +3955,7 @@ async function createSnapshot() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`❌ 發生錯誤: ${error.message}`);
|
alert(`❌ 發生錯誤: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
msg.style.display = 'none';
|
msg.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -3828,7 +3971,7 @@ async function loadBackupHistory() {
|
||||||
if (!tbody) return;
|
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 {
|
try {
|
||||||
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
||||||
|
|
@ -3886,29 +4029,48 @@ window.applyBackupFilters = function() {
|
||||||
|
|
||||||
// 渲染過濾後的結果
|
// 渲染過濾後的結果
|
||||||
if (filteredData.length > 0) {
|
if (filteredData.length > 0) {
|
||||||
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
|
|
||||||
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||||||
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
||||||
|
|
||||||
tbody.innerHTML = filteredData.map(item => `
|
tbody.innerHTML = filteredData.map(item => {
|
||||||
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
// 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
const pinIcon = item.is_pinned
|
||||||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
? `<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>`;
|
||||||
<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>'}
|
return `
|
||||||
</td>
|
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
||||||
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
<!-- 1. 時間 -->
|
||||||
<td style="padding: 12px 10px; text-align: right;">
|
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
||||||
<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 樣式,只有解鎖後才會出現 -->
|
<!-- 2. 🌟 獨立的保護 (釘選) 欄位,採水平與垂直置中對齊 -->
|
||||||
<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>
|
<td style="padding: 12px 10px; text-align: center; vertical-align: middle;">
|
||||||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
${pinIcon}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
`).join('');
|
<!-- 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>
|
||||||
|
<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('');
|
||||||
} else {
|
} else {
|
||||||
// 🟢 修正:沒有資料時,colspan 改為 5
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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. 請求設備差異分析 (安全還原第一步)
|
// 5. 請求設備差異分析 (安全還原第一步)
|
||||||
// 🌟 接收第三個參數 backupHost
|
// 🌟 接收第三個參數 backupHost
|
||||||
async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
||||||
|
|
@ -4453,6 +4630,7 @@ window.deleteBackup = deleteBackup;
|
||||||
window.restoreBackup = restoreBackup;
|
window.restoreBackup = restoreBackup;
|
||||||
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
|
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
|
||||||
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
|
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
|
||||||
|
window.togglePin = togglePin;
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
FILE: static/terminal.js
|
FILE: static/terminal.js
|
||||||
|
|
@ -4963,6 +5141,8 @@ button:hover { background-color: #3498db; }
|
||||||
contain-intrinsic-size: 0 24px;
|
contain-intrinsic-size: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
FILE: static/edit-mode.js
|
FILE: static/edit-mode.js
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|
@ -6942,7 +7122,7 @@ import asyncssh
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
@ -6950,10 +7130,14 @@ from logger import get_logger
|
||||||
|
|
||||||
# 引入 DB 函數
|
# 引入 DB 函數
|
||||||
from database import (
|
from database import (
|
||||||
|
get_pool,
|
||||||
insert_config_backup,
|
insert_config_backup,
|
||||||
get_config_backup_list,
|
get_config_backup_list,
|
||||||
get_config_backup_detail,
|
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
|
from cmts_scraper import fetch_raw_config
|
||||||
|
|
@ -7088,23 +7272,35 @@ async def delete_backup(backup_id: str):
|
||||||
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
||||||
return {"status": "success", "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="手動建立設備快照 (串流版)")
|
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
|
||||||
async def create_snapshot(req: SnapshotRequest):
|
async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
|
||||||
async def backup_streamer():
|
async def backup_streamer():
|
||||||
try:
|
try:
|
||||||
# 1. 廣播:正在連線
|
pool = await get_pool()
|
||||||
|
|
||||||
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# 2. 廣播:正在下載
|
|
||||||
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
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)
|
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
||||||
|
|
||||||
# 3. 廣播:正在解析
|
|
||||||
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
||||||
|
|
||||||
# 4. 廣播:寫入資料庫
|
|
||||||
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
||||||
backup_id = await insert_config_backup(
|
backup_id = await insert_config_backup(
|
||||||
host=req.host,
|
host=req.host,
|
||||||
|
|
@ -7120,12 +7316,23 @@ async def create_snapshot(req: SnapshotRequest):
|
||||||
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
||||||
return
|
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({
|
yield json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
||||||
"backup_id": backup_id,
|
"backup_id": backup_id,
|
||||||
"host": req.host
|
"host": req.host,
|
||||||
|
"metrics": metrics # 🌟 包含容量指標
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -7326,7 +7533,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
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():
|
async def forward_to_ssh():
|
||||||
try:
|
try:
|
||||||
|
|
@ -7349,7 +7556,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
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())
|
ws_task = asyncio.create_task(forward_to_ws())
|
||||||
ssh_task = asyncio.create_task(forward_to_ssh())
|
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)
|
await websocket.close(code=4001, reason=safe_reason)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
|
logger.error(f"❌ [Connection Error] 建立連線或執行過程中發生錯誤: {str(e)}")
|
||||||
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
|
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
|
||||||
finally:
|
finally:
|
||||||
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
||||||
|
|
|
||||||
123
database.py
123
database.py
|
|
@ -251,7 +251,7 @@ async def insert_config_backup(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
|
"""取得歷史快照列表 (支援 config_type='all' 撈取全部,包含 is_pinned 狀態)"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return None
|
return None
|
||||||
|
|
@ -259,18 +259,18 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if config_type == "all":
|
if config_type == "all":
|
||||||
# 🟢 SELECT 加入 description
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
query = """
|
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
|
FROM config_backups
|
||||||
WHERE host = $1
|
WHERE host = $1
|
||||||
ORDER BY timestamp DESC;
|
ORDER BY timestamp DESC;
|
||||||
"""
|
"""
|
||||||
records = await conn.fetch(query, host)
|
records = await conn.fetch(query, host)
|
||||||
else:
|
else:
|
||||||
# 🟢 SELECT 加入 description
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
query = """
|
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
|
FROM config_backups
|
||||||
WHERE host = $1 AND config_type = $2
|
WHERE host = $1 AND config_type = $2
|
||||||
ORDER BY timestamp DESC;
|
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"],
|
"config_type": r["config_type"],
|
||||||
"timestamp": r["timestamp"].isoformat(),
|
"timestamp": r["timestamp"].isoformat(),
|
||||||
"snapshot_name": r["snapshot_name"],
|
"snapshot_name": r["snapshot_name"],
|
||||||
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
|
"description": r["description"],
|
||||||
"is_auto": r["is_auto"]
|
"is_auto": r["is_auto"],
|
||||||
|
"is_pinned": r["is_pinned"] # 🌟 放入回傳字典
|
||||||
}
|
}
|
||||||
for r in records
|
for r in records
|
||||||
]
|
]
|
||||||
|
|
@ -350,3 +351,111 @@ async def delete_config_backup(backup_id: str) -> bool:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
|
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
|
||||||
return False
|
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}
|
||||||
|
|
@ -539,10 +539,11 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
||||||
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
|
<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: 8%; text-align: center;">保護</th> <!-- 🌟 新增:獨立的保護欄位 -->
|
||||||
<th style="padding: 10px; color: #34495e; width: 25%;">描述</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; 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="backup-history-tbody">
|
<tbody id="backup-history-tbody">
|
||||||
|
|
|
||||||
10
init_db.py
10
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 資料表與索引...")
|
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS config_backups (
|
CREATE TABLE IF NOT EXISTS config_backups (
|
||||||
|
|
@ -90,17 +90,25 @@ async def init_database(force_reset: bool = False):
|
||||||
snapshot_name VARCHAR(255),
|
snapshot_name VARCHAR(255),
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- 🌟 新增:釘選防護欄位
|
||||||
raw_cli TEXT,
|
raw_cli TEXT,
|
||||||
parsed_tree JSONB,
|
parsed_tree JSONB,
|
||||||
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 建立基礎查詢索引
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
||||||
ON config_backups (host, config_type, timestamp DESC);
|
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()
|
await conn.close()
|
||||||
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import asyncssh
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
@ -11,10 +11,14 @@ from logger import get_logger
|
||||||
|
|
||||||
# 引入 DB 函數
|
# 引入 DB 函數
|
||||||
from database import (
|
from database import (
|
||||||
|
get_pool,
|
||||||
insert_config_backup,
|
insert_config_backup,
|
||||||
get_config_backup_list,
|
get_config_backup_list,
|
||||||
get_config_backup_detail,
|
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
|
from cmts_scraper import fetch_raw_config
|
||||||
|
|
@ -149,23 +153,35 @@ async def delete_backup(backup_id: str):
|
||||||
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
||||||
return {"status": "success", "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="手動建立設備快照 (串流版)")
|
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
|
||||||
async def create_snapshot(req: SnapshotRequest):
|
async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
|
||||||
async def backup_streamer():
|
async def backup_streamer():
|
||||||
try:
|
try:
|
||||||
# 1. 廣播:正在連線
|
pool = await get_pool()
|
||||||
|
|
||||||
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# 2. 廣播:正在下載
|
|
||||||
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
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)
|
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
||||||
|
|
||||||
# 3. 廣播:正在解析
|
|
||||||
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
||||||
|
|
||||||
# 4. 廣播:寫入資料庫
|
|
||||||
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
||||||
backup_id = await insert_config_backup(
|
backup_id = await insert_config_backup(
|
||||||
host=req.host,
|
host=req.host,
|
||||||
|
|
@ -181,12 +197,23 @@ async def create_snapshot(req: SnapshotRequest):
|
||||||
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
||||||
return
|
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({
|
yield json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
||||||
"backup_id": backup_id,
|
"backup_id": backup_id,
|
||||||
"host": req.host
|
"host": req.host,
|
||||||
|
"metrics": metrics # 🌟 包含容量指標
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -227,3 +227,14 @@ export async function apiVerifyGodMode(password) {
|
||||||
}
|
}
|
||||||
return response.json();
|
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();
|
||||||
|
}
|
||||||
109
static/app.js
109
static/app.js
|
|
@ -18,7 +18,8 @@ import {
|
||||||
apiGetLogLevels, apiSetLogLevel,
|
apiGetLogLevels, apiSetLogLevel,
|
||||||
apiVerifyGodMode,
|
apiVerifyGodMode,
|
||||||
apiGetLeafOptions, // 🌟 補上這個
|
apiGetLeafOptions, // 🌟 補上這個
|
||||||
apiSyncLeafOptions // 🌟 補上這個
|
apiSyncLeafOptions, // 🌟 補上這個
|
||||||
|
apiToggleBackupPin // 🌟 新增這行
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
|
|
||||||
// 3. 共用工具模組 (Utils)
|
// 3. 共用工具模組 (Utils)
|
||||||
|
|
@ -1468,7 +1469,7 @@ async function saveSystemSettings() {
|
||||||
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
||||||
let allBackupsData = [];
|
let allBackupsData = [];
|
||||||
|
|
||||||
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
|
// 🌟 修正版:手動建立設備快照 (解決容量看板型別解析 Bug)
|
||||||
async function createSnapshot() {
|
async function createSnapshot() {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
||||||
|
|
@ -1487,10 +1488,10 @@ async function createSnapshot() {
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
msg.style.display = 'inline-block';
|
msg.style.display = 'inline-block';
|
||||||
msg.style.color = '#f39c12'; // 🌟 確保橘色
|
msg.style.color = '#f39c12'; // 確保橘色
|
||||||
msg.innerHTML = '⏳ 準備連線至設備...';
|
msg.innerHTML = '⏳ 準備連線至設備...';
|
||||||
|
|
||||||
let progressInterval = null; // 🌟 新增:進度條計時器
|
let progressInterval = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backups/snapshot', {
|
const response = await fetch('/api/v1/backups/snapshot', {
|
||||||
|
|
@ -1512,7 +1513,7 @@ async function createSnapshot() {
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let isSuccess = false;
|
let isSuccess = false;
|
||||||
let finalMessage = "";
|
let finalData = null; // 🌟 修正:改為儲存完整的 JSON 物件,而非只有字串
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
|
|
@ -1527,7 +1528,6 @@ async function createSnapshot() {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line);
|
const data = JSON.parse(line);
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
// 🌟 導入百分比動畫邏輯
|
|
||||||
if (data.message.includes('正在下載')) {
|
if (data.message.includes('正在下載')) {
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
if (progressInterval) clearInterval(progressInterval);
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
|
|
@ -1556,7 +1556,7 @@ async function createSnapshot() {
|
||||||
}
|
}
|
||||||
} else if (data.status === 'success') {
|
} else if (data.status === 'success') {
|
||||||
isSuccess = true;
|
isSuccess = true;
|
||||||
finalMessage = data.message;
|
finalData = data; // 🌟 修正:儲存完整的成功資料物件 (包含 metrics)
|
||||||
} else if (data.status === 'error') {
|
} else if (data.status === 'error') {
|
||||||
throw new Error(data.message);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
@ -1568,7 +1568,23 @@ async function createSnapshot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSuccess) {
|
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 = '';
|
document.getElementById('snapshotName').value = '';
|
||||||
if (document.getElementById('snapshotDescription')) {
|
if (document.getElementById('snapshotDescription')) {
|
||||||
document.getElementById('snapshotDescription').value = '';
|
document.getElementById('snapshotDescription').value = '';
|
||||||
|
|
@ -1578,7 +1594,7 @@ async function createSnapshot() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`❌ 發生錯誤: ${error.message}`);
|
alert(`❌ 發生錯誤: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
msg.style.display = 'none';
|
msg.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -1594,7 +1610,7 @@ async function loadBackupHistory() {
|
||||||
if (!tbody) return;
|
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 {
|
try {
|
||||||
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
||||||
|
|
@ -1652,29 +1668,48 @@ window.applyBackupFilters = function() {
|
||||||
|
|
||||||
// 渲染過濾後的結果
|
// 渲染過濾後的結果
|
||||||
if (filteredData.length > 0) {
|
if (filteredData.length > 0) {
|
||||||
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
|
|
||||||
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||||||
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
||||||
|
|
||||||
tbody.innerHTML = filteredData.map(item => `
|
tbody.innerHTML = filteredData.map(item => {
|
||||||
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
// 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
const pinIcon = item.is_pinned
|
||||||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
? `<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>`;
|
||||||
<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>'}
|
return `
|
||||||
</td>
|
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
||||||
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
<!-- 1. 時間 -->
|
||||||
<td style="padding: 12px 10px; text-align: right;">
|
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
||||||
<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 樣式,只有解鎖後才會出現 -->
|
<!-- 2. 🌟 獨立的保護 (釘選) 欄位,採水平與垂直置中對齊 -->
|
||||||
<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>
|
<td style="padding: 12px 10px; text-align: center; vertical-align: middle;">
|
||||||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
${pinIcon}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
`).join('');
|
<!-- 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>
|
||||||
|
<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('');
|
||||||
} else {
|
} else {
|
||||||
// 🟢 修正:沒有資料時,colspan 改為 5
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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. 請求設備差異分析 (安全還原第一步)
|
// 5. 請求設備差異分析 (安全還原第一步)
|
||||||
// 🌟 接收第三個參數 backupHost
|
// 🌟 接收第三個參數 backupHost
|
||||||
async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
||||||
|
|
@ -2219,3 +2269,4 @@ window.deleteBackup = deleteBackup;
|
||||||
window.restoreBackup = restoreBackup;
|
window.restoreBackup = restoreBackup;
|
||||||
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
|
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
|
||||||
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
|
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
|
||||||
|
window.togglePin = togglePin;
|
||||||
|
|
@ -303,3 +303,5 @@ button:hover { background-color: #3498db; }
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: 0 24px;
|
contain-intrinsic-size: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }
|
||||||
Loading…
Reference in New Issue