feat: Core Backup Engine & Async Stability

This commit is contained in:
swpa 2026-05-26 18:30:10 +08:00
parent 9de6896363
commit cfc4060d2b
5 changed files with 194 additions and 2 deletions

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"editor.inlineSuggest.enabled": false,
"json.schemaDownload.enable": false
}

View File

@ -328,7 +328,7 @@ async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
return None return None
query = """ query = """
SELECT id, timestamp, snapshot_name, is_auto, parsed_tree SELECT id, host, timestamp, snapshot_name, is_auto, parsed_tree
FROM config_backups FROM config_backups
WHERE id = $1; WHERE id = $1;
""" """
@ -341,6 +341,7 @@ async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
return { return {
"id": str(record["id"]), "id": str(record["id"]),
"host": record["host"],
"timestamp": record["timestamp"].isoformat(), "timestamp": record["timestamp"].isoformat(),
"snapshot_name": record["snapshot_name"], "snapshot_name": record["snapshot_name"],
"is_auto": record["is_auto"], "is_auto": record["is_auto"],

View File

@ -36,6 +36,8 @@
<button class="tab-btn" onclick="switchTab('config-tab', this)">⚙️ CMTS 設備配置</button> <button class="tab-btn" onclick="switchTab('config-tab', this)">⚙️ CMTS 設備配置</button>
<!-- 💡 新增:系統設定頁籤 --> <!-- 💡 新增:系統設定頁籤 -->
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button> <button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
<!-- 💡 新增:備份與還原頁籤 -->
<button class="tab-btn" onclick="switchTab('backup-tab', this); loadBackupHistory();">💾 設備備份與還原</button>
</div> </div>
<!-- Tab 1: True SSH Terminal --> <!-- Tab 1: True SSH Terminal -->
@ -373,6 +375,50 @@
</div> </div>
</div> </div>
<!-- 💡 Tab 5: 設備備份與還原 -->
<div id="backup-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
<div class="manager-container">
<!-- 上半部:建立新快照 -->
<div class="control-group" style="background: #ffffff; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px;">📸 建立新快照</h3>
<div style="display: flex; align-items: center; gap: 15px;">
<input type="text" id="snapshotName" placeholder="請輸入快照名稱 (例如: 例行備份)" style="width: 300px; padding: 8px; border: 1px solid #bdc3c7; border-radius: 4px;">
<select id="backupConfigType" style="padding: 8px; border: 1px solid #bdc3c7; border-radius: 4px;">
<option value="running">Running Config</option>
</select>
<button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save">🚀 立即備份</button>
<span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none;">⏳ 正在連線設備並抓取設定,請稍候...</span>
</div>
</div>
<!-- 下半部:歷史紀錄列表 -->
<div class="control-group" style="background: #ffffff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px;">
<h3 style="margin: 0; font-size: 18px; color: #2c3e50;">🗂️ 歷史備份紀錄</h3>
<button onclick="loadBackupHistory()" class="btn-modern btn-scan" style="padding: 5px 10px; font-size: 13px;">🔄 重新整理</button>
</div>
<table style="width: 100%; border-collapse: collapse; text-align: left;">
<thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
<th style="padding: 10px; color: #34495e;">時間</th>
<th style="padding: 10px; color: #34495e;">快照名稱</th>
<th style="padding: 10px; color: #34495e;">類型</th>
<th style="padding: 10px; color: #34495e; text-align: right;">操作</th>
</tr>
</thead>
<tbody id="backup-history-tbody">
<tr>
<td colspan="4" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 獨立的彈出式輸出視窗 (Modal) --> <!-- 獨立的彈出式輸出視窗 (Modal) -->
<div id="outputModal" class="modal-overlay" onclick="closeModal(event)"> <div id="outputModal" class="modal-overlay" onclick="closeModal(event)">
<div class="modal-container" onclick="event.stopPropagation()"> <div class="modal-container" onclick="event.stopPropagation()">

View File

@ -72,7 +72,8 @@ async def create_snapshot(req: SnapshotRequest):
return { return {
"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
} }
except Exception as e: except Exception as e:

View File

@ -795,6 +795,134 @@ async function saveSystemSettings() {
} }
} }
// ============================================================================
// 🌟 新增:設備備份與還原 (Backup & Restore)
// ============================================================================
// 1. 建立新快照
async function createSnapshot() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先在畫面上方輸入設備連線資訊!");
const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!");
const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot');
const msg = document.getElementById('backup-status-msg');
btn.disabled = true;
msg.style.display = 'inline-block';
try {
const response = await fetch('/api/v1/backups/snapshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass,
snapshot_name: snapshotName,
config_type: configType
})
});
const result = await response.json();
if (result.status === 'success') {
alert(`${result.message}`);
document.getElementById('snapshotName').value = ''; // 清空輸入框
loadBackupHistory(); // 重新載入歷史紀錄
} else {
alert(`❌ 備份失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
} finally {
btn.disabled = false;
msg.style.display = 'none';
}
}
// 2. 載入歷史紀錄列表
async function loadBackupHistory() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="padding: 20px; text-align: center; color: #7f8c8d;">⏳ 正在載入歷史紀錄...</td></tr>';
try {
const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=running`);
const result = await response.json();
if (result.status === 'success' && result.data.length > 0) {
tbody.innerHTML = result.data.map(item => `
<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: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
<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>
<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="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑 刪除</button>
</td>
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="4" style="padding: 20px; text-align: center; color: #7f8c8d;">尚無任何備份紀錄。</td></tr>';
}
} catch (error) {
tbody.innerHTML = `<tr><td colspan="4" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 載入失敗: ${error.message}</td></tr>`;
}
}
// 3. 檢視特定快照內容
async function viewBackup(backupId) {
openModal("載入快照中...");
const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">⏳ 正在向資料庫撈取快照資料,請稍候...</span>`;
try {
const response = await fetch(`/api/v1/backups/${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`;
// 如果後端有回傳 raw_cli 就優先顯示,否則將 parsed_tree 轉為字串顯示
const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2);
outputEl.style.color = "#e0e0e0";
outputEl.textContent = infoHeader + content;
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 讀取失敗: ${result.message}`;
}
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線失敗: ${error.message}`;
}
}
// 4. 刪除快照
async function deleteBackup(backupId) {
if (!confirm("⚠️ 確定要永久刪除這筆備份紀錄嗎?此動作無法復原!")) return;
try {
const response = await fetch(`/api/v1/backups/${backupId}`, { method: 'DELETE' });
const result = await response.json();
if (result.status === 'success') {
loadBackupHistory(); // 刪除成功後重新整理列表
} else {
alert(`❌ 刪除失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
}
}
// ============================================================================ // ============================================================================
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States) // 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
@ -961,3 +1089,15 @@ window.executeSideCLI = executeSideCLI;
window.toggleChildCheckboxes = toggleChildCheckboxes; window.toggleChildCheckboxes = toggleChildCheckboxes;
window.updateParentCheckboxState = updateParentCheckboxState; window.updateParentCheckboxState = updateParentCheckboxState;
window.closeModal = closeModal; window.closeModal = closeModal;
// --- 備份與還原 ---
window.createSnapshot = createSnapshot;
window.loadBackupHistory = loadBackupHistory;
window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup;
// 將備份還原相關函數綁定到全域,供 HTML onclick 使用
window.loadBackupHistory = loadBackupHistory;
window.createSnapshot = createSnapshot;
//window.restoreSnapshot = restoreSnapshot;
//window.deleteSnapshot = deleteSnapshot;