-
- 🗂️ 歷史備份紀錄
+
+
+
+
+ 📁 歷史備份紀錄
-
-
+
+
-
-
- | 時間 |
- 快照名稱 |
- 類型 |
- 操作 |
+ 時間 |
+ 快照名稱 |
+ 描述 |
+ 類型 |
+ 操作 |
- | 請點擊「重新整理」載入歷史紀錄... |
+ 請點擊「重新整理」載入歷史紀錄... |
diff --git a/routers/backup.py b/routers/backup.py
index 0bf22b7..7a9a1ab 100644
--- a/routers/backup.py
+++ b/routers/backup.py
@@ -32,6 +32,7 @@ class SnapshotRequest(BaseModel):
username: str
password: str
snapshot_name: str
+ description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述
config_type: str = "running"
class RestoreRequest(BaseModel):
@@ -48,6 +49,10 @@ class ExecuteRestoreRequest(BaseModel):
# ==========================================
# 🛠️ 核心演算法:深度差異比對 (Deep Diff)
# ==========================================
+# 🌟 新增小工具:拔除 [0], [1] 虛擬後綴,還原真實 CLI
+def clean_virtual_key(key: str) -> str:
+ return re.sub(r'\s*\[\d+\]$', '', key)
+
def build_add_commands(node: dict, current_path: str) -> list:
"""遞迴展開所有需要新增的絕對路徑指令"""
commands = []
@@ -56,11 +61,13 @@ def build_add_commands(node: dict, current_path: str) -> list:
return commands
for key, val in node.items():
- # 🌟 新增這行:忽略系統中介資料
if key.startswith('_'):
continue
- path = f"{current_path} {key}".strip()
+ # 🌟 使用乾淨的 key 來組合路徑
+ real_key = clean_virtual_key(key)
+ path = f"{current_path} {real_key}".strip()
+
if isinstance(val, dict):
commands.extend(build_add_commands(val, path))
else:
@@ -72,23 +79,21 @@ def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path
"""比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
commands = []
- # 1. 找出需要刪除或覆蓋的 (存在於 current)
for key, curr_val in current_node.items():
- # 🌟 新增這行:忽略系統中介資料
if key.startswith('_'):
continue
- path = f"{current_path} {key}".strip()
+ # 🌟 使用乾淨的 key
+ real_key = clean_virtual_key(key)
+ path = f"{current_path} {real_key}".strip()
if key not in snapshot_node:
- # 快照裡沒有,代表這是後來新增的殘留設定 -> 刪除
commands.append(f"no {path}")
else:
snap_val = snapshot_node[key]
if isinstance(curr_val, dict) and isinstance(snap_val, dict):
commands.extend(generate_diff_commands(curr_val, snap_val, path))
elif isinstance(curr_val, dict) or isinstance(snap_val, dict):
- # 型態不一致,先刪除舊的再新增新的
commands.append(f"no {path}")
if isinstance(snap_val, dict):
commands.extend(build_add_commands(snap_val, path))
@@ -96,17 +101,17 @@ def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path
val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}")
elif curr_val != snap_val:
- # 兩邊都是最終值,但內容不同 -> 直接覆寫
val_str = f" {snap_val}" if snap_val else ""
commands.append(f"{path}{val_str}")
- # 2. 找出需要新增的 (存在於 snapshot,但 current 沒有)
for key, snap_val in snapshot_node.items():
- # 🌟 新增這行:忽略系統中介資料
if key.startswith('_'):
continue
- path = f"{current_path} {key}".strip()
+ # 🌟 使用乾淨的 key
+ real_key = clean_virtual_key(key)
+ path = f"{current_path} {real_key}".strip()
+
if key not in current_node:
if isinstance(snap_val, dict):
commands.extend(build_add_commands(snap_val, path))
@@ -152,6 +157,7 @@ async def create_snapshot(req: SnapshotRequest):
raw_cli=raw_cli,
parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name,
+ description=req.description, # 🟢 將描述傳遞給資料庫函數
is_auto=False
)
@@ -180,7 +186,12 @@ async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
if not snapshot_tree:
return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
- raw_current = await fetch_raw_config(req.host, req.username, req.password, "running")
+ # 🌟 關鍵修正:動態取得該快照的 config_type,避免蘋果比橘子
+ snapshot_config_type = backup_record.get("config_type", "running")
+
+ # 🌟 關鍵修正:將硬編碼的 "running" 改為 snapshot_config_type
+ raw_current = await fetch_raw_config(req.host, req.username, req.password, snapshot_config_type)
+
if not raw_current:
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
diff --git a/static/app.js b/static/app.js
index b067abe..48828e7 100644
--- a/static/app.js
+++ b/static/app.js
@@ -346,8 +346,12 @@ function resetAllManagerData() {
document.getElementById('queryTargetCm').value = '';
document.getElementById('queryTargetRpd').value = '';
-}
+ // 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
+ if (typeof loadBackupHistory === 'function') {
+ loadBackupHistory();
+ }
+}
// ============================================================================
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
@@ -823,14 +827,24 @@ async function saveSystemSettings() {
// 🌟 新增:設備備份與還原 (Backup & Restore)
// ============================================================================
+// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
+let allBackupsData = [];
+
// 1. 建立新快照
async function createSnapshot() {
+ // 🛡️ 防線一:必須先連線才能備份
+ if (!isConnected) {
+ return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
+ }
+
const connInfo = getGlobalConnectionInfo();
- if (!connInfo || !connInfo.host) return alert("請先在畫面上方輸入設備連線資訊!");
+ if (!connInfo || !connInfo.host) return;
const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!");
+ // 🟢 抓取描述輸入框的值
+ const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot');
const msg = document.getElementById('backup-status-msg');
@@ -847,6 +861,7 @@ async function createSnapshot() {
username: connInfo.user,
password: connInfo.pass,
snapshot_name: snapshotName,
+ description: snapshotDescription, // 🟢 將描述傳給後端
config_type: configType
})
});
@@ -854,7 +869,10 @@ async function createSnapshot() {
if (result.status === 'success') {
alert(`✅ ${result.message}`);
- document.getElementById('snapshotName').value = ''; // 清空輸入框
+ document.getElementById('snapshotName').value = ''; // 清空名稱輸入框
+ if (document.getElementById('snapshotDescription')) {
+ document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框
+ }
loadBackupHistory(); // 重新載入歷史紀錄
} else {
alert(`❌ 備份失敗: ${result.message}`);
@@ -867,44 +885,95 @@ async function createSnapshot() {
}
}
-// 2. 載入歷史紀錄列表
+// 2. 載入歷史紀錄列表 (維持 IP 綁定,避免後端報錯)
async function loadBackupHistory() {
+ // 🌟 補回取得當前畫面上設定的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return;
- tbody.innerHTML = '
| ⏳ 正在載入歷史紀錄... |
';
+ tbody.innerHTML = '
| ⏳ 正在載入歷史紀錄... |
';
try {
- const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=running`);
+ // 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
+ const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=all`);
const result = await response.json();
- if (result.status === 'success' && result.data.length > 0) {
- tbody.innerHTML = result.data.map(item => `
-
- | ${new Date(item.timestamp).toLocaleString()} |
- ${item.snapshot_name} |
- ${item.config_type} |
-
-
-
-
-
-
-
- |
-
- `).join('');
+ if (result.status === 'success') {
+ allBackupsData = result.data;
+ applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染
} else {
- tbody.innerHTML = '
| 尚無任何備份紀錄。 |
';
+ // 加入防呆:如果後端回傳 422 (detail),也能正確印出錯誤而不是 undefined
+ const errMsg = result.message || (result.detail ? JSON.stringify(result.detail) : '未知錯誤');
+ tbody.innerHTML = `
| ❌ 載入失敗: ${errMsg} |
`;
}
} catch (error) {
- tbody.innerHTML = `
| ❌ 載入失敗: ${error.message} |
`;
+ tbody.innerHTML = `
| ❌ 連線失敗: ${error.message} |
`;
}
}
+// 💡 前端多維度過濾器邏輯
+window.applyBackupFilters = function() {
+ const tbody = document.getElementById('backup-history-tbody');
+ if (!tbody) return;
+
+ // 取得所有過濾條件 (加上 trim() 避免不小心多敲了空白鍵)
+ const keyword = (document.getElementById('filter-keyword')?.value || '').trim().toLowerCase();
+ const type = document.getElementById('filter-type')?.value || '';
+ const dateStart = document.getElementById('filter-date-start')?.value;
+ const dateEnd = document.getElementById('filter-date-end')?.value;
+
+ // 進行陣列過濾
+ const filteredData = allBackupsData.filter(item => {
+ // 💡 終極防呆版:確保變數存在,並同時搜尋名稱與描述
+ const safeName = (item.snapshot_name || '').toLowerCase();
+ const safeDesc = (item.description || '').toLowerCase();
+ const matchKeyword = safeName.includes(keyword) || safeDesc.includes(keyword);
+
+ const matchType = type === '' || item.config_type === type;
+
+ let matchDate = true;
+ const itemDate = new Date(item.timestamp);
+ if (dateStart) {
+ const start = new Date(dateStart);
+ start.setHours(0, 0, 0, 0);
+ if (itemDate < start) matchDate = false;
+ }
+ if (dateEnd) {
+ const end = new Date(dateEnd);
+ end.setHours(23, 59, 59, 999);
+ if (itemDate > end) matchDate = false;
+ }
+
+ return matchKeyword && matchType && matchDate;
+ });
+
+ // 渲染過濾後的結果
+ if (filteredData.length > 0) {
+ tbody.innerHTML = filteredData.map(item => `
+
+ | ${new Date(item.timestamp).toLocaleString()} |
+ ${item.snapshot_name} |
+
+
+ ${item.description || '無'}
+ |
+ ${item.config_type} |
+
+
+
+
+ |
+
+ `).join('');
+ } else {
+ // 🟢 修正:沒有資料時,colspan 改為 5
+ tbody.innerHTML = '
| 找不到符合條件的備份紀錄。 |
';
+ }
+};
+
// 3. 檢視特定快照內容
async function viewBackup(backupId) {
openModal("載入快照中...");
@@ -916,8 +985,12 @@ async function viewBackup(backupId) {
const result = await response.json();
if (result.status === 'success') {
- const data = result.data;
- const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`;
+ // 確保這行真的有加進去,且檔案有按下 Ctrl+S 儲存!
+ const data = result.data;
+
+ // 🟤 將描述加入彈出視窗的標頭中
+ const descText = data.description ? data.description : '無';
+ const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【備份描述】: ${descText}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`;
// 如果後端有回傳 raw_cli 就優先顯示,否則將 parsed_tree 轉為字串顯示
const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2);
@@ -953,72 +1026,172 @@ async function deleteBackup(backupId) {
}
// 5. 請求設備差異分析 (安全還原第一步)
-async function restoreBackup(snapshotId, snapshotName) {
- const connInfo = getGlobalConnectionInfo();
- if (!connInfo || !connInfo.host) {
- return alert("請先連線至 CMTS 設備!");
+// 🌟 接收第三個參數 backupHost
+async function restoreBackup(snapshotId, snapshotName, backupHost) {
+ // 🛡️ 防線一:必須先連線
+ if (!isConnected) {
+ return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行還原任務!");
+ }
+
+ const connInfo = getGlobalConnectionInfo();
+ if (!connInfo || !connInfo.host) return;
+
+ // 🛡️ 防線二:嚴格阻斷跨設備還原
+ if (backupHost && backupHost !== connInfo.host) {
+ alert(
+ `🚨 【跨設備還原阻斷】🚨\n\n` +
+ `這份快照屬於設備:${backupHost}\n` +
+ `您目前連線的設備:${connInfo.host}\n\n` +
+ `⚠️ 系統目前禁止跨設備還原,以防止嚴重的網路衝突。請先連線至正確的設備再執行此操作!`
+ );
+ return; // 直接中斷,不進行後續 API 呼叫
}
- // 開啟 Modal 顯示分析進度
const modal = document.getElementById('outputModal');
const modalOutput = document.getElementById('modalOutput');
const modalTargetInfo = document.getElementById('modalTargetInfo');
+ // 1. 移除進度條,改為純文字百分比動態顯示
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
- modalOutput.innerHTML = `
🔍 正在抓取設備當前配置,並與快照進行深度比對,請稍候...`;
+ modalOutput.innerHTML = `
+
🔍 正在抓取設備當前配置,並與快照進行深度比對... (0%)
+ `;
modal.classList.add('active');
+ // 啟動漸進式模擬進度邏輯 (最高停在 95%)
+ let progress = 0;
+ const progressInterval = setInterval(() => {
+ progress += (95 - progress) * 0.08;
+ const text = document.getElementById('diff-progress-text');
+ if (text) {
+ text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (${Math.round(progress)}%)`;
+ }
+ }, 400);
+
try {
- // 呼叫後端全新的 Diff API (我們接下來要實作的)
const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- host: connInfo.host,
- username: connInfo.user,
- password: connInfo.pass
- })
+ body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass })
});
const result = await response.json();
-
- if (result.status === 'success') {
- // 渲染差異預覽畫面
- let diffHtml = `
以下是將設備還原至 ${snapshotName} 所需執行的指令清單:
`;
-
- if (result.data.commands.length === 0) {
- diffHtml += `
✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!
`;
- } else {
- diffHtml += `
`;
- result.data.commands.forEach(cmd => {
- if (cmd.startsWith('no ')) {
- diffHtml += `- ${cmd}\n`; // 刪除用紅色
- } else {
- diffHtml += `+ ${cmd}\n`; // 新增/修改用綠色
- }
- });
- diffHtml += ``;
-
- // 加入最終確認按鈕
- diffHtml += `
-
-
-
- `;
- }
- modalOutput.innerHTML = diffHtml;
- } else {
- modalOutput.innerHTML = `
❌ 差異分析失敗:\n${result.message}`;
+
+ // API 回傳後,清除計時器並將進度填滿 100%
+ clearInterval(progressInterval);
+ const text = document.getElementById('diff-progress-text');
+ if (text) {
+ text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (100%) - 分析完成!`;
}
+
+ // 稍微延遲 0.5 秒讓使用者看清楚 100%,再渲染結果
+ setTimeout(() => {
+ if (result.status === 'success') {
+ const commands = result.data.commands;
+ let diffHtml = `
以下是將設備還原至 ${snapshotName} 所需執行的指令清單:
`;
+
+ if (commands.length === 0) {
+ diffHtml += `
✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!
`;
+ } else {
+ const safeCommands = JSON.stringify(commands).replace(/"/g, '"');
+
+ // 2. 更改按鈕顏色:使用亮藍色 (#2980b9) 並加上陰影與邊框,凸顯立體感
+ modalTargetInfo.innerHTML = `
+ 正在分析快照差異: ${snapshotName}
+
+
+
+
+
+ `;
+
+ diffHtml += `
`;
+ commands.forEach(cmd => {
+ if (cmd.startsWith('no ')) diffHtml += `- ${cmd}\n`;
+ else diffHtml += `+ ${cmd}\n`;
+ });
+ diffHtml += ``;
+
+ diffHtml += `
`;
+ commands.forEach(cmd => { diffHtml += `${cmd}\n`; });
+ diffHtml += `commit\n`;
+ diffHtml += ``;
+ }
+ modalOutput.innerHTML = diffHtml;
+ } else {
+ modalOutput.innerHTML = `
❌ 差異分析失敗:\n${result.message}`;
+ }
+ }, 500);
+
} catch (error) {
+ clearInterval(progressInterval);
modalOutput.innerHTML = `
❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}`;
}
}
-// 6. 真正執行差異還原 (掛載到 window 供按鈕呼叫)
-window.executeSmartRestore = async function(snapshotId, snapshotName) {
- // 這裡將會呼叫我們原本設計好的寫入 API
- alert("即將實作:將上方預覽的指令寫入設備!");
+// 6. 真正執行差異還原 (串接後端 API)
+window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
+ if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit!\n確定要繼續嗎?`)) return;
+
+ // 優雅地將按鈕反灰 (Disabled),而不是消失
+ const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore'];
+ btnIds.forEach(id => {
+ const btn = document.getElementById(id);
+ if (btn) {
+ btn.disabled = true;
+ btn.style.opacity = '0.5';
+ btn.style.cursor = 'not-allowed';
+ btn.style.pointerEvents = 'none'; // 徹底阻絕點擊事件
+ if (id === 'btn-confirm-restore') {
+ btn.innerText = '⏳ 正在寫入設備中...';
+ btn.style.backgroundColor = '#7f8c8d'; // 變成灰色
+ }
+ }
+ });
+
+ const connInfo = getGlobalConnectionInfo();
+ const modalOutput = document.getElementById('modalOutput');
+
+ // 在原本的內容上方插入提示,保留指令預覽畫面讓使用者安心
+ const loadingMsg = document.createElement('div');
+ loadingMsg.innerHTML = `
⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...
`;
+ modalOutput.insertBefore(loadingMsg, modalOutput.firstChild);
+
+ try {
+ const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
+ });
+ const result = await response.json();
+
+ if (result.status === 'success') {
+ modalOutput.innerHTML = `
+
✅ 還原成功!設備已 Commit。
+
設備回傳結果:
+
${result.output}
+ `;
+ document.getElementById('modalTargetInfo').innerHTML = `還原完成: ${snapshotName}`;
+ } else {
+ loadingMsg.innerHTML = `
❌ 還原失敗:
${result.message}`;
+ // 失敗的話,把按鈕恢復原狀,允許重試
+ btnIds.forEach(id => {
+ const btn = document.getElementById(id);
+ if (btn) {
+ btn.disabled = false;
+ btn.style.opacity = '1';
+ btn.style.cursor = 'pointer';
+ btn.style.pointerEvents = 'auto';
+ if (id === 'btn-confirm-restore') {
+ btn.innerText = '⚠️ 確認無誤,立即寫入設備並 Commit';
+ btn.style.backgroundColor = '#e74c3c';
+ }
+ }
+ });
+ }
+ } catch (error) {
+ loadingMsg.innerHTML = `
❌ 系統錯誤:
${error.message}`;
+ }
};
// ============================================================================
@@ -1168,7 +1341,7 @@ window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI;
-window.switchFilterMode = switchFilterMode; // 🌟 新增綁定
+window.switchFilterMode = switchFilterMode;
// --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll;
@@ -1192,10 +1365,5 @@ window.createSnapshot = createSnapshot;
window.loadBackupHistory = loadBackupHistory;
window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup;
-window.restoreBackup = restoreBackup; // 🌟 新增綁定還原函數
-
-// 將備份還原相關函數綁定到全域,供 HTML onclick 使用
-window.loadBackupHistory = loadBackupHistory;
-window.createSnapshot = createSnapshot;
-//window.restoreSnapshot = restoreSnapshot;
-//window.deleteSnapshot = deleteSnapshot;
\ No newline at end of file
+window.restoreBackup = restoreBackup;
+window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
\ No newline at end of file